coverage~bigbes/sr-ht-dolt3523280cweb/handlers_settings.go

Coverage
80.5% 107/133 statements
Δ
+0.0
Blob
6e624cf
1 package web
2
3 import (
4 "errors"
5 "log/slog"
6 "net/http"
7 "net/url"
8 "strconv"
9 "strings"
10
11 "github.com/go-chi/chi/v5"
12
13 "go.bigb.es/auxilia/scribe"
14
15 "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
16 "sourcecraft.dev/bigbes/sr-ht-ecore/pages"
17
18 "sourcecraft.dev/bigbes/sr-ht-dolt/core"
19 "sourcecraft.dev/bigbes/sr-ht-dolt/db"
20 )
21
22 // loadRepoForAdmin loads the {user}/{db} repo and enforces the owner-only admin
23 // gate for settings. Login is required (anonymous → login redirect). A missing
24 // repo, or a hidden PRIVATE repo the caller cannot even browse, is reported as
25 // not found; a visible repo the caller does not own is a plain 403. On any of
26 // these it writes the response and returns ok=false.
27 //
28 // The lookup is classified by repoLookupFailed, exactly as the browse path is: a
29 // settings URL that answered "no such database" while the metadata store was
30 // unreachable would be the same lie told on a different page.
31 28 func (a *app) loadRepoForAdmin(w http.ResponseWriter, r *http.Request) (repo *core.Repo, ac *authContext, ok bool) {
32 28 ac = a.requireLogin(w, r)
33 28 if ac == nil {
34 1 return nil, nil, false
35 1 }
36 27 _, caller := callerOf(r.Context())
37 27
38 27 owner := chi.URLParam(r, "user")
39 27 name := chi.URLParam(r, "db")
40 27 repo, err := a.cfg.Repos.GetRepoByOwnerAndName(r.Context(), owner, name)
41 27 if err != nil {
42 1 a.repoLookupFailed(w, r, err)
43 1 return nil, nil, false
44 1 }
45
46 26 if caller.UserID != repo.OwnerID {
47 4 aclMode := a.effectiveACL(r, caller, repo)
48 4 if core.NotFoundForPrivate(caller, repo, aclMode) {
49 1 a.notFound(w, r)
50 3 } else {
51 3 a.forbidden(w, r, "Only the owner may change database settings.")
52 3 }
53 4 return nil, nil, false
54 }
55 22 return repo, ac, true
56 }
57
58 // settingsView is the settings page model.
59 type settingsView struct {
60 chrome.Page
61 Repo *core.Repo
62 ACL []*db.ACLEntry
63 Error string
64 Notice string
65 }
66
67 16 func (a *app) renderSettings(w http.ResponseWriter, r *http.Request, status int, repo *core.Repo, errMsg, notice string) {
68 16 acl, err := a.cfg.Repos.ListACL(r.Context(), repo.ID)
69 16 if err != nil {
70 0 http.Error(w, "failed to list access", http.StatusInternalServerError)
71 0 return
72 0 }
73 16 view := settingsView{
74 16 Page: a.page(r, "Settings — "+repo.OwnerName+"/"+repo.Name),
75 16 Repo: repo,
76 16 ACL: acl,
77 16 Error: errMsg,
78 16 Notice: notice,
79 16 }
80 16 a.render(w, status, "settings", view)
81 }
82
83 // handleSettings renders the settings page (name, description/visibility, ACLs,
84 // danger zone). Owner only.
85 //
86 // A completed rename lands here by redirect rather than by rendering in place,
87 // so the browser's address bar carries the new name; the "renamed" query
88 // parameter is how the notice survives that redirect. It is echoed back to the
89 // page, so it is name-validated first — the value is a redirect target we wrote
90 // ourselves, but nothing stops a reader from hand-editing the URL.
91 8 func (a *app) handleSettings(w http.ResponseWriter, r *http.Request) {
92 8 repo, _, ok := a.loadRepoForAdmin(w, r)
93 8 if !ok {
94 5 return
95 5 }
96 3 notice := ""
97 3 if from := r.URL.Query().Get("renamed"); from != "" && core.ValidateName(from) == nil {
98 1 notice = "Renamed from " + from + "."
99 1 }
100 3 a.renderSettings(w, r, http.StatusOK, repo, "", notice)
101 }
102
103 // handleSettingsPost dispatches the settings form on its "action" field:
104 // update (description + visibility), rename, acl_add, acl_remove, or delete.
105 // Owner only; the same-origin check is the router's (csrf.Require) and has
106 // already run.
107 20 func (a *app) handleSettingsPost(w http.ResponseWriter, r *http.Request) {
108 20 repo, _, ok := a.loadRepoForAdmin(w, r)
109 20 if !ok {
110 1 return
111 1 }
112 19 form, err := pages.FormValues(w, r, 0)
113 19 if err != nil {
114 1 a.renderSettings(w, r, http.StatusBadRequest, repo, "Malformed form submission.", "")
115 1 return
116 1 }
117
118 18 switch form.Get("action") {
119 1 case "update":
120 1 a.settingsUpdate(w, r, repo, form)
121 9 case "rename":
122 9 a.settingsRename(w, r, repo, form)
123 2 case "acl_add":
124 2 a.settingsACLAdd(w, r, repo, form)
125 1 case "acl_remove":
126 1 a.settingsACLRemove(w, r, repo, form)
127 4 case "delete":
128 4 a.settingsDelete(w, r, repo, form)
129 1 default:
130 1 a.renderSettings(w, r, http.StatusBadRequest, repo, "Unknown action.", "")
131 }
132 }
133
134 // settingsUpdate applies the description + visibility change.
135 1 func (a *app) settingsUpdate(w http.ResponseWriter, r *http.Request, repo *core.Repo, form url.Values) {
136 1 description := strings.TrimSpace(form.Get("description"))
137 1 visibility, ok := parseVisibility(form.Get("visibility"))
138 1 if !ok {
139 0 a.renderSettings(w, r, http.StatusBadRequest, repo, "Invalid visibility.", "")
140 0 return
141 0 }
142 1 if err := a.cfg.Repos.UpdateRepo(r.Context(), repo.ID, description, visibility); err != nil {
143 0 http.Error(w, "failed to update database", http.StatusInternalServerError)
144 0 return
145 0 }
146 1 repo.Description = description
147 1 repo.Visibility = visibility
148 1 a.renderSettings(w, r, http.StatusOK, repo, "", "Settings saved.")
149 }
150
151 // settingsRename moves the database to a new name. A database is one metadata
152 // row plus one on-disk store directory, and the name is written into both — the
153 // row's name column and its path, which storage.RepoDiskPath derives from
154 // (owner, name). Both must move, and the served handle memoized under the old
155 // path must go with them.
156 //
157 // The order mirrors creation, which inserts the row before it touches disk: the
158 // row moves first, so a name already taken is caught by the unique index while
159 // nothing on disk has changed, and once it has moved no request can re-open the
160 // store under the old path behind us. A failed store move then rolls the row
161 // back, so metadata and disk never disagree about where a database lives.
162 //
163 // Renaming does not leave a redirect behind: the old address stops resolving,
164 // exactly as it does on git.sr.ht, and clones pointing at it must have their
165 // remote updated. A companion database provisioned from a git repository will
166 // also be re-created under its old name by the next push to that repository —
167 // the hook provisions by the git repo's name, which this rename does not touch.
168 9 func (a *app) settingsRename(w http.ResponseWriter, r *http.Request, repo *core.Repo, form url.Values) {
169 9 newName := strings.TrimSpace(form.Get("name"))
170 9 if newName == repo.Name {
171 1 a.renderSettings(w, r, http.StatusOK, repo, "", "That is already the name of this database.")
172 1 return
173 1 }
174 8 if err := core.ValidateName(newName); err != nil {
175 3 a.renderSettings(w, r, http.StatusBadRequest, repo, err.Error(), "")
176 3 return
177 3 }
178
179 5 oldName, oldPath := repo.Name, repo.Path
180 5 newPath := a.cfg.RepoDiskPath(repo.OwnerName, newName)
181 5
182 5 if err := a.cfg.Repos.RenameRepo(r.Context(), repo.ID, newName, newPath); err != nil {
183 1 switch {
184 1 case errors.Is(err, db.ErrNameTaken):
185 1 a.renderSettings(w, r, http.StatusConflict, repo,
186 1 "You already have a database named "+newName+".", "")
187 0 case errors.Is(err, db.ErrNotFound):
188 0 a.notFound(w, r)
189 0 default:
190 0 http.Error(w, "failed to rename database", http.StatusInternalServerError)
191 }
192 1 return
193 }
194
195 4 if err := a.cfg.Stores.MoveStore(r.Context(), a.cfg.ReposRoot, oldPath, newPath); err != nil {
196 2 // The store-layer error names on-disk paths, which this surface never
197 2 // discloses; the reader gets the fact that matters to them — the rename
198 2 // did not happen — and the detail goes to the log against the id.
199 2 slog.Error("moving a database's on-disk store failed; rolling the renamed record back",
200 2 "component", "web", "database", repo.ID, scribe.Err(err))
201 2 if rerr := a.cfg.Repos.RenameRepo(r.Context(), repo.ID, oldName, oldPath); rerr != nil {
202 1 // Both halves failed: the row now names a database whose store is
203 1 // still at the old path, which no later request can repair on its
204 1 // own. This is the one outcome worth escalating to a human.
205 1 slog.Error("rolling a database's renamed record back failed; the record and its store disagree",
206 1 "component", "web", "database", repo.ID, scribe.Err(rerr))
207 1 http.Error(w, "The database record was renamed, but its on-disk store could not be moved and the record could not be restored. Contact support.",
208 1 http.StatusInternalServerError)
209 1 return
210 1 }
211 1 a.renderSettings(w, r, http.StatusInternalServerError, repo,
212 1 "The database could not be renamed: its on-disk store could not be moved.", "")
213 1 return
214 }
215
216 2 repo.Name, repo.Path = newName, newPath
217 2
218 2 if err := a.cfg.Stores.Evict(oldPath); err != nil {
219 1 // The rename itself is done — record and store are both at the new name
220 1 // — and only the memoized handle for the old path outlived it. Kept
221 1 // distinct from the failures above for that reason.
222 1 slog.Error("evicting a database's cached store handle failed after it was renamed",
223 1 "component", "web", "database", repo.ID, scribe.Err(err))
224 1 http.Error(w, "The database was renamed, but the cached handle for its old location could not be evicted. Contact support.",
225 1 http.StatusInternalServerError)
226 1 return
227 1 }
228
229 1 http.Redirect(w, r, "/~"+repo.OwnerName+"/"+newName+"/settings?renamed="+url.QueryEscape(oldName),
230 1 http.StatusSeeOther)
231 }
232
233 // settingsACLAdd grants (or updates) an ACL entry for a username. The grantee is
234 // resolved via the user resolver, which mirrors the meta profile on first sight.
235 2 func (a *app) settingsACLAdd(w http.ResponseWriter, r *http.Request, repo *core.Repo, form url.Values) {
236 2 username := strings.TrimPrefix(strings.TrimSpace(form.Get("username")), "~")
237 2 mode, ok := parseAccessMode(form.Get("mode"))
238 2 if !ok {
239 0 a.renderSettings(w, r, http.StatusBadRequest, repo, "Invalid access mode.", "")
240 0 return
241 0 }
242 2 if username == "" {
243 0 a.renderSettings(w, r, http.StatusBadRequest, repo, "A username is required.", "")
244 0 return
245 0 }
246
247 2 grantee, err := a.cfg.Users.LookupUser(r.Context(), username)
248 2 if err != nil || grantee == nil {
249 1 a.renderSettings(w, r, http.StatusBadRequest, repo,
250 1 "No such user: "+username, "")
251 1 return
252 1 }
253 1 if grantee.UserID == repo.OwnerID {
254 0 a.renderSettings(w, r, http.StatusBadRequest, repo,
255 0 "The owner already has full access.", "")
256 0 return
257 0 }
258 1 if err := a.cfg.Repos.UpsertACL(r.Context(), repo.ID, grantee.UserID, mode); err != nil {
259 0 http.Error(w, "failed to grant access", http.StatusInternalServerError)
260 0 return
261 0 }
262 1 a.renderSettings(w, r, http.StatusOK, repo, "", "Access granted to "+username+".")
263 }
264
265 // settingsACLRemove revokes an ACL entry by user id.
266 1 func (a *app) settingsACLRemove(w http.ResponseWriter, r *http.Request, repo *core.Repo, form url.Values) {
267 1 userID, err := strconv.Atoi(form.Get("user_id"))
268 1 if err != nil {
269 0 a.renderSettings(w, r, http.StatusBadRequest, repo, "Invalid user.", "")
270 0 return
271 0 }
272 1 if err := a.cfg.Repos.DeleteACL(r.Context(), repo.ID, userID); err != nil {
273 0 if errors.Is(err, db.ErrNotFound) {
274 0 a.renderSettings(w, r, http.StatusNotFound, repo, "No such access entry.", "")
275 0 return
276 0 }
277 0 http.Error(w, "failed to revoke access", http.StatusInternalServerError)
278 0 return
279 }
280 1 a.renderSettings(w, r, http.StatusOK, repo, "", "Access revoked.")
281 }
282
283 // settingsDelete deletes the database after a name-confirmation check: the row,
284 // then the on-disk store, then the served-cache handle. The confirmation guards
285 // against accidental deletion.
286 4 func (a *app) settingsDelete(w http.ResponseWriter, r *http.Request, repo *core.Repo, form url.Values) {
287 4 if form.Get("confirm_name") != repo.Name {
288 1 a.renderSettings(w, r, http.StatusBadRequest, repo,
289 1 "Type the database name exactly to confirm deletion.", "")
290 1 return
291 1 }
292
293 3 if err := a.cfg.Repos.DeleteRepo(r.Context(), repo.ID); err != nil {
294 0 http.Error(w, "failed to delete database", http.StatusInternalServerError)
295 0 return
296 0 }
297 3 if err := a.cfg.Stores.DeleteStore(r.Context(), a.cfg.ReposRoot, repo.Path); err != nil {
298 1 // The store-layer error names the on-disk path, which nothing else on
299 1 // this surface discloses. The reader keeps the fact that matters to
300 1 // them — the record is gone but the store may still be on disk — and
301 1 // the detail goes to the log against the database id.
302 1 slog.Error("deleting a database's on-disk store failed after the record was removed",
303 1 "component", "web", "database", repo.ID, scribe.Err(err))
304 1 http.Error(w, "The database record was removed, but the on-disk store could not be deleted. Contact support.",
305 1 http.StatusInternalServerError)
306 1 return
307 1 }
308 2 if err := a.cfg.Stores.Evict(repo.Path); err != nil {
309 1 // Same split: the store is gone, but the cached handle may survive it
310 1 // — a different fact from the one above, kept distinct on purpose.
311 1 slog.Error("evicting a database's cached store handle failed after the store was deleted",
312 1 "component", "web", "database", repo.ID, scribe.Err(err))
313 1 http.Error(w, "The on-disk store was deleted, but the cached handle could not be evicted. Contact support.",
314 1 http.StatusInternalServerError)
315 1 return
316 1 }
317 1 http.Redirect(w, r, "/", http.StatusSeeOther)
318 }
319
320 // parseAccessMode validates and maps a form access-mode string.
321 2 func parseAccessMode(s string) (core.AccessMode, bool) {
322 2 switch core.AccessMode(s) {
323 1 case core.AccessRO:
324 1 return core.AccessRO, true
325 1 case core.AccessRW:
326 1 return core.AccessRW, true
327 0 default:
328 0 return "", false
329 }
330 }