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