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 verify "tangled.sh/tangled.sh/core/appview/spindleverify"
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 // organize repos by did
117 repoMap := make(map[string][]db.Repo)
118 for _, r := range repos {
119 repoMap[r.Did] = append(repoMap[r.Did], r)
120 }
121
122 s.Pages.SpindleDashboard(w, pages.SpindleDashboardParams{
123 LoggedInUser: user,
124 Spindle: spindle,
125 Members: members,
126 Repos: repoMap,
127 })
128}
129
130// this endpoint inserts a record on behalf of the user to register that domain
131//
132// when registered, it also makes a request to see if the spindle declares this users as its owner,
133// and if so, marks the spindle as verified.
134//
135// if the spindle is not up yet, the user is free to retry verification at a later point
136func (s *Spindles) register(w http.ResponseWriter, r *http.Request) {
137 user := s.OAuth.GetUser(r)
138 l := s.Logger.With("handler", "register")
139
140 noticeId := "register-error"
141 defaultErr := "Failed to register spindle. Try again later."
142 fail := func() {
143 s.Pages.Notice(w, noticeId, defaultErr)
144 }
145
146 instance := r.FormValue("instance")
147 if instance == "" {
148 s.Pages.Notice(w, noticeId, "Incomplete form.")
149 return
150 }
151 l = l.With("instance", instance)
152 l = l.With("user", user.Did)
153
154 tx, err := s.Db.Begin()
155 if err != nil {
156 l.Error("failed to start transaction", "err", err)
157 fail()
158 return
159 }
160 defer func() {
161 tx.Rollback()
162 s.Enforcer.E.LoadPolicy()
163 }()
164
165 err = db.AddSpindle(tx, db.Spindle{
166 Owner: syntax.DID(user.Did),
167 Instance: instance,
168 })
169 if err != nil {
170 l.Error("failed to insert", "err", err)
171 fail()
172 return
173 }
174
175 err = s.Enforcer.AddSpindle(instance)
176 if err != nil {
177 l.Error("failed to create spindle", "err", err)
178 fail()
179 return
180 }
181
182 // create record on pds
183 client, err := s.OAuth.AuthorizedClient(r)
184 if err != nil {
185 l.Error("failed to authorize client", "err", err)
186 fail()
187 return
188 }
189
190 ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance)
191 var exCid *string
192 if ex != nil {
193 exCid = ex.Cid
194 }
195
196 // re-announce by registering under same rkey
197 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
198 Collection: tangled.SpindleNSID,
199 Repo: user.Did,
200 Rkey: instance,
201 Record: &lexutil.LexiconTypeDecoder{
202 Val: &tangled.Spindle{
203 CreatedAt: time.Now().Format(time.RFC3339),
204 },
205 },
206 SwapRecord: exCid,
207 })
208
209 if err != nil {
210 l.Error("failed to put record", "err", err)
211 fail()
212 return
213 }
214
215 err = tx.Commit()
216 if err != nil {
217 l.Error("failed to commit transaction", "err", err)
218 fail()
219 return
220 }
221
222 err = s.Enforcer.E.SavePolicy()
223 if err != nil {
224 l.Error("failed to update ACL", "err", err)
225 s.Pages.HxRefresh(w)
226 return
227 }
228
229 // begin verification
230 err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
231 if err != nil {
232 l.Error("verification failed", "err", err)
233 s.Pages.HxRefresh(w)
234 return
235 }
236
237 _, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did)
238 if err != nil {
239 l.Error("failed to mark verified", "err", err)
240 s.Pages.HxRefresh(w)
241 return
242 }
243
244 // ok
245 s.Pages.HxRefresh(w)
246}
247
248func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) {
249 user := s.OAuth.GetUser(r)
250 l := s.Logger.With("handler", "delete")
251
252 noticeId := "operation-error"
253 defaultErr := "Failed to delete spindle. Try again later."
254 fail := func() {
255 s.Pages.Notice(w, noticeId, defaultErr)
256 }
257
258 instance := chi.URLParam(r, "instance")
259 if instance == "" {
260 l.Error("empty instance")
261 fail()
262 return
263 }
264
265 spindles, err := db.GetSpindles(
266 s.Db,
267 db.FilterEq("owner", user.Did),
268 db.FilterEq("instance", instance),
269 )
270 if err != nil || len(spindles) != 1 {
271 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
272 fail()
273 return
274 }
275
276 if string(spindles[0].Owner) != user.Did {
277 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
278 s.Pages.Notice(w, noticeId, "Failed to delete spindle, unauthorized deletion attempt.")
279 return
280 }
281
282 tx, err := s.Db.Begin()
283 if err != nil {
284 l.Error("failed to start txn", "err", err)
285 fail()
286 return
287 }
288 defer func() {
289 tx.Rollback()
290 s.Enforcer.E.LoadPolicy()
291 }()
292
293 // remove spindle members first
294 err = db.RemoveSpindleMember(
295 tx,
296 db.FilterEq("did", user.Did),
297 db.FilterEq("instance", instance),
298 )
299 if err != nil {
300 l.Error("failed to remove spindle members", "err", err)
301 fail()
302 return
303 }
304
305 err = db.DeleteSpindle(
306 tx,
307 db.FilterEq("owner", user.Did),
308 db.FilterEq("instance", instance),
309 )
310 if err != nil {
311 l.Error("failed to delete spindle", "err", err)
312 fail()
313 return
314 }
315
316 // delete from enforcer
317 if spindles[0].Verified != nil {
318 err = s.Enforcer.RemoveSpindle(instance)
319 if err != nil {
320 l.Error("failed to update ACL", "err", err)
321 fail()
322 return
323 }
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 := tid.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 remove 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 remove 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 remove 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 remove 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 remove 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}