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