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