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