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