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