package web import ( "errors" "net/http" "strings" "github.com/go-chi/chi/v5" "sourcecraft.dev/bigbes/sr-ht-core/config" "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 browseErr string ) 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 { browseErr = err.Error() } // 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 { browseErr = err.Error() } } else { browseErr = err.Error() } view := struct { chrome.Page Repo *core.Repo Branches []browse.Branch DefaultBranch string Commits []browse.CommitInfo Views []View CloneURL string BrowseError string }{ Page: a.page(r, repo.OwnerName+"/"+repo.Name+" — "+serviceName), Repo: repo, Branches: branches, DefaultBranch: defBr, Commits: commits, Views: views, CloneURL: a.cloneURL(repo), BrowseError: browseErr, } 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 } }