package web
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
"sourcecraft.dev/bigbes/sr-ht-dolt/db"
)
// loadRepoForAdmin loads the {user}/{db} repo and enforces the owner-only admin
// gate for settings. Login is required (anonymous → login redirect). A missing
// repo, or a hidden PRIVATE repo the caller cannot even browse, is reported as
// not found; a visible repo the caller does not own is a plain 403. On any of
// these it writes the response and returns ok=false.
func (a *app) loadRepoForAdmin(w http.ResponseWriter, r *http.Request) (repo *core.Repo, ac *authContext, ok bool) {
ac = a.requireLogin(w, r)
if ac == nil {
return nil, nil, false
}
_, caller := callerOf(r.Context())
owner := chi.URLParam(r, "user")
name := chi.URLParam(r, "db")
repo, err := a.cfg.Repos.GetRepoByOwnerAndName(r.Context(), owner, name)
if err != nil {
a.notFound(w, r)
return nil, nil, false
}
if caller.UserID != repo.OwnerID {
aclMode := a.effectiveACL(r, caller, repo)
if core.NotFoundForPrivate(caller, repo, aclMode) {
a.notFound(w, r)
} else {
a.forbidden(w, r, "Only the owner may change database settings.")
}
return nil, nil, false
}
return repo, ac, true
}
// settingsView is the settings page model.
type settingsView struct {
basePage
Repo *core.Repo
ACL []*db.ACLEntry
Error string
Notice string
}
func (a *app) renderSettings(w http.ResponseWriter, r *http.Request, status int, repo *core.Repo, errMsg, notice string) {
acl, err := a.cfg.Repos.ListACL(r.Context(), repo.ID)
if err != nil {
http.Error(w, "failed to list access", http.StatusInternalServerError)
return
}
view := settingsView{
basePage: a.newBasePage(r, "Settings — "+repo.OwnerName+"/"+repo.Name),
Repo: repo,
ACL: acl,
Error: errMsg,
Notice: notice,
}
a.render(w, status, "settings.html", view)
}
// handleSettings renders the settings page (description/visibility, ACLs, danger
// zone). Owner only.
func (a *app) handleSettings(w http.ResponseWriter, r *http.Request) {
repo, _, ok := a.loadRepoForAdmin(w, r)
if !ok {
return
}
a.renderSettings(w, r, http.StatusOK, repo, "", "")
}
// handleSettingsPost dispatches the settings form on its "action" field:
// update (description + visibility), acl_add, acl_remove, or delete. Owner only,
// same-origin only.
func (a *app) handleSettingsPost(w http.ResponseWriter, r *http.Request) {
repo, _, ok := a.loadRepoForAdmin(w, r)
if !ok {
return
}
if !a.checkSameOrigin(r) {
a.forbidden(w, r, "Cross-origin request rejected.")
return
}
if err := r.ParseForm(); err != nil {
a.renderSettings(w, r, http.StatusBadRequest, repo, "Malformed form submission.", "")
return
}
switch r.PostFormValue("action") {
case "update":
a.settingsUpdate(w, r, repo)
case "acl_add":
a.settingsACLAdd(w, r, repo)
case "acl_remove":
a.settingsACLRemove(w, r, repo)
case "delete":
a.settingsDelete(w, r, repo)
default:
a.renderSettings(w, r, http.StatusBadRequest, repo, "Unknown action.", "")
}
}
// settingsUpdate applies the description + visibility change.
func (a *app) settingsUpdate(w http.ResponseWriter, r *http.Request, repo *core.Repo) {
description := strings.TrimSpace(r.PostFormValue("description"))
visibility, ok := parseVisibility(r.PostFormValue("visibility"))
if !ok {
a.renderSettings(w, r, http.StatusBadRequest, repo, "Invalid visibility.", "")
return
}
if err := a.cfg.Repos.UpdateRepo(r.Context(), repo.ID, description, visibility); err != nil {
http.Error(w, "failed to update database", http.StatusInternalServerError)
return
}
repo.Description = description
repo.Visibility = visibility
a.renderSettings(w, r, http.StatusOK, repo, "", "Settings saved.")
}
// settingsACLAdd grants (or updates) an ACL entry for a username. The grantee is
// resolved via the user resolver, which mirrors the meta profile on first sight.
func (a *app) settingsACLAdd(w http.ResponseWriter, r *http.Request, repo *core.Repo) {
username := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("username")), "~")
mode, ok := parseAccessMode(r.PostFormValue("mode"))
if !ok {
a.renderSettings(w, r, http.StatusBadRequest, repo, "Invalid access mode.", "")
return
}
if username == "" {
a.renderSettings(w, r, http.StatusBadRequest, repo, "A username is required.", "")
return
}
grantee, err := a.cfg.Users.LookupUser(r.Context(), username)
if err != nil || grantee == nil {
a.renderSettings(w, r, http.StatusBadRequest, repo,
"No such user: "+username, "")
return
}
if grantee.UserID == repo.OwnerID {
a.renderSettings(w, r, http.StatusBadRequest, repo,
"The owner already has full access.", "")
return
}
if err := a.cfg.Repos.UpsertACL(r.Context(), repo.ID, grantee.UserID, mode); err != nil {
http.Error(w, "failed to grant access", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, http.StatusOK, repo, "", "Access granted to "+username+".")
}
// settingsACLRemove revokes an ACL entry by user id.
func (a *app) settingsACLRemove(w http.ResponseWriter, r *http.Request, repo *core.Repo) {
userID, err := strconv.Atoi(r.PostFormValue("user_id"))
if err != nil {
a.renderSettings(w, r, http.StatusBadRequest, repo, "Invalid user.", "")
return
}
if err := a.cfg.Repos.DeleteACL(r.Context(), repo.ID, userID); err != nil {
if errors.Is(err, db.ErrNotFound) {
a.renderSettings(w, r, http.StatusNotFound, repo, "No such access entry.", "")
return
}
http.Error(w, "failed to revoke access", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, http.StatusOK, repo, "", "Access revoked.")
}
// settingsDelete deletes the database after a name-confirmation check: the row,
// then the on-disk store, then the served-cache handle. The confirmation guards
// against accidental deletion.
func (a *app) settingsDelete(w http.ResponseWriter, r *http.Request, repo *core.Repo) {
if r.PostFormValue("confirm_name") != repo.Name {
a.renderSettings(w, r, http.StatusBadRequest, repo,
"Type the database name exactly to confirm deletion.", "")
return
}
if err := a.cfg.Repos.DeleteRepo(r.Context(), repo.ID); err != nil {
http.Error(w, "failed to delete database", http.StatusInternalServerError)
return
}
if err := a.cfg.Stores.DeleteStore(r.Context(), a.cfg.ReposRoot, repo.Path); err != nil {
http.Error(w, "database record removed but store deletion failed: "+err.Error(),
http.StatusInternalServerError)
return
}
if err := a.cfg.Stores.Evict(repo.Path); err != nil {
http.Error(w, "store deleted but cache eviction failed: "+err.Error(),
http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// parseAccessMode validates and maps a form access-mode string.
func parseAccessMode(s string) (core.AccessMode, bool) {
switch core.AccessMode(s) {
case core.AccessRO:
return core.AccessRO, true
case core.AccessRW:
return core.AccessRW, true
default:
return "", false
}
}