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