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