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