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/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}