Monorepo for Tangled
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601package spindles
import ( "errors" "fmt" "log/slog" "net/http" "slices" "strings" "time"
"github.com/go-chi/chi/v5" "tangled.org/core/api/tangled" "tangled.org/core/appview/config" "tangled.org/core/appview/db" "tangled.org/core/appview/middleware" "tangled.org/core/appview/models" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/serververify" "tangled.org/core/idresolver" "tangled.org/core/orm" "tangled.org/core/rbac" "tangled.org/core/tid" "tangled.org/core/xrpc/xrpcclient"
comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util")
type Spindles struct { Db *db.DB OAuth *oauth.OAuth Pages *pages.Pages Config *config.Config Enforcer *rbac.Enforcer IdResolver *idresolver.Resolver Logger *slog.Logger}
func (s *Spindles) Router() http.Handler { r := chi.NewRouter()
r.With(middleware.AuthMiddleware(s.OAuth)).Get("/", s.spindles) r.With(middleware.AuthMiddleware(s.OAuth)).Post("/register", s.register)
r.With(middleware.AuthMiddleware(s.OAuth)).Get("/{instance}", s.dashboard) r.With(middleware.AuthMiddleware(s.OAuth)).Delete("/{instance}", s.delete)
r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/retry", s.retry) r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/add", s.addMember) r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/remove", s.removeMember)
return r}
func (s *Spindles) spindles(w http.ResponseWriter, r *http.Request) { user := s.OAuth.GetMultiAccountUser(r) all, err := db.GetSpindles( r.Context(), s.Db, orm.FilterEq("owner", user.Did), ) if err != nil { s.Logger.Error("failed to fetch spindles", "err", err) w.WriteHeader(http.StatusInternalServerError) return }
s.Pages.Spindles(w, pages.SpindlesParams{ BaseParams: pages.BaseParamsFromContext(r.Context()), Spindles: all, Tab: "spindles", })}
func (s *Spindles) dashboard(w http.ResponseWriter, r *http.Request) { l := s.Logger.With("handler", "dashboard")
user := s.OAuth.GetMultiAccountUser(r) l = l.With("user", user.Did)
instance := chi.URLParam(r, "instance") if instance == "" { return } l = l.With("instance", instance)
spindles, err := db.GetSpindles( r.Context(), s.Db, orm.FilterEq("instance", instance), orm.FilterEq("owner", user.Did), orm.FilterIsNot("verified", "null"), ) if err != nil || len(spindles) != 1 { l.Error("failed to get spindle", "err", err, "len(spindles)", len(spindles)) http.Error(w, "Not found", http.StatusNotFound) return }
spindle := spindles[0] members, err := s.Enforcer.GetSpindleUsersByRole("server:member", spindle.Instance) if err != nil { l.Error("failed to get spindle members", "err", err) http.Error(w, "Not found", http.StatusInternalServerError) return } slices.Sort(members)
repos, err := db.GetRepos( s.Db, orm.FilterEq("spindle", instance), ) if err != nil { l.Error("failed to get spindle repos", "err", err) http.Error(w, "Not found", http.StatusInternalServerError) return }
// organize repos by did repoMap := make(map[string][]models.Repo) for _, r := range repos { repoMap[r.Did] = append(repoMap[r.Did], r) }
s.Pages.SpindleDashboard(w, pages.SpindleDashboardParams{ BaseParams: pages.BaseParamsFromContext(r.Context()), Spindle: spindle, Members: members, Repos: repoMap, Tab: "spindles", })}
// this endpoint inserts a record on behalf of the user to register that domain//// when registered, it also makes a request to see if the spindle declares this users as its owner,// and if so, marks the spindle as verified.//// if the spindle is not up yet, the user is free to retry verification at a later pointfunc (s *Spindles) register(w http.ResponseWriter, r *http.Request) { user := s.OAuth.GetMultiAccountUser(r) l := s.Logger.With("handler", "register")
noticeId := "register-error" defaultErr := "Failed to register spindle. Try again later." fail := func() { s.Pages.Notice(w, noticeId, defaultErr) }
instance := r.FormValue("instance") // Strip protocol, trailing slashes, and whitespace // Rkey cannot contain slashes instance = strings.TrimSpace(instance) instance = strings.TrimPrefix(instance, "https://") instance = strings.TrimPrefix(instance, "http://") instance = strings.TrimSuffix(instance, "/") if instance == "" { s.Pages.Notice(w, noticeId, "Incomplete form.") return } l = l.With("instance", instance) l = l.With("user", user.Did)
tx, err := s.Db.Begin() if err != nil { l.Error("failed to start transaction", "err", err) fail() return } defer tx.Rollback()
if err := db.AddSpindle(tx, models.Spindle{ Owner: syntax.DID(user.Did), Instance: instance, }); err != nil { l.Error("failed to insert", "err", err) fail() return }
client, err := s.OAuth.AuthorizedClient(r) if err != nil { l.Error("failed to authorize client", "err", err) fail() return }
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) var exCid *string if ex != nil { exCid = ex.Cid }
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.SpindleNSID, Repo: user.Did, Rkey: instance, Record: &lexutil.LexiconTypeDecoder{ Val: &tangled.Spindle{ CreatedAt: time.Now().Format(time.RFC3339), }, }, SwapRecord: exCid, }) if err != nil { l.Error("failed to put record", "err", err) fail() return }
if err := tx.Commit(); err != nil { l.Error("failed to commit transaction", "err", err) fail() return }
s.Pages.HxRefresh(w)}
func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { user := s.OAuth.GetMultiAccountUser(r) l := s.Logger.With("handler", "delete")
noticeId := "operation-error" defaultErr := "Failed to delete spindle. Try again later." fail := func() { s.Pages.Notice(w, noticeId, defaultErr) }
instance := chi.URLParam(r, "instance") if instance == "" { l.Error("empty instance") fail() return }
spindles, err := db.GetSpindles( r.Context(), s.Db, orm.FilterEq("owner", user.Did), orm.FilterEq("instance", instance), ) if err != nil || len(spindles) != 1 { l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) fail() return }
if string(spindles[0].Owner) != user.Did { l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) s.Pages.Notice(w, noticeId, "Failed to delete spindle, unauthorized deletion attempt.") return }
tx, err := s.Db.Begin() if err != nil { l.Error("failed to start txn", "err", err) fail() return } defer func() { tx.Rollback() s.Enforcer.E.LoadPolicy() }()
// remove spindle members first err = db.RemoveSpindleMember( tx, orm.FilterEq("did", user.Did), orm.FilterEq("instance", instance), ) if err != nil { l.Error("failed to remove spindle members", "err", err) fail() return }
err = db.DeleteSpindle( tx, orm.FilterEq("owner", user.Did), orm.FilterEq("instance", instance), ) if err != nil { l.Error("failed to delete spindle", "err", err) fail() return }
// delete from enforcer if spindles[0].Verified != nil { err = s.Enforcer.RemoveSpindle(instance) if err != nil { l.Error("failed to update ACL", "err", err) fail() return } }
client, err := s.OAuth.AuthorizedClient(r) if err != nil { l.Error("failed to authorize client", "err", err) fail() return }
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.SpindleNSID, Repo: user.Did, Rkey: instance, }) if err != nil { // non-fatal l.Error("failed to delete record", "err", err) }
err = tx.Commit() if err != nil { l.Error("failed to delete spindle", "err", err) fail() return }
err = s.Enforcer.E.SavePolicy() if err != nil { l.Error("failed to update ACL", "err", err) s.Pages.HxRefresh(w) return }
shouldRedirect := r.Header.Get("shouldRedirect") if shouldRedirect == "true" { s.Pages.HxRedirect(w, "/settings/spindles") return }
w.Write([]byte{})}
func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) { user := s.OAuth.GetMultiAccountUser(r) l := s.Logger.With("handler", "retry")
noticeId := "operation-error" defaultErr := "Failed to verify spindle. Try again later." fail := func() { s.Pages.Notice(w, noticeId, defaultErr) }
instance := chi.URLParam(r, "instance") if instance == "" { l.Error("empty instance") fail() return } l = l.With("instance", instance) l = l.With("user", user.Did)
spindles, err := db.GetSpindles( r.Context(), s.Db, orm.FilterEq("owner", user.Did), orm.FilterEq("instance", instance), ) if err != nil || len(spindles) != 1 { l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) fail() return }
if string(spindles[0].Owner) != user.Did { l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) s.Pages.Notice(w, noticeId, "Failed to verify spindle, unauthorized verification attempt.") return }
// begin verification err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) if err != nil { l.Error("verification failed", "err", err)
if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { s.Pages.Notice(w, noticeId, "Failed to verify spindle, XRPC queries are unsupported on this spindle, consider upgrading!") return }
if e, ok := err.(*serververify.OwnerMismatch); ok { s.Pages.Notice(w, noticeId, e.Error()) return }
fail() return }
rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) if err != nil { l.Error("failed to mark verified", "err", err) s.Pages.Notice(w, noticeId, err.Error()) return }
verifiedSpindle, err := db.GetSpindles( r.Context(), s.Db, orm.FilterEq("id", rowId), ) if err != nil || len(verifiedSpindle) != 1 { l.Error("failed get new spindle", "err", err) s.Pages.HxRefresh(w) return }
shouldRefresh := r.Header.Get("shouldRefresh") if shouldRefresh == "true" { s.Pages.HxRefresh(w) return }
w.Header().Set("HX-Reswap", "outerHTML") s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]})}
func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) { user := s.OAuth.GetMultiAccountUser(r) l := s.Logger.With("handler", "addMember")
instance := chi.URLParam(r, "instance") if instance == "" { l.Error("empty instance") http.Error(w, "Not found", http.StatusNotFound) return } l = l.With("instance", instance) l = l.With("user", user.Did)
spindles, err := db.GetSpindles( r.Context(), s.Db, orm.FilterEq("owner", user.Did), orm.FilterEq("instance", instance), ) if err != nil || len(spindles) != 1 { l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) http.Error(w, "Not found", http.StatusNotFound) return }
noticeId := fmt.Sprintf("add-member-error-%d", spindles[0].Id) defaultErr := "Failed to add member. Try again later." fail := func() { s.Pages.Notice(w, noticeId, defaultErr) }
if string(spindles[0].Owner) != user.Did { l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") return }
member := r.FormValue("member") member = strings.TrimPrefix(member, "@") if member == "" { l.Error("empty member") s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") return } l = l.With("member", member)
memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) if err != nil { l.Error("failed to resolve member identity to handle", "err", err) s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") return } if memberId.Handle.IsInvalidHandle() { l.Error("failed to resolve member identity to handle") s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") return }
// write to pds client, err := s.OAuth.AuthorizedClient(r) if err != nil { l.Error("failed to authorize client", "err", err) fail() return }
rkey := tid.TID()
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.SpindleMemberNSID, Repo: user.Did, Rkey: rkey, Record: &lexutil.LexiconTypeDecoder{ Val: &tangled.SpindleMember{ CreatedAt: time.Now().Format(time.RFC3339), Instance: instance, Subject: memberId.DID.String(), }, }, }) if err != nil { l.Error("failed to add record to PDS", "err", err) s.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") return }
// success s.Pages.HxRedirect(w, fmt.Sprintf("/settings/spindles/%s", instance))}
func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) { user := s.OAuth.GetMultiAccountUser(r) l := s.Logger.With("handler", "removeMember")
noticeId := "operation-error" defaultErr := "Failed to remove member. Try again later." fail := func() { s.Pages.Notice(w, noticeId, defaultErr) }
instance := chi.URLParam(r, "instance") if instance == "" { l.Error("empty instance") fail() return } l = l.With("instance", instance) l = l.With("user", user.Did)
spindles, err := db.GetSpindles( r.Context(), s.Db, orm.FilterEq("owner", user.Did), orm.FilterEq("instance", instance), ) if err != nil || len(spindles) != 1 { l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) fail() return }
if string(spindles[0].Owner) != user.Did { l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") return }
member := r.FormValue("member") member = strings.TrimPrefix(member, "@") if member == "" { l.Error("empty member") s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") return } l = l.With("member", member)
memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) if err != nil { l.Error("failed to resolve member identity to handle", "err", err) s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") return }
members, err := db.GetSpindleMembers( s.Db, orm.FilterEq("did", user.Did), orm.FilterEq("instance", instance), orm.FilterEq("subject", memberId.DID), ) if err != nil || len(members) != 1 { l.Error("failed to get member", "err", err) fail() return }
client, err := s.OAuth.AuthorizedClient(r) if err != nil { l.Error("failed to authorize client", "err", err) fail() return }
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.SpindleMemberNSID, Repo: user.Did, Rkey: members[0].Rkey, }) if err != nil { l.Error("failed to delete record", "err", err) fail() return }
s.Pages.HxRefresh(w)}