package web import ( "context" "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 // Initialize asks for an "Initialize data repository" commit in the new // store. It defaults to OFF: an initial commit makes the first push from a // database with a history of its own a non-fast-forward (dolt decides that // on the client), so it would have to be forced. On costs the opposite — // the database is clonable immediately, which an empty store is not. Initialize bool } 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"), // An unchecked checkbox is simply absent from the submission, so any // value at all means checked. Initialize: values.Get("initialize") != "", } 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 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 } // Empty by default, so the first push from a database that already has a // history lands as this one's initial history rather than being rejected as // a non-fast-forward. The checkbox buys the opposite trade: an initial // commit, and with it a database that can be cloned before anything is // pushed to it. if err := a.initStore(r.Context(), diskPath, ac, form.Initialize); err != nil { // Both init paths self-clean their 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) } // initStore materializes the on-disk store for a newly created database. With // initialize false — the default — it writes a store with no commits at all, so // the owner's first push is a fast-forward from empty. With initialize true it // writes the "Initialize data repository" commit, whose author is cosmetic // (real pushes overwrite it): the instance owner from the config, falling back // to the creating user's own name and address. func (a *app) initStore(ctx context.Context, diskPath string, ac *authContext, initialize bool) error { if !initialize { return a.cfg.Stores.InitEmptyStore(ctx, diskPath) } ownerName, ownerEmail := config.GetOwner(a.cfg.Conf) if ownerName == "" { ownerName = ac.Username } if ownerEmail == "" { ownerEmail = ac.Email } return a.cfg.Stores.InitStore(ctx, diskPath, ownerName, ownerEmail) } // 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 Empty 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 database nothing has been pushed to yet: it has no branches and the // store read fine, so the emptiness is the answer rather than a symptom. // The page then teaches push instead of clone — a store with no commits // cannot be cloned at all, dolt refuses it as "contains no Dolt data". // A browse failure is deliberately NOT empty: an unreadable store must // not be advertised as a fresh one waiting for its first push. Empty: !browseFailed && len(branches) == 0, } 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 } }