package web import ( "errors" "net/http" "strings" "git.sr.ht/~sircmpwn/core-go/config" "github.com/go-chi/chi/v5" "go.bigb.es/sourcehut-dolt/browse" "go.bigb.es/sourcehut-dolt/core" "go.bigb.es/sourcehut-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 { basePage Repos []*core.Repo }{basePage: a.newBasePage(r, serviceName)} 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 = repos } a.render(w, http.StatusOK, "index.html", 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 { basePage Form createForm Error string }{ basePage: a.newBasePage(r, "Create database — "+serviceName), Form: form, Error: errMsg, } a.render(w, status, "create.html", 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 and a same-origin POST // are required. func (a *app) handleCreate(w http.ResponseWriter, r *http.Request) { ac := a.requireLogin(w, r) if ac == nil { return } if !a.checkSameOrigin(r) { a.forbidden(w, r, "Cross-origin request rejected.") return } if err := r.ParseForm(); err != nil { a.renderCreate(w, r, http.StatusBadRequest, createForm{}, "Malformed form submission.") return } form := createForm{ Name: strings.TrimSpace(r.PostFormValue("name")), Description: strings.TrimSpace(r.PostFormValue("description")), Visibility: r.PostFormValue("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 { basePage Owner string Repos []*core.Repo }{ basePage: a.newBasePage(r, "~"+owner+" — "+serviceName), Owner: owner, Repos: repos, } a.render(w, http.StatusOK, "user.html", view) } // 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 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() } } } else { browseErr = err.Error() } } else { browseErr = err.Error() } view := struct { basePage Repo *core.Repo Branches []browse.Branch DefaultBranch string Commits []browse.CommitInfo CloneURL string BrowseError string }{ basePage: a.newBasePage(r, repo.OwnerName+"/"+repo.Name+" — "+serviceName), Repo: repo, Branches: branches, DefaultBranch: defBr, Commits: commits, CloneURL: a.cloneURL(r, repo), BrowseError: browseErr, } a.render(w, http.StatusOK, "overview.html", view) } // cloneURL builds the HTTPS clone URL for repo: {self origin}/~owner/name. func (a *app) cloneURL(r *http.Request, repo *core.Repo) string { return a.newBasePage(r, "").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 } }