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