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