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