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