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