package web
import (
"errors"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"sourcecraft.dev/bigbes/sr-ht-core/config"
"go.bigb.es/auxilia/scribe"
"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
"sourcecraft.dev/bigbes/sr-ht-ecore/pages"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
"sourcecraft.dev/bigbes/sr-ht-dolt/db"
)
// overviewCommitLimit is how many recent commits the database overview shows.
const overviewCommitLimit = 10
// handleIndex renders the dashboard: the signed-in user's databases (owned +
// ACL) with a create link, or an anonymous welcome blurb.
func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) {
ac, caller := callerOf(r.Context())
view := struct {
chrome.Page
Repos chrome.RepoList
}{Page: a.page(r, serviceName), Repos: repoList(nil)}
if ac != nil {
repos, err := a.cfg.Repos.ListReposForDashboard(r.Context(), caller.UserID)
if err != nil {
http.Error(w, "failed to list databases", http.StatusInternalServerError)
return
}
view.Repos = repoList(repos)
}
a.render(w, http.StatusOK, "index", view)
}
// handleCreateForm renders the new-database form. Login is required.
func (a *app) handleCreateForm(w http.ResponseWriter, r *http.Request) {
if ac := a.requireLogin(w, r); ac == nil {
return
}
a.renderCreate(w, r, http.StatusOK, createForm{Visibility: string(core.VisibilityPublic)}, "")
}
// createForm is the create page's sticky form state.
type createForm struct {
Name string
Description string
Visibility string
}
func (a *app) renderCreate(w http.ResponseWriter, r *http.Request, status int, form createForm, errMsg string) {
view := struct {
chrome.Page
Form createForm
Error string
}{
Page: a.page(r, "Create database — "+serviceName),
Form: form,
Error: errMsg,
}
a.render(w, status, "create", view)
}
// handleCreate processes the new-database form. It validates the name, creates
// the metadata row, then the on-disk store; on store-init failure it removes the
// just-created row so no orphan metadata survives. Login is required; the
// same-origin check is the router's (csrf.Require) and has already run.
func (a *app) handleCreate(w http.ResponseWriter, r *http.Request) {
ac := a.requireLogin(w, r)
if ac == nil {
return
}
values, err := pages.FormValues(w, r, 0)
if err != nil {
a.renderCreate(w, r, http.StatusBadRequest, createForm{}, "Malformed form submission.")
return
}
form := createForm{
Name: strings.TrimSpace(values.Get("name")),
Description: strings.TrimSpace(values.Get("description")),
Visibility: values.Get("visibility"),
}
visibility, ok := parseVisibility(form.Visibility)
if !ok {
a.renderCreate(w, r, http.StatusBadRequest, form, "Invalid visibility.")
return
}
if err := core.ValidateName(form.Name); err != nil {
a.renderCreate(w, r, http.StatusBadRequest, form, err.Error())
return
}
owner := ac.Username
ownerName, ownerEmail := config.GetOwner(a.cfg.Conf)
if ownerEmail == "" {
ownerEmail = ac.Email
}
if ownerName == "" {
ownerName = owner
}
diskPath := a.cfg.RepoDiskPath(owner, form.Name)
repo := &core.Repo{
Name: form.Name,
Description: form.Description,
OwnerID: ac.UserID,
OwnerName: owner,
Path: diskPath,
Visibility: visibility,
}
// Insert the metadata row first: a name collision (ErrNameTaken) is caught
// before we ever touch disk. Then create the on-disk store; if that fails,
// remove the row we just inserted so metadata and disk never diverge.
created, err := a.cfg.Repos.CreateRepo(r.Context(), repo)
if err != nil {
if errors.Is(err, db.ErrNameTaken) {
a.renderCreate(w, r, http.StatusConflict, form,
"You already have a database with that name.")
return
}
http.Error(w, "failed to create database", http.StatusInternalServerError)
return
}
if err := a.cfg.Stores.InitStore(r.Context(), diskPath, ownerName, ownerEmail); err != nil {
// InitStore self-cleans its directory; undo the metadata row too.
_ = a.cfg.Repos.DeleteRepo(r.Context(), created.ID)
http.Error(w, "failed to initialize database store", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/~"+owner+"/"+form.Name, http.StatusSeeOther)
}
// handleUser renders a single user's visible databases (~user listing).
func (a *app) handleUser(w http.ResponseWriter, r *http.Request) {
owner := chi.URLParam(r, "user")
_, caller := callerOf(r.Context())
repos, err := a.cfg.Repos.ListReposByOwner(r.Context(), owner, caller)
if err != nil {
http.Error(w, "failed to list databases", http.StatusInternalServerError)
return
}
view := struct {
chrome.Page
Owner string
Repos chrome.RepoList
}{
Page: a.page(r, "~"+owner+" — "+serviceName),
Owner: owner,
Repos: repoList(repos),
}
a.render(w, http.StatusOK, "user", view)
}
// repoList adapts our databases to the shared listing partial
// ("srht-repo-list"), which every custom service on the instance renders its
// projects through. The Href and Title are the only service-specific part: a
// database lives at /~owner/name and is named for it, exactly as a repository
// is on git.sr.ht.
func repoList(repos []*core.Repo) chrome.RepoList {
items := make([]chrome.ListItem, 0, len(repos))
for _, repo := range repos {
path := "/~" + repo.OwnerName + "/" + repo.Name
items = append(items, chrome.ListItem{
Href: path,
Title: path[1:],
Visibility: string(repo.Visibility),
Description: repo.Description,
})
}
return chrome.RepoList{Items: items, Empty: "No databases yet."}
}
// handleOverview renders the database overview: description, visibility badge,
// branch list, latest commits, and a clone box showing both auth flows.
func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) {
repo, _, _, ok := a.loadRepoForBrowse(w, r)
if !ok {
return
}
var (
branches []browse.Branch
defBr string
commits []browse.CommitInfo
views []View
// browseFailed says only that the history could not be read. It used to
// be the browse layer's own error text, rendered into the page: that
// string names dolt internals and the store's on-disk path, which
// nothing else on this surface discloses and which a reader can do
// nothing with. A bool rather than a message, so no error can reach the
// template by being assigned to it later.
browseFailed bool
)
// browseFailure records the reason where it belongs — the operator's log,
// against the database it happened to — and leaves the page a fixed sentence.
browseFailure := func(what string, err error) {
browseFailed = true
slog.Warn(what, "component", "web", "database", repo.ID, scribe.Err(err))
}
if sess, err := a.cfg.Browse.Open(r.Context(), repo.Path); err == nil {
defer sess.Close()
if bs, err := sess.Branches(r.Context()); err == nil {
branches = bs
defBr = browse.DefaultBranch(bs)
if defBr != "" {
if cs, _, err := sess.Log(r.Context(), defBr, "", overviewCommitLimit); err == nil {
commits = cs
} else {
browseFailure("reading a database's log for the overview failed", err)
}
// Fingerprint the tables at the default branch to compute the
// optional alternative-view tabs. A browse failure here must not
// break the overview: on error we simply yield no view tabs.
if tables, err := sess.Tables(r.Context(), defBr); err == nil {
views = applicableViews(a.views, tables)
}
}
} else {
browseFailure("listing a database's branches for the overview failed", err)
}
} else {
browseFailure("opening a database for the overview failed", err)
}
view := struct {
chrome.Page
Repo *core.Repo
Branches []browse.Branch
DefaultBranch string
Commits []browse.CommitInfo
Views []View
CloneURL string
BrowseFailed bool
}{
Page: a.page(r, repo.OwnerName+"/"+repo.Name+" — "+serviceName),
Repo: repo,
Branches: branches,
DefaultBranch: defBr,
Commits: commits,
Views: views,
CloneURL: a.cloneURL(repo),
BrowseFailed: browseFailed,
}
a.render(w, http.StatusOK, "overview", view)
}
// cloneURL builds the HTTPS clone URL for repo: {self origin}/~owner/name. The
// origin is the chrome's, resolved once at startup from our config section, so
// a clone box and a nav link can never quote two different hosts for us.
func (a *app) cloneURL(repo *core.Repo) string {
return a.chrome.SelfOrigin() + "/~" + repo.OwnerName + "/" + repo.Name
}
// requireLogin returns the authenticated caller, or nil after redirecting an
// anonymous request to the login page.
func (a *app) requireLogin(w http.ResponseWriter, r *http.Request) *authContext {
ac, _ := callerOf(r.Context())
if ac == nil {
a.redirectLogin(w, r)
return nil
}
return ac
}
// parseVisibility validates and maps a form visibility string.
func parseVisibility(s string) (core.Visibility, bool) {
switch core.Visibility(s) {
case core.VisibilityPublic:
return core.VisibilityPublic, true
case core.VisibilityUnlisted:
return core.VisibilityUnlisted, true
case core.VisibilityPrivate:
return core.VisibilityPrivate, true
default:
return "", false
}
}