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