1package api 2 3import ( 4 "encoding/json" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "reflect" 9 "regexp" 10 "slices" 11 "strconv" 12 "strings" 13 "time" 14 15 "github.com/go-chi/chi/v5" 16 "github.com/go-chi/chi/v5/middleware" 17 "github.com/go-chi/cors" 18 "github.com/go-chi/httprate" 19 _ "github.com/lib/pq" 20 did "github.com/whyrusleeping/go-did" 21 22 "tangled.sh/seiso.moe/alethia.directory/ent" 23 "tangled.sh/seiso.moe/alethia.directory/ent/operation" 24 "tangled.sh/seiso.moe/alethia.directory/ent/syncstatus" 25 "tangled.sh/seiso.moe/alethia.directory/pkg/plc" 26) 27 28var ( 29 validDIDPattern = regexp.MustCompile(`[a-z0-9:]{24}`) 30) 31 32type Server struct { 33 client *ent.Client 34 logger *slog.Logger 35 port int 36} 37 38type DidDocService struct { 39 ID string `json:"id"` 40 Type string `json:"type"` 41 ServiceEndpoint string `json:"serviceEndpoint"` 42} 43 44type DidDocument struct { 45 Context []string `json:"@context"` 46 ID string `json:"id"` 47 AlsoKnownAs []string `json:"alsoKnownAs"` 48 VerificationMethod []VerificationMethod `json:"verificationMethod"` 49 Service []DidDocService `json:"service"` 50} 51 52type VerificationMethod struct { 53 ID string 54 Type string 55 Controller string 56 PublicKeyMultibase string 57} 58 59type KeyAndContext struct { 60 Context *string 61 Type string 62 PublicKeyMultiBase string 63} 64 65func NewServer(client *ent.Client, logger *slog.Logger, port int) *Server { 66 return &Server{ 67 client: client, 68 logger: logger, 69 port: port, 70 } 71} 72 73func (s *Server) Start() error { 74 r := chi.NewRouter() 75 r.Use(middleware.Logger) 76 77 r.Use(cors.Handler(cors.Options{ 78 AllowedOrigins: []string{"https://*", "http://*"}, 79 AllowedMethods: []string{"GET"}, 80 AllowCredentials: false, 81 })) 82 83 // probally overkill but meh 84 r.Use(httprate.LimitByIP(500, 1*time.Minute)) 85 86 // unofficial endpoints 87 r.Get("/_health", s.handleHealth) 88 89 // official endpoints 90 r.Get("/{did}", s.handleDid) 91 r.Get("/{did}/log", s.handleLog) 92 r.Get("/{did}/log/audit", s.handleAudit) 93 r.Get("/{did}/log/last", s.handleLastOp) 94 r.Get("/{did}/data", s.handlePlcData) 95 r.Get("/export", s.handleExport) 96 97 s.logger.Info("starting API server", "port", s.port) 98 return http.ListenAndServe(fmt.Sprintf(":%d", s.port), r) 99} 100 101func validDID(did string) bool { 102 return validDIDPattern.MatchString(did) 103} 104 105func formatKeyAndContext(key string) KeyAndContext { 106 keyInfo, err := did.PubKeyFromDIDString(key) 107 if err != nil { 108 return KeyAndContext{ 109 Context: nil, 110 Type: "Multikey", 111 PublicKeyMultiBase: strings.ReplaceAll(key, "did:key:", ""), 112 } 113 } 114 115 switch keyInfo.Type { 116 case did.KeyTypeSecp256k1: 117 str := "https://w3id.org/security/suites/secp256k1-2019/v1" 118 return KeyAndContext{ 119 Context: &str, 120 Type: "MultiKey", 121 PublicKeyMultiBase: strings.ReplaceAll(key, "did:key:", ""), 122 } 123 case did.KeyTypeP256: 124 str := "https://w3id.org/security/suites/secp256k1-2019/v1" 125 return KeyAndContext{ 126 Context: &str, 127 Type: "MultiKey", 128 PublicKeyMultiBase: strings.ReplaceAll(key, "did:key:", ""), 129 } 130 default: 131 str := "https://w3id.org/security/suites/ecdsa-2019/v1" 132 return KeyAndContext{ 133 Context: &str, 134 Type: "MultiKey", 135 PublicKeyMultiBase: strings.ReplaceAll(key, "did:key:", ""), 136 } 137 } 138} 139 140func formatDidDoc(did string, operation plc.PLCOperation) DidDocument { 141 context := []string{ 142 "https://www.w3.org/ns/did/v1", 143 "https://w3id.org/security/multikey/v1", 144 } 145 146 verificationMethods := make([]VerificationMethod, 0, len(operation.VerificationMethods)) 147 for k, v := range operation.VerificationMethods { 148 info := formatKeyAndContext(v) 149 if info.Context != nil && !slices.Contains(context, *info.Context) { 150 context = append(context, *info.Context) 151 } 152 verificationMethods = append(verificationMethods, VerificationMethod{ 153 ID: fmt.Sprintf("%s#%s", did, k), 154 Type: info.Type, 155 Controller: did, 156 PublicKeyMultibase: info.PublicKeyMultiBase, 157 }) 158 } 159 160 services := make([]DidDocService, 0, len(operation.Services)) 161 for k, v := range operation.Services { 162 services = append(services, DidDocService{ 163 ID: fmt.Sprintf("#%s", k), 164 Type: v.Type, 165 ServiceEndpoint: v.Endpoint, 166 }) 167 } 168 169 return DidDocument{ 170 Context: context, 171 ID: did, 172 AlsoKnownAs: operation.AlsoKnownAs, 173 VerificationMethod: verificationMethods, 174 Service: services, 175 } 176} 177 178func (s *Server) handleDid(w http.ResponseWriter, r *http.Request) { 179 ctx := r.Context() 180 did := r.PathValue("did") 181 182 if !validDID(did) { 183 s.writeErrorResponse(w, http.StatusNotFound, fmt.Sprintf("DID not registered: %s", did)) 184 return 185 } 186 187 n, err := s.client.Operation.Query(). 188 Where(operation.Did(did)). 189 Where(operation.NullifiedEQ(false)). 190 Order(ent.Desc(operation.FieldCreatedAt)). 191 First(ctx) 192 193 if err != nil { 194 if ent.IsNotFound(err) { 195 s.writeErrorResponse(w, http.StatusNotFound, fmt.Sprintf("DID not registered: %s", did)) 196 return 197 } 198 s.logger.Error("database query failed", "error", err) 199 w.WriteHeader(http.StatusInternalServerError) 200 return 201 } 202 203 if n.Operation.GetType() == plc.OperationTypeTombstone { 204 s.writeErrorResponse(w, http.StatusOK, fmt.Sprintf("DID not available: %s", did)) 205 return 206 } 207 208 didDoc := formatDidDoc(did, n.Operation) 209 s.writeJSONResponse(w, http.StatusOK, didDoc) 210} 211 212func (s *Server) handlePlcData(w http.ResponseWriter, r *http.Request) { 213 ctx := r.Context() 214 did := r.PathValue("did") 215 216 if !validDID(did) { 217 s.writeErrorResponse(w, http.StatusNotFound, fmt.Sprintf("DID not registered: %s", did)) 218 return 219 } 220 221 n, err := s.client.Operation.Query(). 222 Where(operation.Did(did)). 223 Where(operation.NullifiedEQ(false)). 224 Order(ent.Desc(operation.FieldCreatedAt)). 225 First(ctx) 226 227 if err != nil { 228 if ent.IsNotFound(err) { 229 s.writeErrorResponse(w, http.StatusNotFound, fmt.Sprintf("DID not registered: %s", did)) 230 return 231 } 232 s.logger.Error("database query failed", "error", err) 233 w.WriteHeader(http.StatusInternalServerError) 234 return 235 } 236 237 if n.Operation.GetType() == plc.OperationTypeTombstone { 238 s.writeErrorResponse(w, http.StatusOK, fmt.Sprintf("DID not available: %s", did)) 239 return 240 } 241 242 resp := struct { 243 did string 244 VerificationMethods map[string]string 245 RotationKeys []string 246 AlsoKnownAs []string 247 Services map[string]plc.ServiceEndpoint 248 }{ 249 did: did, 250 VerificationMethods: n.Operation.VerificationMethods, 251 RotationKeys: n.Operation.RotationKeys, 252 AlsoKnownAs: n.Operation.AlsoKnownAs, 253 Services: n.Operation.Services, 254 } 255 s.writeJSONResponse(w, http.StatusOK, resp) 256} 257 258func (s *Server) handleLog(w http.ResponseWriter, r *http.Request) { 259 ctx := r.Context() 260 did := r.PathValue("did") 261 262 if !validDID(did) { 263 s.writeErrorResponse(w, http.StatusNotFound, fmt.Sprintf("DID not registered: %s", did)) 264 return 265 } 266 267 n, err := s.client.Operation.Query(). 268 Where(operation.Did(did)). 269 Where(operation.NullifiedEQ(false)). 270 Order(ent.Asc(operation.FieldCreatedAt)). 271 All(ctx) 272 273 if err != nil { 274 if ent.IsNotFound(err) { 275 s.writeErrorResponse(w, http.StatusNotFound, fmt.Sprintf("DID not registered: %s", did)) 276 return 277 } 278 s.logger.Error("database query failed", "error", err) 279 w.WriteHeader(http.StatusInternalServerError) 280 return 281 } 282 283 ops := make([]any, 0, len(n)) 284 for _, v := range n { 285 ops = append(ops, v.Operation) 286 } 287 288 s.writeJSONResponse(w, http.StatusOK, ops) 289} 290 291func (s *Server) handleAudit(w http.ResponseWriter, r *http.Request) { 292 ctx := r.Context() 293 did := r.PathValue("did") 294 295 if !validDID(did) { 296 s.writeErrorResponse(w, http.StatusNotFound, fmt.Sprintf("DID not registered: %s", did)) 297 return 298 } 299 300 n, err := s.client.Operation.Query(). 301 Where(operation.Did(did)). 302 Where(operation.NullifiedEQ(false)). 303 Order(ent.Asc(operation.FieldCreatedAt)). 304 All(ctx) 305 306 if err != nil { 307 if ent.IsNotFound(err) { 308 s.writeErrorResponse(w, http.StatusNotFound, fmt.Sprintf("DID not registered: %s", did)) 309 return 310 } 311 s.logger.Error("database query failed", "error", err) 312 w.WriteHeader(http.StatusInternalServerError) 313 return 314 } 315 316 s.writeJSONResponse(w, http.StatusOK, n) 317} 318 319func (s *Server) handleLastOp(w http.ResponseWriter, r *http.Request) { 320 ctx := r.Context() 321 did := r.PathValue("did") 322 323 if !validDID(did) { 324 s.writeErrorResponse(w, http.StatusNotFound, fmt.Sprintf("DID not registered: %s", did)) 325 return 326 } 327 328 n, err := s.client.Operation.Query(). 329 Where(operation.Did(did)). 330 Where(operation.NullifiedEQ(false)). 331 Order(ent.Desc(operation.FieldCreatedAt)). 332 First(ctx) 333 334 if err != nil { 335 if ent.IsNotFound(err) { 336 s.writeErrorResponse(w, http.StatusNotFound, fmt.Sprintf("DID not registered: %s", did)) 337 return 338 } 339 s.logger.Error("database query failed", "error", err) 340 w.WriteHeader(http.StatusInternalServerError) 341 return 342 } 343 344 s.writeJSONResponse(w, http.StatusOK, n.Operation) 345} 346 347func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { 348 ctx := r.Context() 349 350 countStr := r.FormValue("count") 351 var count int 352 if countStr == "" { 353 count = 10 354 } else { 355 var err error 356 count, err = strconv.Atoi(r.FormValue("count")) 357 if err != nil { 358 s.writeErrorResponse(w, http.StatusBadRequest, "invalid count parameter") 359 return 360 } 361 } 362 count = min(1000, count) 363 364 after, err := time.Parse(time.RFC3339Nano, r.FormValue("after")) 365 if err != nil { 366 after = time.Time{} 367 } 368 369 ops, err := s.client.Operation.Query(). 370 Select( 371 operation.FieldDid, 372 operation.FieldOperation, 373 operation.FieldCid, 374 operation.FieldNullified, 375 operation.FieldCreatedAt, 376 ). 377 Where(operation.CreatedAtGT(after)). 378 Order(ent.Asc(operation.FieldCreatedAt)). 379 Limit(count). 380 All(ctx) 381 if err != nil { 382 s.logger.Error("failed to query operations", "error", err) 383 w.WriteHeader(http.StatusInternalServerError) 384 return 385 } 386 387 s.writeJSONLResponse(w, http.StatusOK, ops) 388} 389 390func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { 391 ctx := r.Context() 392 393 syncStatus, err := s.client.SyncStatus.Query(). 394 Where(syncstatus.Key("last_mirror_sync")). 395 First(ctx) 396 397 if err != nil { 398 if ent.IsNotFound(err) { 399 s.writeJSONResponse(w, http.StatusOK, map[string]any{ 400 "status": "starting", 401 "message": "No sync data available yet", 402 }) 403 return 404 } 405 s.logger.Error("failed to query sync status", "error", err) 406 w.WriteHeader(http.StatusInternalServerError) 407 return 408 } 409 410 s.writeJSONResponse(w, http.StatusOK, map[string]any{ 411 "status": "healthy", 412 "last_updated_at": syncStatus.LastSyncTime.Format(time.RFC3339), 413 }) 414} 415 416func (s *Server) writeErrorResponse(w http.ResponseWriter, statusCode int, message string) { 417 s.writeJSONResponse(w, statusCode, map[string]any{"message": message}) 418} 419 420func (s *Server) writeJSONResponse(w http.ResponseWriter, statusCode int, data any) { 421 w.Header().Set("Content-Type", "application/json") 422 w.WriteHeader(statusCode) 423 424 b, err := json.Marshal(data) 425 if err != nil { 426 s.logger.Error("failed to marshal JSON response", "error", err) 427 return 428 } 429 430 if _, err := w.Write(b); err != nil { 431 s.logger.Error("failed to write response", "error", err) 432 } 433} 434 435func (s *Server) writeJSONLResponse(w http.ResponseWriter, statusCode int, data any) { 436 w.Header().Set("Content-Type", "application/jsonlines") 437 w.WriteHeader(statusCode) 438 439 rv := reflect.ValueOf(data) 440 if rv.Kind() != reflect.Slice { 441 s.logger.Error("JSONL response expects slice data") 442 return 443 } 444 445 for i := range rv.Len() { 446 item := rv.Index(i).Interface() 447 if err := s.writeJSONLine(w, item); err != nil { 448 s.logger.Error("failed to write item as JSON line", "error", err) 449 return 450 } 451 } 452} 453 454func (s *Server) writeJSONLine(w http.ResponseWriter, data any) error { 455 b, err := json.Marshal(data) 456 if err != nil { 457 return err 458 } 459 460 if _, err := w.Write(b); err != nil { 461 return err 462 } 463 if _, err := w.Write([]byte("\n")); err != nil { 464 return err 465 } 466 467 return nil 468}