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