Monorepo for Tangled
tangled.org
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 BaseParams: pages.BaseParamsFromContext(r.Context()),
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 BaseParams: pages.BaseParamsFromContext(r.Context()),
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 tx.Rollback()
174
175 if err := db.AddSpindle(tx, models.Spindle{
176 Owner: syntax.DID(user.Did),
177 Instance: instance,
178 }); err != nil {
179 l.Error("failed to insert", "err", err)
180 fail()
181 return
182 }
183
184 client, err := s.OAuth.AuthorizedClient(r)
185 if err != nil {
186 l.Error("failed to authorize client", "err", err)
187 fail()
188 return
189 }
190
191 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance)
192 var exCid *string
193 if ex != nil {
194 exCid = ex.Cid
195 }
196
197 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
198 Collection: tangled.SpindleNSID,
199 Repo: user.Did,
200 Rkey: instance,
201 Record: &lexutil.LexiconTypeDecoder{
202 Val: &tangled.Spindle{
203 CreatedAt: time.Now().Format(time.RFC3339),
204 },
205 },
206 SwapRecord: exCid,
207 })
208 if err != nil {
209 l.Error("failed to put record", "err", err)
210 fail()
211 return
212 }
213
214 if err := tx.Commit(); err != nil {
215 l.Error("failed to commit transaction", "err", err)
216 fail()
217 return
218 }
219
220 s.Pages.HxRefresh(w)
221}
222
223func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) {
224 user := s.OAuth.GetMultiAccountUser(r)
225 l := s.Logger.With("handler", "delete")
226
227 noticeId := "operation-error"
228 defaultErr := "Failed to delete spindle. Try again later."
229 fail := func() {
230 s.Pages.Notice(w, noticeId, defaultErr)
231 }
232
233 instance := chi.URLParam(r, "instance")
234 if instance == "" {
235 l.Error("empty instance")
236 fail()
237 return
238 }
239
240 spindles, err := db.GetSpindles(
241 r.Context(),
242 s.Db,
243 orm.FilterEq("owner", user.Did),
244 orm.FilterEq("instance", instance),
245 )
246 if err != nil || len(spindles) != 1 {
247 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
248 fail()
249 return
250 }
251
252 if string(spindles[0].Owner) != user.Did {
253 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
254 s.Pages.Notice(w, noticeId, "Failed to delete spindle, unauthorized deletion attempt.")
255 return
256 }
257
258 tx, err := s.Db.Begin()
259 if err != nil {
260 l.Error("failed to start txn", "err", err)
261 fail()
262 return
263 }
264 defer func() {
265 tx.Rollback()
266 s.Enforcer.E.LoadPolicy()
267 }()
268
269 // remove spindle members first
270 err = db.RemoveSpindleMember(
271 tx,
272 orm.FilterEq("did", user.Did),
273 orm.FilterEq("instance", instance),
274 )
275 if err != nil {
276 l.Error("failed to remove spindle members", "err", err)
277 fail()
278 return
279 }
280
281 err = db.DeleteSpindle(
282 tx,
283 orm.FilterEq("owner", user.Did),
284 orm.FilterEq("instance", instance),
285 )
286 if err != nil {
287 l.Error("failed to delete spindle", "err", err)
288 fail()
289 return
290 }
291
292 // delete from enforcer
293 if spindles[0].Verified != nil {
294 err = s.Enforcer.RemoveSpindle(instance)
295 if err != nil {
296 l.Error("failed to update ACL", "err", err)
297 fail()
298 return
299 }
300 }
301
302 client, err := s.OAuth.AuthorizedClient(r)
303 if err != nil {
304 l.Error("failed to authorize client", "err", err)
305 fail()
306 return
307 }
308
309 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
310 Collection: tangled.SpindleNSID,
311 Repo: user.Did,
312 Rkey: instance,
313 })
314 if err != nil {
315 // non-fatal
316 l.Error("failed to delete record", "err", err)
317 }
318
319 err = tx.Commit()
320 if err != nil {
321 l.Error("failed to delete spindle", "err", err)
322 fail()
323 return
324 }
325
326 err = s.Enforcer.E.SavePolicy()
327 if err != nil {
328 l.Error("failed to update ACL", "err", err)
329 s.Pages.HxRefresh(w)
330 return
331 }
332
333 shouldRedirect := r.Header.Get("shouldRedirect")
334 if shouldRedirect == "true" {
335 s.Pages.HxRedirect(w, "/settings/spindles")
336 return
337 }
338
339 w.Write([]byte{})
340}
341
342func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) {
343 user := s.OAuth.GetMultiAccountUser(r)
344 l := s.Logger.With("handler", "retry")
345
346 noticeId := "operation-error"
347 defaultErr := "Failed to verify spindle. Try again later."
348 fail := func() {
349 s.Pages.Notice(w, noticeId, defaultErr)
350 }
351
352 instance := chi.URLParam(r, "instance")
353 if instance == "" {
354 l.Error("empty instance")
355 fail()
356 return
357 }
358 l = l.With("instance", instance)
359 l = l.With("user", user.Did)
360
361 spindles, err := db.GetSpindles(
362 r.Context(),
363 s.Db,
364 orm.FilterEq("owner", user.Did),
365 orm.FilterEq("instance", instance),
366 )
367 if err != nil || len(spindles) != 1 {
368 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
369 fail()
370 return
371 }
372
373 if string(spindles[0].Owner) != user.Did {
374 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
375 s.Pages.Notice(w, noticeId, "Failed to verify spindle, unauthorized verification attempt.")
376 return
377 }
378
379 // begin verification
380 err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
381 if err != nil {
382 l.Error("verification failed", "err", err)
383
384 if errors.Is(err, xrpcclient.ErrXrpcUnsupported) {
385 s.Pages.Notice(w, noticeId, "Failed to verify spindle, XRPC queries are unsupported on this spindle, consider upgrading!")
386 return
387 }
388
389 if e, ok := err.(*serververify.OwnerMismatch); ok {
390 s.Pages.Notice(w, noticeId, e.Error())
391 return
392 }
393
394 fail()
395 return
396 }
397
398 rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did)
399 if err != nil {
400 l.Error("failed to mark verified", "err", err)
401 s.Pages.Notice(w, noticeId, err.Error())
402 return
403 }
404
405 verifiedSpindle, err := db.GetSpindles(
406 r.Context(),
407 s.Db,
408 orm.FilterEq("id", rowId),
409 )
410 if err != nil || len(verifiedSpindle) != 1 {
411 l.Error("failed get new spindle", "err", err)
412 s.Pages.HxRefresh(w)
413 return
414 }
415
416 shouldRefresh := r.Header.Get("shouldRefresh")
417 if shouldRefresh == "true" {
418 s.Pages.HxRefresh(w)
419 return
420 }
421
422 w.Header().Set("HX-Reswap", "outerHTML")
423 s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]})
424}
425
426func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) {
427 user := s.OAuth.GetMultiAccountUser(r)
428 l := s.Logger.With("handler", "addMember")
429
430 instance := chi.URLParam(r, "instance")
431 if instance == "" {
432 l.Error("empty instance")
433 http.Error(w, "Not found", http.StatusNotFound)
434 return
435 }
436 l = l.With("instance", instance)
437 l = l.With("user", user.Did)
438
439 spindles, err := db.GetSpindles(
440 r.Context(),
441 s.Db,
442 orm.FilterEq("owner", user.Did),
443 orm.FilterEq("instance", instance),
444 )
445 if err != nil || len(spindles) != 1 {
446 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
447 http.Error(w, "Not found", http.StatusNotFound)
448 return
449 }
450
451 noticeId := fmt.Sprintf("add-member-error-%d", spindles[0].Id)
452 defaultErr := "Failed to add member. Try again later."
453 fail := func() {
454 s.Pages.Notice(w, noticeId, defaultErr)
455 }
456
457 if string(spindles[0].Owner) != user.Did {
458 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
459 s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.")
460 return
461 }
462
463 member := r.FormValue("member")
464 member = strings.TrimPrefix(member, "@")
465 if member == "" {
466 l.Error("empty member")
467 s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
468 return
469 }
470 l = l.With("member", member)
471
472 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
473 if err != nil {
474 l.Error("failed to resolve member identity to handle", "err", err)
475 s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
476 return
477 }
478 if memberId.Handle.IsInvalidHandle() {
479 l.Error("failed to resolve member identity to handle")
480 s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
481 return
482 }
483
484 // write to pds
485 client, err := s.OAuth.AuthorizedClient(r)
486 if err != nil {
487 l.Error("failed to authorize client", "err", err)
488 fail()
489 return
490 }
491
492 rkey := tid.TID()
493
494 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
495 Collection: tangled.SpindleMemberNSID,
496 Repo: user.Did,
497 Rkey: rkey,
498 Record: &lexutil.LexiconTypeDecoder{
499 Val: &tangled.SpindleMember{
500 CreatedAt: time.Now().Format(time.RFC3339),
501 Instance: instance,
502 Subject: memberId.DID.String(),
503 },
504 },
505 })
506 if err != nil {
507 l.Error("failed to add record to PDS", "err", err)
508 s.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.")
509 return
510 }
511
512 // success
513 s.Pages.HxRedirect(w, fmt.Sprintf("/settings/spindles/%s", instance))
514}
515
516func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) {
517 user := s.OAuth.GetMultiAccountUser(r)
518 l := s.Logger.With("handler", "removeMember")
519
520 noticeId := "operation-error"
521 defaultErr := "Failed to remove member. Try again later."
522 fail := func() {
523 s.Pages.Notice(w, noticeId, defaultErr)
524 }
525
526 instance := chi.URLParam(r, "instance")
527 if instance == "" {
528 l.Error("empty instance")
529 fail()
530 return
531 }
532 l = l.With("instance", instance)
533 l = l.With("user", user.Did)
534
535 spindles, err := db.GetSpindles(
536 r.Context(),
537 s.Db,
538 orm.FilterEq("owner", user.Did),
539 orm.FilterEq("instance", instance),
540 )
541 if err != nil || len(spindles) != 1 {
542 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles))
543 fail()
544 return
545 }
546
547 if string(spindles[0].Owner) != user.Did {
548 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
549 s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.")
550 return
551 }
552
553 member := r.FormValue("member")
554 member = strings.TrimPrefix(member, "@")
555 if member == "" {
556 l.Error("empty member")
557 s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
558 return
559 }
560 l = l.With("member", member)
561
562 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
563 if err != nil {
564 l.Error("failed to resolve member identity to handle", "err", err)
565 s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
566 return
567 }
568
569 members, err := db.GetSpindleMembers(
570 s.Db,
571 orm.FilterEq("did", user.Did),
572 orm.FilterEq("instance", instance),
573 orm.FilterEq("subject", memberId.DID),
574 )
575 if err != nil || len(members) != 1 {
576 l.Error("failed to get member", "err", err)
577 fail()
578 return
579 }
580
581 client, err := s.OAuth.AuthorizedClient(r)
582 if err != nil {
583 l.Error("failed to authorize client", "err", err)
584 fail()
585 return
586 }
587
588 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
589 Collection: tangled.SpindleMemberNSID,
590 Repo: user.Did,
591 Rkey: members[0].Rkey,
592 })
593 if err != nil {
594 l.Error("failed to delete record", "err", err)
595 fail()
596 return
597 }
598
599 s.Pages.HxRefresh(w)
600}