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