1package knots
2
3import (
4 "errors"
5 "fmt"
6 "log/slog"
7 "net/http"
8 "slices"
9 "time"
10
11 "github.com/go-chi/chi/v5"
12 "tangled.org/core/api/tangled"
13 "tangled.org/core/appview/config"
14 "tangled.org/core/appview/db"
15 "tangled.org/core/appview/middleware"
16 "tangled.org/core/appview/models"
17 "tangled.org/core/appview/oauth"
18 "tangled.org/core/appview/pages"
19 "tangled.org/core/appview/serververify"
20 "tangled.org/core/appview/xrpcclient"
21 "tangled.org/core/eventconsumer"
22 "tangled.org/core/idresolver"
23 "tangled.org/core/rbac"
24 "tangled.org/core/tid"
25
26 comatproto "github.com/bluesky-social/indigo/api/atproto"
27 lexutil "github.com/bluesky-social/indigo/lex/util"
28)
29
30type Knots struct {
31 Db *db.DB
32 OAuth *oauth.OAuth
33 Pages *pages.Pages
34 Config *config.Config
35 Enforcer *rbac.Enforcer
36 IdResolver *idresolver.Resolver
37 Logger *slog.Logger
38 Knotstream *eventconsumer.Consumer
39}
40
41func (k *Knots) Router() http.Handler {
42 r := chi.NewRouter()
43
44 r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots)
45 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register)
46
47 r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard)
48 r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete)
49
50 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry)
51 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember)
52 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember)
53
54 return r
55}
56
57func (k *Knots) knots(w http.ResponseWriter, r *http.Request) {
58 user := k.OAuth.GetUser(r)
59 registrations, err := db.GetRegistrations(
60 k.Db,
61 db.FilterEq("did", user.Did),
62 )
63 if err != nil {
64 k.Logger.Error("failed to fetch knot registrations", "err", err)
65 w.WriteHeader(http.StatusInternalServerError)
66 return
67 }
68
69 k.Pages.Knots(w, pages.KnotsParams{
70 LoggedInUser: user,
71 Registrations: registrations,
72 })
73}
74
75func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) {
76 l := k.Logger.With("handler", "dashboard")
77
78 user := k.OAuth.GetUser(r)
79 l = l.With("user", user.Did)
80
81 domain := chi.URLParam(r, "domain")
82 if domain == "" {
83 return
84 }
85 l = l.With("domain", domain)
86
87 registrations, err := db.GetRegistrations(
88 k.Db,
89 db.FilterEq("did", user.Did),
90 db.FilterEq("domain", domain),
91 )
92 if err != nil {
93 l.Error("failed to get registrations", "err", err)
94 http.Error(w, "Not found", http.StatusNotFound)
95 return
96 }
97 if len(registrations) != 1 {
98 l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
99 return
100 }
101 registration := registrations[0]
102
103 members, err := k.Enforcer.GetUserByRole("server:member", domain)
104 if err != nil {
105 l.Error("failed to get knot members", "err", err)
106 http.Error(w, "Not found", http.StatusInternalServerError)
107 return
108 }
109 slices.Sort(members)
110
111 repos, err := db.GetRepos(
112 k.Db,
113 0,
114 db.FilterEq("knot", domain),
115 )
116 if err != nil {
117 l.Error("failed to get knot repos", "err", err)
118 http.Error(w, "Not found", http.StatusInternalServerError)
119 return
120 }
121
122 // organize repos by did
123 repoMap := make(map[string][]models.Repo)
124 for _, r := range repos {
125 repoMap[r.Did] = append(repoMap[r.Did], r)
126 }
127
128 k.Pages.Knot(w, pages.KnotParams{
129 LoggedInUser: user,
130 Registration: ®istration,
131 Members: members,
132 Repos: repoMap,
133 IsOwner: true,
134 })
135}
136
137func (k *Knots) register(w http.ResponseWriter, r *http.Request) {
138 user := k.OAuth.GetUser(r)
139 l := k.Logger.With("handler", "register")
140
141 noticeId := "register-error"
142 defaultErr := "Failed to register knot. Try again later."
143 fail := func() {
144 k.Pages.Notice(w, noticeId, defaultErr)
145 }
146
147 domain := r.FormValue("domain")
148 if domain == "" {
149 k.Pages.Notice(w, noticeId, "Incomplete form.")
150 return
151 }
152 l = l.With("domain", domain)
153 l = l.With("user", user.Did)
154
155 tx, err := k.Db.Begin()
156 if err != nil {
157 l.Error("failed to start transaction", "err", err)
158 fail()
159 return
160 }
161 defer func() {
162 tx.Rollback()
163 k.Enforcer.E.LoadPolicy()
164 }()
165
166 err = db.AddKnot(tx, domain, user.Did)
167 if err != nil {
168 l.Error("failed to insert", "err", err)
169 fail()
170 return
171 }
172
173 err = k.Enforcer.AddKnot(domain)
174 if err != nil {
175 l.Error("failed to create knot", "err", err)
176 fail()
177 return
178 }
179
180 // create record on pds
181 client, err := k.OAuth.AuthorizedClient(r)
182 if err != nil {
183 l.Error("failed to authorize client", "err", err)
184 fail()
185 return
186 }
187
188 ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
189 var exCid *string
190 if ex != nil {
191 exCid = ex.Cid
192 }
193
194 // re-announce by registering under same rkey
195 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
196 Collection: tangled.KnotNSID,
197 Repo: user.Did,
198 Rkey: domain,
199 Record: &lexutil.LexiconTypeDecoder{
200 Val: &tangled.Knot{
201 CreatedAt: time.Now().Format(time.RFC3339),
202 },
203 },
204 SwapRecord: exCid,
205 })
206
207 if err != nil {
208 l.Error("failed to put record", "err", err)
209 fail()
210 return
211 }
212
213 err = tx.Commit()
214 if err != nil {
215 l.Error("failed to commit transaction", "err", err)
216 fail()
217 return
218 }
219
220 err = k.Enforcer.E.SavePolicy()
221 if err != nil {
222 l.Error("failed to update ACL", "err", err)
223 k.Pages.HxRefresh(w)
224 return
225 }
226
227 // begin verification
228 err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev)
229 if err != nil {
230 l.Error("verification failed", "err", err)
231 k.Pages.HxRefresh(w)
232 return
233 }
234
235 err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did)
236 if err != nil {
237 l.Error("failed to mark verified", "err", err)
238 k.Pages.HxRefresh(w)
239 return
240 }
241
242 // add this knot to knotstream
243 go k.Knotstream.AddSource(
244 r.Context(),
245 eventconsumer.NewKnotSource(domain),
246 )
247
248 // ok
249 k.Pages.HxRefresh(w)
250}
251
252func (k *Knots) delete(w http.ResponseWriter, r *http.Request) {
253 user := k.OAuth.GetUser(r)
254 l := k.Logger.With("handler", "delete")
255
256 noticeId := "operation-error"
257 defaultErr := "Failed to delete knot. Try again later."
258 fail := func() {
259 k.Pages.Notice(w, noticeId, defaultErr)
260 }
261
262 domain := chi.URLParam(r, "domain")
263 if domain == "" {
264 l.Error("empty domain")
265 fail()
266 return
267 }
268
269 // get record from db first
270 registrations, err := db.GetRegistrations(
271 k.Db,
272 db.FilterEq("did", user.Did),
273 db.FilterEq("domain", domain),
274 )
275 if err != nil {
276 l.Error("failed to get registration", "err", err)
277 fail()
278 return
279 }
280 if len(registrations) != 1 {
281 l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
282 fail()
283 return
284 }
285 registration := registrations[0]
286
287 tx, err := k.Db.Begin()
288 if err != nil {
289 l.Error("failed to start txn", "err", err)
290 fail()
291 return
292 }
293 defer func() {
294 tx.Rollback()
295 k.Enforcer.E.LoadPolicy()
296 }()
297
298 err = db.DeleteKnot(
299 tx,
300 db.FilterEq("did", user.Did),
301 db.FilterEq("domain", domain),
302 )
303 if err != nil {
304 l.Error("failed to delete registration", "err", err)
305 fail()
306 return
307 }
308
309 // delete from enforcer if it was registered
310 if registration.Registered != nil {
311 err = k.Enforcer.RemoveKnot(domain)
312 if err != nil {
313 l.Error("failed to update ACL", "err", err)
314 fail()
315 return
316 }
317 }
318
319 client, err := k.OAuth.AuthorizedClient(r)
320 if err != nil {
321 l.Error("failed to authorize client", "err", err)
322 fail()
323 return
324 }
325
326 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
327 Collection: tangled.KnotNSID,
328 Repo: user.Did,
329 Rkey: domain,
330 })
331 if err != nil {
332 // non-fatal
333 l.Error("failed to delete record", "err", err)
334 }
335
336 err = tx.Commit()
337 if err != nil {
338 l.Error("failed to delete knot", "err", err)
339 fail()
340 return
341 }
342
343 err = k.Enforcer.E.SavePolicy()
344 if err != nil {
345 l.Error("failed to update ACL", "err", err)
346 k.Pages.HxRefresh(w)
347 return
348 }
349
350 shouldRedirect := r.Header.Get("shouldRedirect")
351 if shouldRedirect == "true" {
352 k.Pages.HxRedirect(w, "/knots")
353 return
354 }
355
356 w.Write([]byte{})
357}
358
359func (k *Knots) retry(w http.ResponseWriter, r *http.Request) {
360 user := k.OAuth.GetUser(r)
361 l := k.Logger.With("handler", "retry")
362
363 noticeId := "operation-error"
364 defaultErr := "Failed to verify knot. Try again later."
365 fail := func() {
366 k.Pages.Notice(w, noticeId, defaultErr)
367 }
368
369 domain := chi.URLParam(r, "domain")
370 if domain == "" {
371 l.Error("empty domain")
372 fail()
373 return
374 }
375 l = l.With("domain", domain)
376 l = l.With("user", user.Did)
377
378 // get record from db first
379 registrations, err := db.GetRegistrations(
380 k.Db,
381 db.FilterEq("did", user.Did),
382 db.FilterEq("domain", domain),
383 )
384 if err != nil {
385 l.Error("failed to get registration", "err", err)
386 fail()
387 return
388 }
389 if len(registrations) != 1 {
390 l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
391 fail()
392 return
393 }
394 registration := registrations[0]
395
396 // begin verification
397 err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev)
398 if err != nil {
399 l.Error("verification failed", "err", err)
400
401 if errors.Is(err, xrpcclient.ErrXrpcUnsupported) {
402 k.Pages.Notice(w, noticeId, "Failed to verify knot, XRPC queries are unsupported on this knot, consider upgrading!")
403 return
404 }
405
406 if e, ok := err.(*serververify.OwnerMismatch); ok {
407 k.Pages.Notice(w, noticeId, e.Error())
408 return
409 }
410
411 fail()
412 return
413 }
414
415 err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did)
416 if err != nil {
417 l.Error("failed to mark verified", "err", err)
418 k.Pages.Notice(w, noticeId, err.Error())
419 return
420 }
421
422 // if this knot requires upgrade, then emit a record too
423 //
424 // this is part of migrating from the old knot system to the new one
425 if registration.NeedsUpgrade {
426 // re-announce by registering under same rkey
427 client, err := k.OAuth.AuthorizedClient(r)
428 if err != nil {
429 l.Error("failed to authorize client", "err", err)
430 fail()
431 return
432 }
433
434 ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
435 var exCid *string
436 if ex != nil {
437 exCid = ex.Cid
438 }
439
440 // ignore the error here
441 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
442 Collection: tangled.KnotNSID,
443 Repo: user.Did,
444 Rkey: domain,
445 Record: &lexutil.LexiconTypeDecoder{
446 Val: &tangled.Knot{
447 CreatedAt: time.Now().Format(time.RFC3339),
448 },
449 },
450 SwapRecord: exCid,
451 })
452 if err != nil {
453 l.Error("non-fatal: failed to reannouce knot", "err", err)
454 }
455 }
456
457 // add this knot to knotstream
458 go k.Knotstream.AddSource(
459 r.Context(),
460 eventconsumer.NewKnotSource(domain),
461 )
462
463 shouldRefresh := r.Header.Get("shouldRefresh")
464 if shouldRefresh == "true" {
465 k.Pages.HxRefresh(w)
466 return
467 }
468
469 // Get updated registration to show
470 registrations, err = db.GetRegistrations(
471 k.Db,
472 db.FilterEq("did", user.Did),
473 db.FilterEq("domain", domain),
474 )
475 if err != nil {
476 l.Error("failed to get registration", "err", err)
477 fail()
478 return
479 }
480 if len(registrations) != 1 {
481 l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
482 fail()
483 return
484 }
485 updatedRegistration := registrations[0]
486
487 w.Header().Set("HX-Reswap", "outerHTML")
488 k.Pages.KnotListing(w, pages.KnotListingParams{
489 Registration: &updatedRegistration,
490 })
491}
492
493func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) {
494 user := k.OAuth.GetUser(r)
495 l := k.Logger.With("handler", "addMember")
496
497 domain := chi.URLParam(r, "domain")
498 if domain == "" {
499 l.Error("empty domain")
500 http.Error(w, "Not found", http.StatusNotFound)
501 return
502 }
503 l = l.With("domain", domain)
504 l = l.With("user", user.Did)
505
506 registrations, err := db.GetRegistrations(
507 k.Db,
508 db.FilterEq("did", user.Did),
509 db.FilterEq("domain", domain),
510 db.FilterIsNot("registered", "null"),
511 )
512 if err != nil {
513 l.Error("failed to get registration", "err", err)
514 return
515 }
516 if len(registrations) != 1 {
517 l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
518 return
519 }
520 registration := registrations[0]
521
522 noticeId := fmt.Sprintf("add-member-error-%d", registration.Id)
523 defaultErr := "Failed to add member. Try again later."
524 fail := func() {
525 k.Pages.Notice(w, noticeId, defaultErr)
526 }
527
528 member := r.FormValue("member")
529 if member == "" {
530 l.Error("empty member")
531 k.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
532 return
533 }
534 l = l.With("member", member)
535
536 memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
537 if err != nil {
538 l.Error("failed to resolve member identity to handle", "err", err)
539 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
540 return
541 }
542 if memberId.Handle.IsInvalidHandle() {
543 l.Error("failed to resolve member identity to handle")
544 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
545 return
546 }
547
548 // write to pds
549 client, err := k.OAuth.AuthorizedClient(r)
550 if err != nil {
551 l.Error("failed to authorize client", "err", err)
552 fail()
553 return
554 }
555
556 rkey := tid.TID()
557
558 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
559 Collection: tangled.KnotMemberNSID,
560 Repo: user.Did,
561 Rkey: rkey,
562 Record: &lexutil.LexiconTypeDecoder{
563 Val: &tangled.KnotMember{
564 CreatedAt: time.Now().Format(time.RFC3339),
565 Domain: domain,
566 Subject: memberId.DID.String(),
567 },
568 },
569 })
570 if err != nil {
571 l.Error("failed to add record to PDS", "err", err)
572 k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.")
573 return
574 }
575
576 err = k.Enforcer.AddKnotMember(domain, memberId.DID.String())
577 if err != nil {
578 l.Error("failed to add member to ACLs", "err", err)
579 fail()
580 return
581 }
582
583 err = k.Enforcer.E.SavePolicy()
584 if err != nil {
585 l.Error("failed to save ACL policy", "err", err)
586 fail()
587 return
588 }
589
590 // success
591 k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
592}
593
594func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
595 user := k.OAuth.GetUser(r)
596 l := k.Logger.With("handler", "removeMember")
597
598 noticeId := "operation-error"
599 defaultErr := "Failed to remove member. Try again later."
600 fail := func() {
601 k.Pages.Notice(w, noticeId, defaultErr)
602 }
603
604 domain := chi.URLParam(r, "domain")
605 if domain == "" {
606 l.Error("empty domain")
607 fail()
608 return
609 }
610 l = l.With("domain", domain)
611 l = l.With("user", user.Did)
612
613 registrations, err := db.GetRegistrations(
614 k.Db,
615 db.FilterEq("did", user.Did),
616 db.FilterEq("domain", domain),
617 db.FilterIsNot("registered", "null"),
618 )
619 if err != nil {
620 l.Error("failed to get registration", "err", err)
621 return
622 }
623 if len(registrations) != 1 {
624 l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
625 return
626 }
627
628 member := r.FormValue("member")
629 if member == "" {
630 l.Error("empty member")
631 k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
632 return
633 }
634 l = l.With("member", member)
635
636 memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
637 if err != nil {
638 l.Error("failed to resolve member identity to handle", "err", err)
639 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
640 return
641 }
642 if memberId.Handle.IsInvalidHandle() {
643 l.Error("failed to resolve member identity to handle")
644 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
645 return
646 }
647
648 // remove from enforcer
649 err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String())
650 if err != nil {
651 l.Error("failed to update ACLs", "err", err)
652 fail()
653 return
654 }
655
656 client, err := k.OAuth.AuthorizedClient(r)
657 if err != nil {
658 l.Error("failed to authorize client", "err", err)
659 fail()
660 return
661 }
662
663 // TODO: We need to track the rkey for knot members to delete the record
664 // For now, just remove from ACLs
665 _ = client
666
667 // commit everything
668 err = k.Enforcer.E.SavePolicy()
669 if err != nil {
670 l.Error("failed to save ACLs", "err", err)
671 fail()
672 return
673 }
674
675 // ok
676 k.Pages.HxRefresh(w)
677}