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