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