This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

wip: appview: deprecate spindle dashboard

Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
date (Jul 29, 2026, 11:02 PM +0900) commit 86b2068f parent c59b89c3 change-id xtlsovqu
+10 -989
+1 -43
appview/pages/pages.go
··· 17 17 "sync" 18 18 "time" 19 19 20 - "tangled.org/core/api/tangled" 21 20 "tangled.org/core/appview/cache" 22 21 "tangled.org/core/appview/commitverify" 23 22 "tangled.org/core/appview/config" ··· 675 674 676 675 type SpindlesParams struct { 677 676 BaseParams 678 - Spindles []models.Spindle 679 - Tab string 677 + Tab string 680 678 } 681 679 682 680 func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { 683 681 params.Tab = "spindles" 684 682 return p.execute("spindles/index", w, params) 685 - } 686 - 687 - type SpindleListingParams struct { 688 - models.Spindle 689 - Tab string 690 - } 691 - 692 - func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { 693 - return p.executePlain("spindles/fragments/spindleListing", w, params) 694 - } 695 - 696 - type SpindleDashboardParams struct { 697 - BaseParams 698 - Spindle models.Spindle 699 - Members []string 700 - Repos map[string][]models.Repo 701 - Tab string 702 - } 703 - 704 - func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { 705 - return p.execute("spindles/dashboard", w, params) 706 683 } 707 684 708 685 type NewRepoParams struct { ··· 1203 1180 type Collaborator struct { 1204 1181 Did string 1205 1182 Role string 1206 - } 1207 - 1208 - type RepoSettingsParams struct { 1209 - BaseParams 1210 - RepoInfo repoinfo.RepoInfo 1211 - Collaborators []Collaborator 1212 - Active string 1213 - Branches []types.Branch 1214 - Spindles []string 1215 - CurrentSpindle string 1216 - Secrets []*tangled.RepoListSecrets_Secret 1217 - 1218 - // TODO: use repoinfo.roles 1219 - IsCollaboratorInviteAllowed bool 1220 - } 1221 - 1222 - func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 1223 - params.Active = "settings" 1224 - return p.executeRepo("repo/settings", w, params) 1225 1183 } 1226 1184 1227 1185 type RepoGeneralSettingsParams struct {
-1
appview/pages/templates/repo/new.html
··· 229 229 </div> 230 230 <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 231 231 A spindle runs your CI workflows. 232 - You can also <a href="/settings/spindles" class="underline">register your own spindle</a>. 233 232 </p> 234 233 </div> 235 234 {{ end }}
-133
appview/pages/templates/spindles/dashboard.html
··· 1 - {{ define "title" }}{{ .Tab | capitalize }} &middot; {{.Spindle.Instance}} &middot; Tangled{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Settings</p> 6 - </div> 7 - <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 - <div class="col-span-1"> 10 - {{ template "user/settings/fragments/sidebar" . }} 11 - </div> 12 - <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 - {{ template "spindleDash" . }} 14 - </div> 15 - </section> 16 - </div> 17 - {{ end }} 18 - 19 - {{ define "spindleDash" }} 20 - <div> 21 - <div class="flex justify-between items-center"> 22 - <h2 class="text-lg pb-2 font-medium">{{ .Tab | capitalize }} &middot; {{ .Spindle.Instance }}</h2> 23 - <div id="right-side" class="flex gap-2"> 24 - {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 25 - {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Spindle.Owner) }} 26 - {{ if .Spindle.Verified }} 27 - <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} Verified</span> 28 - {{ if $isOwner }} 29 - {{ template "spindles/fragments/addMemberModal" .Spindle }} 30 - {{ end }} 31 - {{ else }} 32 - <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} Unverified</span> 33 - {{ if $isOwner }} 34 - {{ block "retryButton" .Spindle }} {{ end }} 35 - {{ end }} 36 - {{ end }} 37 - 38 - {{ if $isOwner }} 39 - {{ block "deleteButton" .Spindle }} {{ end }} 40 - {{ end }} 41 - </div> 42 - </div> 43 - <div id="operation-error" class="dark:text-red-400"></div> 44 - </div> 45 - 46 - {{ if .Members }} 47 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 48 - <div class="flex flex-col gap-2"> 49 - {{ block "member" . }} {{ end }} 50 - </div> 51 - </section> 52 - {{ end }} 53 - {{ end }} 54 - 55 - 56 - {{ define "member" }} 57 - {{ range .Members }} 58 - <div> 59 - <div class="flex justify-between items-center"> 60 - <div class="flex items-center gap-2"> 61 - {{ template "user/fragments/picHandleLink" . }} 62 - </div> 63 - {{ if ne $.LoggedInUser.Did . }} 64 - {{ block "removeMemberButton" (list $ . ) }} {{ end }} 65 - {{ end }} 66 - </div> 67 - <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 68 - {{ $repos := index $.Repos . }} 69 - {{ range $repos }} 70 - <div class="flex gap-2 items-center"> 71 - {{ i "book-marked" "size-4" }} 72 - <a href="/{{ .Did }}/{{ .Rkey }}"> 73 - {{ .Name }} 74 - </a> 75 - </div> 76 - {{ else }} 77 - <div class="text-gray-500 dark:text-gray-400"> 78 - No repositories configured yet. 79 - </div> 80 - {{ end }} 81 - </div> 82 - </div> 83 - {{ end }} 84 - {{ end }} 85 - 86 - {{ define "deleteButton" }} 87 - <button 88 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 89 - title="Delete spindle" 90 - hx-delete="/settings/spindles/{{ .Instance }}" 91 - hx-swap="outerHTML" 92 - hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?" 93 - hx-headers='{"shouldRedirect": "true"}' 94 - > 95 - {{ i "trash-2" "w-5 h-5" }} 96 - <span class="hidden md:inline">Delete</span> 97 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 98 - </button> 99 - {{ end }} 100 - 101 - 102 - {{ define "retryButton" }} 103 - <button 104 - class="btn gap-2 group" 105 - title="Retry spindle verification" 106 - hx-post="/settings/spindles/{{ .Instance }}/retry" 107 - hx-swap="none" 108 - hx-headers='{"shouldRefresh": "true"}' 109 - > 110 - {{ i "rotate-ccw" "w-5 h-5" }} 111 - <span class="hidden md:inline">Retry</span> 112 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 113 - </button> 114 - {{ end }} 115 - 116 - 117 - {{ define "removeMemberButton" }} 118 - {{ $root := index . 0 }} 119 - {{ $member := index . 1 }} 120 - <button 121 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 122 - title="Remove member" 123 - hx-post="/settings/spindles/{{ $root.Spindle.Instance }}/remove" 124 - hx-swap="none" 125 - hx-vals='{"member": "{{$member}}" }' 126 - hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?" 127 - > 128 - {{ i "user-minus" "w-4 h-4" }} 129 - Remove 130 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 131 - </button> 132 - {{ end }} 133 -
-65
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 1 - {{ define "spindles/fragments/addMemberModal" }} 2 - <button 3 - class="btn gap-2 group" 4 - title="Add member to this spindle" 5 - popovertarget="add-member-{{ .Instance }}" 6 - popovertargetaction="toggle" 7 - > 8 - {{ i "user-plus" "w-5 h-5" }} 9 - <span class="hidden md:inline">Add member</span> 10 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 11 - </button> 12 - 13 - <div 14 - id="add-member-{{ .Instance }}" 15 - popover 16 - class=" 17 - bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 18 - w-full md:w-96 p-4 rounded drop-shadow overflow-visible"> 19 - {{ block "addSpindleMemberPopover" . }} {{ end }} 20 - </div> 21 - {{ end }} 22 - 23 - {{ define "addSpindleMemberPopover" }} 24 - <form 25 - hx-post="/settings/spindles/{{ .Instance }}/add" 26 - hx-indicator="#spinner" 27 - hx-swap="none" 28 - class="flex flex-col gap-2" 29 - > 30 - <label for="member-did-{{ .Id }}" class="text-lg font-medium p-0"> 31 - Add member 32 - </label> 33 - <p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p> 34 - <actor-typeahead> 35 - <input 36 - autocapitalize="none" 37 - autocorrect="off" 38 - autocomplete="off" 39 - type="text" 40 - id="member-did-{{ .Id }}" 41 - name="member" 42 - required 43 - placeholder="user.tngl.sh" 44 - class="w-full" 45 - /> 46 - </actor-typeahead> 47 - <div class="flex gap-2 pt-2"> 48 - <button 49 - type="button" 50 - popovertarget="add-member-{{ .Instance }}" 51 - popovertargetaction="hide" 52 - class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 53 - > 54 - {{ i "x" "size-4" }} Cancel 55 - </button> 56 - <button type="submit" class="btn w-1/2 flex items-center"> 57 - <span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} Add</span> 58 - <span id="spinner" class="group"> 59 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 60 - </span> 61 - </button> 62 - </div> 63 - <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 64 - </form> 65 - {{ end }}
-77
appview/pages/templates/spindles/fragments/spindleListing.html
··· 1 - {{ define "spindles/fragments/spindleListing" }} 2 - <div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 - {{ block "spindleLeftSide" . }} {{ end }} 4 - {{ block "spindleRightSide" . }} {{ end }} 5 - </div> 6 - {{ end }} 7 - 8 - {{ define "spindleLeftSide" }} 9 - {{ if .Verified }} 10 - <a href="/settings/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 - {{ i "hard-drive" "w-4 h-4" }} 12 - <span class="hover:underline"> 13 - {{ .Instance }} 14 - </span> 15 - <span class="text-gray-500"> 16 - {{ template "repo/fragments/shortTimeAgo" .Created }} 17 - </span> 18 - </a> 19 - {{ else }} 20 - <div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 21 - {{ i "hard-drive" "w-4 h-4" }} 22 - {{ .Instance }} 23 - <span class="text-gray-500"> 24 - {{ template "repo/fragments/shortTimeAgo" .Created }} 25 - </span> 26 - </div> 27 - {{ end }} 28 - {{ end }} 29 - 30 - {{ define "spindleRightSide" }} 31 - <div id="right-side" class="flex gap-2"> 32 - {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 33 - 34 - {{ if .NeedsUpgrade }} 35 - <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> {{ i "shield-alert" "w-4 h-4" }} Needs upgrade </span> 36 - {{ block "spindleRetryButton" . }} {{ end }} 37 - {{ else if .Verified }} 38 - <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} Verified</span> 39 - {{ template "spindles/fragments/addMemberModal" . }} 40 - {{ else }} 41 - <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} Unverified</span> 42 - {{ block "spindleRetryButton" . }} {{ end }} 43 - {{ end }} 44 - 45 - {{ block "spindleDeleteButton" . }} {{ end }} 46 - </div> 47 - {{ end }} 48 - 49 - {{ define "spindleDeleteButton" }} 50 - <button 51 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 52 - title="Delete spindle" 53 - hx-delete="/settings/spindles/{{ .Instance }}" 54 - hx-swap="outerHTML" 55 - hx-target="#spindle-{{.Id}}" 56 - hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?" 57 - > 58 - {{ i "trash-2" "w-5 h-5" }} 59 - <span class="hidden md:inline">Delete</span> 60 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 61 - </button> 62 - {{ end }} 63 - 64 - 65 - {{ define "spindleRetryButton" }} 66 - <button 67 - class="btn gap-2 group" 68 - title="Retry spindle verification" 69 - hx-post="/settings/spindles/{{ .Instance }}/retry" 70 - hx-swap="none" 71 - hx-target="#spindle-{{.Id}}" 72 - > 73 - {{ i "rotate-ccw" "w-5 h-5" }} 74 - <span class="hidden md:inline">Retry</span> 75 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 76 - </button> 77 - {{ end }}
+9 -66
appview/pages/templates/spindles/index.html
··· 20 20 <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 21 <div class="col-span-1 md:col-span-2"> 22 22 <h2 class="text-lg pb-2 font-medium">Spindle</h2> 23 - {{ block "about" . }} {{ end }} 23 + <section class="rounded flex items-center gap-2"> 24 + <p class="text-gray-500 dark:text-gray-400"> 25 + Spindles are small CI runners. 26 + </p> 27 + </section> 24 28 </div> 25 29 <div class="col-span-1 md:col-span-1 md:justify-self-end"> 26 - {{ template "docsButton" . }} 30 + <a href="https://docs.tangled.org/spindles.html#self-hosting-guide" class="btn flex items-center gap-2"> 31 + {{ i "book" "size-4" }} 32 + Docs 33 + </a> 27 34 </div> 28 35 </div> 29 36 30 37 <section> 31 38 <div class="flex flex-col gap-6"> 32 - {{ block "list" . }} {{ end }} 33 - {{ block "register" . }} {{ end }} 34 39 </div> 35 40 </section> 36 41 {{ end }} 37 42 38 - {{ define "about" }} 39 - <section class="rounded flex items-center gap-2"> 40 - <p class="text-gray-500 dark:text-gray-400"> 41 - Spindles are small CI runners. 42 - </p> 43 - </section> 44 - {{ end }} 45 - 46 43 {{ define "list" }} 47 44 <section class="rounded w-full flex flex-col gap-2"> 48 45 <h2 class="text-lg font-medium py-2 dark:text-gray-300">Your spindles</h2> ··· 58 55 <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 59 56 </section> 60 57 {{ end }} 61 - 62 - {{ define "register" }} 63 - <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 64 - <h2 class="text-lg font-medium py-2 dark:text-gray-300">Register a spindle</h2> 65 - <p class="mb-2 dark:text-gray-300">Enter the hostname of your spindle to get started.</p> 66 - <form 67 - hx-post="/settings/spindles/register" 68 - class="max-w-2xl mb-2 space-y-4" 69 - hx-indicator="#register-button" 70 - hx-swap="none" 71 - > 72 - <div class="flex gap-2"> 73 - <input 74 - type="text" 75 - id="instance" 76 - name="instance" 77 - placeholder="spindle.example.com" 78 - required 79 - class="flex-1 w-full dark:bg-gray-800 dark:text-white dark:border-gray-700 dark:placeholder-gray-400 px-3 py-2 border rounded" 80 - > 81 - <button 82 - type="submit" 83 - id="register-button" 84 - class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 85 - > 86 - <span class="inline-flex items-center gap-2"> 87 - {{ i "plus" "w-4 h-4" }} 88 - Register 89 - </span> 90 - <span class="pl-2 hidden group-[.htmx-request]:inline"> 91 - {{ i "loader-circle" "w-4 h-4 animate-spin" }} 92 - </span> 93 - </button> 94 - </div> 95 - 96 - <div id="register-error" class="dark:text-red-400"></div> 97 - </form> 98 - 99 - </section> 100 - {{ end }} 101 - 102 - {{ define "docsButton" }} 103 - <a 104 - class="btn flex items-center gap-2" 105 - href="https://docs.tangled.org/spindles.html#self-hosting-guide"> 106 - {{ i "book" "size-4" }} 107 - Docs 108 - </a> 109 - <div 110 - id="add-email-modal" 111 - popover 112 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 113 - </div> 114 - {{ end }}
-44
appview/serververify/verify.go
··· 82 82 return nil 83 83 } 84 84 85 - // MarkSpindleVerified marks a spindle as verified in the DB and adds the user as its owner 86 - func MarkSpindleVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { 87 - tx, err := d.Begin() 88 - if err != nil { 89 - return 0, fmt.Errorf("failed to create txn: %w", err) 90 - } 91 - committed := false 92 - defer func() { 93 - if committed { 94 - return 95 - } 96 - tx.Rollback() 97 - e.E.LoadPolicy() 98 - }() 99 - 100 - // mark this spindle as verified in the db 101 - rowId, err := db.VerifySpindle( 102 - tx, 103 - orm.FilterEq("owner", owner), 104 - orm.FilterEq("instance", instance), 105 - ) 106 - if err != nil { 107 - return 0, fmt.Errorf("failed to write to DB: %w", err) 108 - } 109 - 110 - err = e.AddSpindleOwner(instance, owner) 111 - if err != nil { 112 - return 0, fmt.Errorf("failed to update ACL: %w", err) 113 - } 114 - 115 - err = tx.Commit() 116 - if err != nil { 117 - return 0, fmt.Errorf("failed to commit txn: %w", err) 118 - } 119 - 120 - err = e.E.SavePolicy() 121 - if err != nil { 122 - return 0, fmt.Errorf("failed to update ACL: %w", err) 123 - } 124 - committed = true 125 - 126 - return rowId, nil 127 - } 128 - 129 85 // MarkKnotVerified marks a knot as verified and sets up ownership/permissions 130 86 func MarkKnotVerified(d *db.DB, e *rbac.Enforcer, domain, owner string) error { 131 87 tx, err := d.BeginTx(context.Background(), nil)
-560
appview/spindles/spindles.go
··· 1 1 package spindles 2 2 3 3 import ( 4 - "errors" 5 - "fmt" 6 4 "log/slog" 7 5 "net/http" 8 - "slices" 9 - "strings" 10 - "time" 11 6 12 7 "github.com/go-chi/chi/v5" 13 - "tangled.org/core/api/tangled" 14 8 "tangled.org/core/appview/config" 15 9 "tangled.org/core/appview/db" 16 10 "tangled.org/core/appview/middleware" 17 - "tangled.org/core/appview/models" 18 11 "tangled.org/core/appview/oauth" 19 12 "tangled.org/core/appview/pages" 20 - "tangled.org/core/appview/serververify" 21 13 "tangled.org/core/idresolver" 22 - "tangled.org/core/orm" 23 14 "tangled.org/core/rbac" 24 - "tangled.org/core/tid" 25 - "tangled.org/core/xrpc/xrpcclient" 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 15 ) 31 16 32 17 type Spindles struct { ··· 43 28 r := chi.NewRouter() 44 29 45 30 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 31 55 32 return r 56 33 } 57 34 58 35 func (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 36 s.Pages.Spindles(w, pages.SpindlesParams{ 72 37 BaseParams: pages.BaseParamsFromContext(r.Context()), 73 - Spindles: all, 74 38 Tab: "spindles", 75 39 }) 76 40 } 77 - 78 - func (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 143 - func (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 - 223 - func (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 - 342 - func (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 - 426 - func (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 - 516 - func (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 - }