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