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