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