this repo has no description
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 // delete from enforcer
320 if spindles[0].Verified != nil {
321 err = s.Enforcer.RemoveSpindle(instance)
322 if err != nil {
323 l.Error("failed to update ACL", "err", err)
324 fail()
325 return
326 }
327 }
328
329 client, err := s.OAuth.AuthorizedClient(r)
330 if err != nil {
331 l.Error("failed to authorize client", "err", err)
332 fail()
333 return
334 }
335
336 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
337 Collection: tangled.SpindleNSID,
338 Repo: user.Did,
339 Rkey: instance,
340 })
341 if err != nil {
342 // non-fatal
343 l.Error("failed to delete record", "err", err)
344 }
345
346 err = tx.Commit()
347 if err != nil {
348 l.Error("failed to delete spindle", "err", err)
349 fail()
350 return
351 }
352
353 err = s.Enforcer.E.SavePolicy()
354 if err != nil {
355 l.Error("failed to update ACL", "err", err)
356 s.Pages.HxRefresh(w)
357 return
358 }
359
360 shouldRedirect := r.Header.Get("shouldRedirect")
361 if shouldRedirect == "true" {
362 s.Pages.HxRedirect(w, "/spindles")
363 return
364 }
365
366 w.Write([]byte{})
367}
368
369func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) {
370 user := s.OAuth.GetUser(r)
371 l := s.Logger.With("handler", "retry")
372
373 noticeId := "operation-error"
374 defaultErr := "Failed to verify spindle. Try again later."
375 fail := func() {
376 s.Pages.Notice(w, noticeId, defaultErr)
377 }
378
379 instance := chi.URLParam(r, "instance")
380 if instance == "" {
381 l.Error("empty instance")
382 fail()
383 return
384 }
385 l = l.With("instance", instance)
386 l = l.With("user", user.Did)
387
388 spindles, err := db.GetSpindles(
389 s.Db,
390 db.FilterEq("owner", user.Did),
391 db.FilterEq("instance", instance),
392 )
393 if err != nil || len(spindles) != 1 {
394 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
395 fail()
396 return
397 }
398
399 if string(spindles[0].Owner) != user.Did {
400 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
401 s.Pages.Notice(w, noticeId, "Failed to verify spindle, unauthorized verification attempt.")
402 return
403 }
404
405 // begin verification
406 err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
407 if err != nil {
408 l.Error("verification failed", "err", err)
409
410 if errors.Is(err, verify.FetchError) {
411 s.Pages.Notice(w, noticeId, err.Error())
412 return
413 }
414
415 if e, ok := err.(*verify.OwnerMismatch); ok {
416 s.Pages.Notice(w, noticeId, e.Error())
417 return
418 }
419
420 fail()
421 return
422 }
423
424 rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did)
425 if err != nil {
426 l.Error("failed to mark verified", "err", err)
427 s.Pages.Notice(w, noticeId, err.Error())
428 return
429 }
430
431 verifiedSpindle, err := db.GetSpindles(
432 s.Db,
433 db.FilterEq("id", rowId),
434 )
435 if err != nil || len(verifiedSpindle) != 1 {
436 l.Error("failed get new spindle", "err", err)
437 s.Pages.HxRefresh(w)
438 return
439 }
440
441 shouldRefresh := r.Header.Get("shouldRefresh")
442 if shouldRefresh == "true" {
443 s.Pages.HxRefresh(w)
444 return
445 }
446
447 w.Header().Set("HX-Reswap", "outerHTML")
448 s.Pages.SpindleListing(w, pages.SpindleListingParams{verifiedSpindle[0]})
449}
450
451func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) {
452 user := s.OAuth.GetUser(r)
453 l := s.Logger.With("handler", "addMember")
454
455 instance := chi.URLParam(r, "instance")
456 if instance == "" {
457 l.Error("empty instance")
458 http.Error(w, "Not found", http.StatusNotFound)
459 return
460 }
461 l = l.With("instance", instance)
462 l = l.With("user", user.Did)
463
464 spindles, err := db.GetSpindles(
465 s.Db,
466 db.FilterEq("owner", user.Did),
467 db.FilterEq("instance", instance),
468 )
469 if err != nil || len(spindles) != 1 {
470 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
471 http.Error(w, "Not found", http.StatusNotFound)
472 return
473 }
474
475 noticeId := fmt.Sprintf("add-member-error-%d", spindles[0].Id)
476 defaultErr := "Failed to add member. Try again later."
477 fail := func() {
478 s.Pages.Notice(w, noticeId, defaultErr)
479 }
480
481 if string(spindles[0].Owner) != user.Did {
482 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
483 s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.")
484 return
485 }
486
487 member := r.FormValue("member")
488 if member == "" {
489 l.Error("empty member")
490 s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
491 return
492 }
493 l = l.With("member", member)
494
495 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
496 if err != nil {
497 l.Error("failed to resolve member identity to handle", "err", err)
498 s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
499 return
500 }
501 if memberId.Handle.IsInvalidHandle() {
502 l.Error("failed to resolve member identity to handle")
503 s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
504 return
505 }
506
507 // write to pds
508 client, err := s.OAuth.AuthorizedClient(r)
509 if err != nil {
510 l.Error("failed to authorize client", "err", err)
511 fail()
512 return
513 }
514
515 tx, err := s.Db.Begin()
516 if err != nil {
517 l.Error("failed to start txn", "err", err)
518 fail()
519 return
520 }
521 defer func() {
522 tx.Rollback()
523 s.Enforcer.E.LoadPolicy()
524 }()
525
526 rkey := appview.TID()
527
528 // add member to db
529 if err = db.AddSpindleMember(tx, db.SpindleMember{
530 Did: syntax.DID(user.Did),
531 Rkey: rkey,
532 Instance: instance,
533 Subject: memberId.DID,
534 }); err != nil {
535 l.Error("failed to add spindle member", "err", err)
536 fail()
537 return
538 }
539
540 if err = s.Enforcer.AddSpindleMember(instance, memberId.DID.String()); err != nil {
541 l.Error("failed to add member to ACLs")
542 fail()
543 return
544 }
545
546 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
547 Collection: tangled.SpindleMemberNSID,
548 Repo: user.Did,
549 Rkey: rkey,
550 Record: &lexutil.LexiconTypeDecoder{
551 Val: &tangled.SpindleMember{
552 CreatedAt: time.Now().Format(time.RFC3339),
553 Instance: instance,
554 Subject: memberId.DID.String(),
555 },
556 },
557 })
558 if err != nil {
559 l.Error("failed to add record to PDS", "err", err)
560 s.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.")
561 return
562 }
563
564 if err = tx.Commit(); err != nil {
565 l.Error("failed to commit txn", "err", err)
566 fail()
567 return
568 }
569
570 if err = s.Enforcer.E.SavePolicy(); err != nil {
571 l.Error("failed to add member to ACLs", "err", err)
572 fail()
573 return
574 }
575
576 // success
577 s.Pages.HxRedirect(w, fmt.Sprintf("/spindles/%s", instance))
578}
579
580func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) {
581 user := s.OAuth.GetUser(r)
582 l := s.Logger.With("handler", "removeMember")
583
584 noticeId := "operation-error"
585 defaultErr := "Failed to add member. Try again later."
586 fail := func() {
587 s.Pages.Notice(w, noticeId, defaultErr)
588 }
589
590 instance := chi.URLParam(r, "instance")
591 if instance == "" {
592 l.Error("empty instance")
593 fail()
594 return
595 }
596 l = l.With("instance", instance)
597 l = l.With("user", user.Did)
598
599 spindles, err := db.GetSpindles(
600 s.Db,
601 db.FilterEq("owner", user.Did),
602 db.FilterEq("instance", instance),
603 )
604 if err != nil || len(spindles) != 1 {
605 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
606 fail()
607 return
608 }
609
610 if string(spindles[0].Owner) != user.Did {
611 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
612 s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.")
613 return
614 }
615
616 member := r.FormValue("member")
617 if member == "" {
618 l.Error("empty member")
619 s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
620 return
621 }
622 l = l.With("member", member)
623
624 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
625 if err != nil {
626 l.Error("failed to resolve member identity to handle", "err", err)
627 s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
628 return
629 }
630 if memberId.Handle.IsInvalidHandle() {
631 l.Error("failed to resolve member identity to handle")
632 s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
633 return
634 }
635
636 tx, err := s.Db.Begin()
637 if err != nil {
638 l.Error("failed to start txn", "err", err)
639 fail()
640 return
641 }
642 defer func() {
643 tx.Rollback()
644 s.Enforcer.E.LoadPolicy()
645 }()
646
647 // get the record from the DB first:
648 members, err := db.GetSpindleMembers(
649 s.Db,
650 db.FilterEq("did", user.Did),
651 db.FilterEq("instance", instance),
652 db.FilterEq("subject", memberId.DID),
653 )
654 if err != nil || len(members) != 1 {
655 l.Error("failed to get member", "err", err)
656 fail()
657 return
658 }
659
660 // remove from db
661 if err = db.RemoveSpindleMember(
662 tx,
663 db.FilterEq("did", user.Did),
664 db.FilterEq("instance", instance),
665 db.FilterEq("subject", memberId.DID),
666 ); err != nil {
667 l.Error("failed to remove spindle member", "err", err)
668 fail()
669 return
670 }
671
672 // remove from enforcer
673 if err = s.Enforcer.RemoveSpindleMember(instance, memberId.DID.String()); err != nil {
674 l.Error("failed to update ACLs", "err", err)
675 fail()
676 return
677 }
678
679 client, err := s.OAuth.AuthorizedClient(r)
680 if err != nil {
681 l.Error("failed to authorize client", "err", err)
682 fail()
683 return
684 }
685
686 // remove from pds
687 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
688 Collection: tangled.SpindleMemberNSID,
689 Repo: user.Did,
690 Rkey: members[0].Rkey,
691 })
692 if err != nil {
693 // non-fatal
694 l.Error("failed to delete record", "err", err)
695 }
696
697 // commit everything
698 if err = tx.Commit(); err != nil {
699 l.Error("failed to commit txn", "err", err)
700 fail()
701 return
702 }
703
704 // commit everything
705 if err = s.Enforcer.E.SavePolicy(); err != nil {
706 l.Error("failed to save ACLs", "err", err)
707 fail()
708 return
709 }
710
711 // ok
712 s.Pages.HxRefresh(w)
713 return
714}