| 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 |
|
} |