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