diff --git a/web/adapters.go b/web/adapters.go
new file mode 100644
index 0000000000000000000000000000000000000000..484c08f7823146ae34777d870bc8fceb9f81d2e4
--- /dev/null
+++ b/web/adapters.go
@@ -0,0 +1,107 @@
+package web
+
+import (
+ "context"
+
+ "git.sr.ht/~sircmpwn/core-go/auth"
+
+ "go.bigb.es/sourcehut-dolt/authn"
+ "go.bigb.es/sourcehut-dolt/browse"
+ "go.bigb.es/sourcehut-dolt/core"
+ "go.bigb.es/sourcehut-dolt/db"
+)
+
+// This file holds the production adapters that satisfy the small interfaces in
+// deps.go over the real db, browse and core-go layers. Tests do not use these;
+// they inject their own fakes. Each adapter is a zero-value struct with a
+// compile-time interface assertion so a signature drift in a committed package
+// breaks the build here rather than at Phase-3 wiring time.
+
+// DBAdapter satisfies RepoStore over the request-scoped db.Store. It holds no
+// connection: every method builds a Store from the *sql.DB the core-go database
+// middleware installed in ctx (db.FromContext), so one value serves all
+// requests and leaks nothing.
+type DBAdapter struct{}
+
+var _ RepoStore = DBAdapter{}
+
+func (DBAdapter) CreateRepo(ctx context.Context, r *core.Repo) (*core.Repo, error) {
+ return db.FromContext(ctx).CreateRepo(ctx, r)
+}
+
+func (DBAdapter) GetRepoByOwnerAndName(ctx context.Context, owner, name string) (*core.Repo, error) {
+ return db.FromContext(ctx).GetRepoByOwnerAndName(ctx, owner, name)
+}
+
+func (DBAdapter) ListReposByOwner(ctx context.Context, owner string, viewer *core.Caller) ([]*core.Repo, error) {
+ return db.FromContext(ctx).ListReposByOwner(ctx, owner, viewer)
+}
+
+func (DBAdapter) ListReposForDashboard(ctx context.Context, userID int) ([]*core.Repo, error) {
+ return db.FromContext(ctx).ListReposForDashboard(ctx, userID)
+}
+
+func (DBAdapter) UpdateRepo(ctx context.Context, id int, description string, visibility core.Visibility) error {
+ return db.FromContext(ctx).UpdateRepo(ctx, id, description, visibility)
+}
+
+func (DBAdapter) DeleteRepo(ctx context.Context, id int) error {
+ return db.FromContext(ctx).DeleteRepo(ctx, id)
+}
+
+func (DBAdapter) EffectiveAccess(ctx context.Context, userID, repoID int) (*core.AccessMode, error) {
+ return db.FromContext(ctx).EffectiveAccess(ctx, userID, repoID)
+}
+
+func (DBAdapter) ListACL(ctx context.Context, repoID int) ([]*db.ACLEntry, error) {
+ return db.FromContext(ctx).ListACL(ctx, repoID)
+}
+
+func (DBAdapter) UpsertACL(ctx context.Context, repoID, userID int, mode core.AccessMode) error {
+ return db.FromContext(ctx).UpsertACL(ctx, repoID, userID, mode)
+}
+
+func (DBAdapter) DeleteACL(ctx context.Context, repoID, userID int) error {
+ return db.FromContext(ctx).DeleteACL(ctx, repoID, userID)
+}
+
+func (DBAdapter) InsertKey(ctx context.Context, userID int, kid string, pubkey []byte, comment string) (*db.DoltKey, error) {
+ return db.FromContext(ctx).InsertKey(ctx, userID, kid, pubkey, comment)
+}
+
+func (DBAdapter) ListKeysByUser(ctx context.Context, userID int) ([]*db.DoltKey, error) {
+ return db.FromContext(ctx).ListKeysByUser(ctx, userID)
+}
+
+func (DBAdapter) DeleteKey(ctx context.Context, id, userID int) error {
+ return db.FromContext(ctx).DeleteKey(ctx, id, userID)
+}
+
+// BrowseAdapter satisfies BrowseOpener over browse.Open. The returned *browse.DB
+// already implements every BrowseSession method plus Close.
+type BrowseAdapter struct{}
+
+var _ BrowseOpener = BrowseAdapter{}
+
+func (BrowseAdapter) Open(ctx context.Context, diskPath string) (BrowseSession, error) {
+ dbh, err := browse.Open(ctx, diskPath)
+ if err != nil {
+ return nil, err
+ }
+ return dbh, nil
+}
+
+// MetaUserResolver satisfies UserResolver via core-go's auth.LookupUser, which
+// mirrors the meta.sr.ht profile into the local user table and yields the
+// account's UserID. Used to resolve an ACL grantee by username.
+type MetaUserResolver struct{}
+
+var _ UserResolver = MetaUserResolver{}
+
+func (MetaUserResolver) LookupUser(ctx context.Context, username string) (*core.Caller, error) {
+ var ac auth.AuthContext
+ if err := auth.LookupUser(ctx, username, &ac); err != nil {
+ return nil, err
+ }
+ return authn.AsCoreCaller(&ac), nil
+}
diff --git a/web/chrome.go b/web/chrome.go
new file mode 100644
index 0000000000000000000000000000000000000000..67c07a2f341bb7db0ca8bef9c12616569fa75ccf
--- /dev/null
+++ b/web/chrome.go
@@ -0,0 +1,150 @@
+package web
+
+import (
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+
+ "git.sr.ht/~sircmpwn/core-go/auth"
+ "git.sr.ht/~sircmpwn/core-go/config"
+ "github.com/vaughan0/go-ini"
+
+ "go.bigb.es/sourcehut-dolt/authn"
+)
+
+// serviceName is our own service key in the shared config and nav.
+const serviceName = "dolt.sr.ht"
+
+// networkOrder is upstream core.sr.ht's fixed nav ordering (flask.py
+// _network_order). Services present in the config but not listed here sort
+// after these, alphabetically. paste.sr.ht and pages.sr.ht are excluded from
+// the network entirely (they have no user-facing nav), mirroring _network.
+var networkOrder = []string{
+ "hub.sr.ht",
+ "git.sr.ht",
+ "hg.sr.ht",
+ "lists.sr.ht",
+ "todo.sr.ht",
+ "builds.sr.ht",
+ "man.sr.ht",
+ "meta.sr.ht",
+}
+
+var networkExcluded = map[string]bool{
+ "paste.sr.ht": true,
+ "pages.sr.ht": true,
+}
+
+// navEntry is one service link in the shared nav.
+type navEntry struct {
+ // Name is the leading segment of the service (e.g. "git" for git.sr.ht),
+ // rendered as the link text exactly as upstream nav.html does.
+ Name string
+ // Site is the full service key (e.g. "git.sr.ht").
+ Site string
+ // Origin is the external URL for the service.
+ Origin string
+ // Active is true for our own service, which highlights it in the nav.
+ Active bool
+}
+
+// basePage is the chrome model shared by every rendered page. Handler view
+// structs embed it so templates reference its fields directly (e.g. .SiteName).
+type basePage struct {
+ Title string
+ Site string
+ SiteLabel string
+ SiteName string
+ Environment string
+ ShowEnvBanner bool
+ Network []navEntry
+ MetaOrigin string
+ SelfOrigin string
+ LoginURL string
+ LogoutURL string
+ StyleHref string
+ // CurrentUser is the authenticated caller, or nil for an anonymous request.
+ CurrentUser *auth.AuthContext
+}
+
+// buildNetwork returns the ordered nav entries for conf: every section ending
+// ".sr.ht" that is not excluded, ordered by networkOrder then alphabetically
+// for unknown services. Origins are resolved externally via config.GetOrigin.
+func buildNetwork(conf ini.File) []navEntry {
+ var sites []string
+ for section := range conf {
+ if !strings.HasSuffix(section, ".sr.ht") || networkExcluded[section] {
+ continue
+ }
+ sites = append(sites, section)
+ }
+
+ orderIndex := func(s string) int {
+ for i, n := range networkOrder {
+ if n == s {
+ return i
+ }
+ }
+ return len(networkOrder) // unknown services sort after the fixed set
+ }
+ sort.Slice(sites, func(i, j int) bool {
+ oi, oj := orderIndex(sites[i]), orderIndex(sites[j])
+ if oi != oj {
+ return oi < oj
+ }
+ return sites[i] < sites[j] // stable, alphabetical among unknowns
+ })
+
+ entries := make([]navEntry, 0, len(sites))
+ for _, s := range sites {
+ entries = append(entries, navEntry{
+ Name: strings.SplitN(s, ".", 2)[0],
+ Site: s,
+ Origin: config.GetOrigin(conf, s, true),
+ Active: s == serviceName,
+ })
+ }
+ return entries
+}
+
+// newBasePage builds the chrome model for a request. Title is the per-page
+//
; the caller sets any page-specific fields on its own view struct.
+func (a *app) newBasePage(r *http.Request, title string) basePage {
+ conf := a.cfg.Conf
+ env := config.GetString(conf, "sr.ht", "environment", "development")
+ self := config.GetOrigin(conf, serviceName, true)
+ meta := config.GetOrigin(conf, "meta.sr.ht", true)
+
+ return basePage{
+ Title: title,
+ Site: serviceName,
+ SiteLabel: strings.SplitN(serviceName, ".", 2)[0],
+ SiteName: config.GetString(conf, "sr.ht", "site-name", "sr.ht"),
+ Environment: env,
+ ShowEnvBanner: env != "production",
+ Network: buildNetwork(conf),
+ MetaOrigin: meta,
+ SelfOrigin: self,
+ LoginURL: loginURL(self, meta, r),
+ LogoutURL: logoutURL(self, meta),
+ StyleHref: a.styleHref,
+ CurrentUser: authn.CallerFromContext(r.Context()),
+ }
+}
+
+// loginURL mirrors core.sr.ht flask.py: {meta}/login?return_to={self+full_path}.
+// full_path is the request path with its query string, so login round-trips the
+// user back to exactly where they were.
+func loginURL(self, meta string, r *http.Request) string {
+ returnTo := self + r.URL.EscapedPath()
+ if r.URL.RawQuery != "" {
+ returnTo += "?" + r.URL.RawQuery
+ }
+ return meta + "/login?return_to=" + url.QueryEscape(returnTo)
+}
+
+// logoutURL mirrors core.sr.ht flask.py: {meta}/logout?return_to={self origin}.
+func logoutURL(self, meta string) string {
+ return meta + "/logout?return_to=" + url.QueryEscape(self)
+}
diff --git a/web/csrf.go b/web/csrf.go
new file mode 100644
index 0000000000000000000000000000000000000000..07f674db46f2f8347695696e9b0736b43ade94f9
--- /dev/null
+++ b/web/csrf.go
@@ -0,0 +1,48 @@
+package web
+
+import (
+ "net/http"
+ "net/url"
+)
+
+// checkSameOrigin is dolt.sr.ht's CSRF defence for state-changing POSTs. core-go
+// ships no CSRF helper, so we keep it simple and explicit: a mutating request
+// must carry an Origin (or, failing that, a Referer) header whose scheme+host
+// matches our own configured origin. Cross-site form posts from a browser always
+// send an Origin that differs from ours, so this blocks them; same-origin form
+// submissions from our own pages always match.
+//
+// Rationale and limits (documented deliberately): we trust the Origin/Referer
+// header, which browsers set and script cannot forge cross-origin. A request
+// with NEITHER header is rejected — our own forms are same-origin and browsers
+// send Origin on form POSTs, so a missing header signals a non-browser or
+// stripped request, which we decline rather than wave through. This is a
+// header-check, not a token scheme; it is sufficient because dolt.sr.ht uses the
+// shared unified-login cookie (SameSite handling lives in meta) and has no
+// cross-origin embedding.
+func (a *app) checkSameOrigin(r *http.Request) bool {
+ selfOrigin := a.newBasePage(r, "").SelfOrigin
+ self, err := url.Parse(selfOrigin)
+ if err != nil || self.Host == "" {
+ return false
+ }
+
+ if origin := r.Header.Get("Origin"); origin != "" {
+ return originMatches(origin, self)
+ }
+ if referer := r.Header.Get("Referer"); referer != "" {
+ return originMatches(referer, self)
+ }
+ // No Origin and no Referer: refuse rather than assume same-origin.
+ return false
+}
+
+// originMatches reports whether raw (a full URL from an Origin or Referer
+// header) has the same scheme and host as self.
+func originMatches(raw string, self *url.URL) bool {
+ u, err := url.Parse(raw)
+ if err != nil {
+ return false
+ }
+ return u.Scheme == self.Scheme && u.Host == self.Host
+}
diff --git a/web/deps.go b/web/deps.go
new file mode 100644
index 0000000000000000000000000000000000000000..6d7f257a496fb7dcd095806e8869e7a6cf03022f
--- /dev/null
+++ b/web/deps.go
@@ -0,0 +1,140 @@
+// Package web is the HTTP layer of dolt.sr.ht: the chi router, request
+// handlers, SourceHut nav/chrome, and the html/template views for the database
+// dashboard, browse pages, settings and dolt-key management.
+//
+// # Dependency injection
+//
+// web is deliberately decoupled from the packages that touch Postgres, disk and
+// the remotesapi. It depends directly only on the pure/committed packages it
+// renders (core, browse) and authn (for the caller in the request context).
+// Everything with side effects — the metadata store, the on-disk store manager,
+// the browse opener, and username resolution against meta — is reached through
+// SMALL local interfaces declared here and satisfied by thin adapters (see
+// adapters.go for the production wiring, and the tests for fakes). This keeps
+// httptest coverage free of Postgres and dolt internals, and lets the Phase-3
+// main assemble the real Config without web importing storage/ or remoteapi/.
+package web
+
+import (
+ "context"
+
+ "github.com/vaughan0/go-ini"
+
+ "git.sr.ht/~sircmpwn/core-go/auth"
+
+ "go.bigb.es/sourcehut-dolt/authn"
+ "go.bigb.es/sourcehut-dolt/browse"
+ "go.bigb.es/sourcehut-dolt/core"
+ "go.bigb.es/sourcehut-dolt/db"
+)
+
+// Config carries everything the router and handlers need. The Phase-3 main
+// builds one and passes it to Register.
+type Config struct {
+ // Conf is the shared instance config (the same ini.File every *.sr.ht
+ // service reads). Used to render the nav/chrome and resolve origins.
+ Conf ini.File
+ // ReposRoot is the absolute directory holding the bare NBS stores, one per
+ // database at /~/. Passed to StoreManager.DeleteStore
+ // as the containment root.
+ ReposRoot string
+ // StaticDir is the directory holding built static assets (the hashed
+ // main.min..css and logo.svg). The CSS filename is discovered from it at
+ // Register time; "" falls back to the dev stylesheet /static/main.css.
+ StaticDir string
+
+ // Stores manages the on-disk NBS chunk stores. Satisfied in production by a
+ // storage-backed adapter (Phase 3); web never imports storage/.
+ Stores StoreManager
+ // Repos is the metadata store (repositories, ACLs, dolt keys). Satisfied in
+ // production by dbAdapter over db.Store; fakes are used in tests.
+ Repos RepoStore
+ // Browse opens read-only handles to bare stores for the browse pages.
+ // Satisfied in production by browseAdapter over browse.Open.
+ Browse BrowseOpener
+ // Users resolves a SourceHut username to its account (for ACL add-by-username),
+ // mirroring the meta profile on first sight. Satisfied in production by a
+ // core-go auth.LookupUser adapter.
+ Users UserResolver
+ // RepoDiskPath returns the absolute on-disk store dir for owner/name. In
+ // production this is storage.RepoDiskPath bound to ReposRoot.
+ RepoDiskPath func(owner, name string) string
+}
+
+// StoreManager is the on-disk store lifecycle the create/delete handlers drive.
+// It mirrors the storage package's InitStore/DeleteStore functions and the
+// Cache.Evict method; web declares it as an interface so it never imports
+// storage/.
+type StoreManager interface {
+ // InitStore creates a bare store at absPath and writes an empty repo authored
+ // by ownerName/ownerEmail. On any failure it must leave no partial store.
+ InitStore(ctx context.Context, absPath, ownerName, ownerEmail string) error
+ // DeleteStore removes the store at absPath, refusing anything outside root.
+ DeleteStore(ctx context.Context, root, absPath string) error
+ // Evict closes and drops any memoized served handle for diskPath, so a
+ // recreation at the same path never reuses a stale store.
+ Evict(diskPath string) error
+}
+
+// RepoStore is the subset of db.Store the handlers use. Declaring it as an
+// interface lets tests inject a fake without Postgres; the production dbAdapter
+// (adapters.go) is a compile-time-checked implementation over the real store.
+// Every method takes ctx first; the production adapter reads the request-scoped
+// *sql.DB from ctx (db.FromContext) so a single adapter value serves all
+// requests.
+type RepoStore interface {
+ CreateRepo(ctx context.Context, r *core.Repo) (*core.Repo, error)
+ GetRepoByOwnerAndName(ctx context.Context, ownerUsername, name string) (*core.Repo, error)
+ ListReposByOwner(ctx context.Context, ownerUsername string, viewer *core.Caller) ([]*core.Repo, error)
+ ListReposForDashboard(ctx context.Context, userID int) ([]*core.Repo, error)
+ UpdateRepo(ctx context.Context, id int, description string, visibility core.Visibility) error
+ DeleteRepo(ctx context.Context, id int) error
+
+ EffectiveAccess(ctx context.Context, userID, repoID int) (*core.AccessMode, error)
+ ListACL(ctx context.Context, repoID int) ([]*db.ACLEntry, error)
+ UpsertACL(ctx context.Context, repoID, userID int, mode core.AccessMode) error
+ DeleteACL(ctx context.Context, repoID, userID int) error
+
+ InsertKey(ctx context.Context, userID int, kid string, pubkey []byte, comment string) (*db.DoltKey, error)
+ ListKeysByUser(ctx context.Context, userID int) ([]*db.DoltKey, error)
+ DeleteKey(ctx context.Context, id, userID int) error
+}
+
+// BrowseSession is the read-only browse surface a single request uses. It is
+// exactly the method set of *browse.DB (plus Close), so the production adapter
+// returns a *browse.DB directly. Fakes implement it for httptest.
+type BrowseSession interface {
+ Branches(ctx context.Context) ([]browse.Branch, error)
+ Log(ctx context.Context, refStr, fromHash string, limit int) ([]browse.CommitInfo, string, error)
+ Tables(ctx context.Context, refStr string) ([]browse.TableInfo, error)
+ Rows(ctx context.Context, refStr, table string, offset, limit int) (*browse.RowPage, error)
+ CommitSummary(ctx context.Context, hashStr string) (*browse.CommitDiff, error)
+ Close() error
+}
+
+// BrowseOpener opens a BrowseSession over the bare store at diskPath. Open must
+// be paired with Session.Close by the caller (handlers defer it).
+type BrowseOpener interface {
+ Open(ctx context.Context, diskPath string) (BrowseSession, error)
+}
+
+// UserResolver resolves a username to a core account, mirroring the meta
+// profile into the local user table on first sight (so the resolved UserID can
+// be used as an ACL grantee). Returns an error the caller treats as "no such
+// user" for a permanent miss.
+type UserResolver interface {
+ LookupUser(ctx context.Context, username string) (*core.Caller, error)
+}
+
+// authContext aliases core-go's auth.AuthContext for brevity in handler
+// signatures; it is the authenticated caller (nil = anonymous).
+type authContext = auth.AuthContext
+
+// callerOf returns the resolved caller for a request context: the raw
+// *auth.AuthContext (nil = anonymous) for chrome rendering, and the pure
+// core.Caller for the access-control matrix. It is the single bridge from the
+// authn context value to the domain types used throughout the handlers.
+func callerOf(ctx context.Context) (*auth.AuthContext, *core.Caller) {
+ ac := authn.CallerFromContext(ctx)
+ return ac, authn.AsCoreCaller(ac)
+}
diff --git a/web/handlers_browse.go b/web/handlers_browse.go
new file mode 100644
index 0000000000000000000000000000000000000000..d87196d083260b388d6d3923ecb5ca777be6621f
--- /dev/null
+++ b/web/handlers_browse.go
@@ -0,0 +1,217 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+
+ "github.com/go-chi/chi/v5"
+
+ "go.bigb.es/sourcehut-dolt/browse"
+ "go.bigb.es/sourcehut-dolt/core"
+)
+
+const (
+ // logPageSize is the number of commits per /log page.
+ logPageSize = 20
+ // rowsPageSize is the number of rows per /table page.
+ rowsPageSize = 50
+)
+
+// openBrowse opens a browse session for repo, writing a 500 and returning
+// ok=false on failure. The caller must Close the returned session.
+func (a *app) openBrowse(w http.ResponseWriter, r *http.Request, repo *core.Repo) (BrowseSession, bool) {
+ sess, err := a.cfg.Browse.Open(r.Context(), repo.Path)
+ if err != nil {
+ http.Error(w, "failed to open database", http.StatusInternalServerError)
+ return nil, false
+ }
+ return sess, true
+}
+
+// handleLog renders a paginated commit log for a branch (or ref). Pages after
+// the first are reached via ?from= (the nextHash from the prior page); the
+// active branch is chosen with ?branch=.
+func (a *app) handleLog(w http.ResponseWriter, r *http.Request) {
+ repo, _, _, ok := a.loadRepoForBrowse(w, r)
+ if !ok {
+ return
+ }
+ sess, ok := a.openBrowse(w, r, repo)
+ if !ok {
+ return
+ }
+ defer sess.Close()
+
+ branches, err := sess.Branches(r.Context())
+ if err != nil {
+ http.Error(w, "failed to list branches", http.StatusInternalServerError)
+ return
+ }
+
+ branch := r.URL.Query().Get("branch")
+ if branch == "" {
+ branch = browse.DefaultBranch(branches)
+ }
+ fromHash := r.URL.Query().Get("from")
+
+ commits, nextHash, err := sess.Log(r.Context(), branch, fromHash, logPageSize)
+ if err != nil {
+ if errors.Is(err, browse.ErrRefNotFound) {
+ a.notFound(w, r)
+ return
+ }
+ http.Error(w, "failed to read log", http.StatusInternalServerError)
+ return
+ }
+
+ view := struct {
+ basePage
+ Repo *core.Repo
+ Branches []browse.Branch
+ Branch string
+ Commits []browse.CommitInfo
+ NextHash string
+ }{
+ basePage: a.newBasePage(r, "Log — "+repo.OwnerName+"/"+repo.Name),
+ Repo: repo,
+ Branches: branches,
+ Branch: branch,
+ Commits: commits,
+ NextHash: nextHash,
+ }
+ a.render(w, http.StatusOK, "log.html", view)
+}
+
+// handleCommit renders a single commit's per-table diff summary.
+func (a *app) handleCommit(w http.ResponseWriter, r *http.Request) {
+ repo, _, _, ok := a.loadRepoForBrowse(w, r)
+ if !ok {
+ return
+ }
+ sess, ok := a.openBrowse(w, r, repo)
+ if !ok {
+ return
+ }
+ defer sess.Close()
+
+ hash := chi.URLParam(r, "hash")
+ summary, err := sess.CommitSummary(r.Context(), hash)
+ if err != nil {
+ if errors.Is(err, browse.ErrRefNotFound) {
+ a.notFound(w, r)
+ return
+ }
+ http.Error(w, "failed to read commit", http.StatusInternalServerError)
+ return
+ }
+
+ view := struct {
+ basePage
+ Repo *core.Repo
+ Summary *browse.CommitDiff
+ }{
+ basePage: a.newBasePage(r, "Commit "+shortHash(hash)+" — "+repo.OwnerName+"/"+repo.Name),
+ Repo: repo,
+ Summary: summary,
+ }
+ a.render(w, http.StatusOK, "commit.html", view)
+}
+
+// handleTree renders the tables (with schemas) present at a ref.
+func (a *app) handleTree(w http.ResponseWriter, r *http.Request) {
+ repo, _, _, ok := a.loadRepoForBrowse(w, r)
+ if !ok {
+ return
+ }
+ sess, ok := a.openBrowse(w, r, repo)
+ if !ok {
+ return
+ }
+ defer sess.Close()
+
+ ref := chi.URLParam(r, "ref")
+ tables, err := sess.Tables(r.Context(), ref)
+ if err != nil {
+ if errors.Is(err, browse.ErrRefNotFound) {
+ a.notFound(w, r)
+ return
+ }
+ http.Error(w, "failed to read tables", http.StatusInternalServerError)
+ return
+ }
+
+ view := struct {
+ basePage
+ Repo *core.Repo
+ Ref string
+ Tables []browse.TableInfo
+ }{
+ basePage: a.newBasePage(r, "Tree "+ref+" — "+repo.OwnerName+"/"+repo.Name),
+ Repo: repo,
+ Ref: ref,
+ Tables: tables,
+ }
+ a.render(w, http.StatusOK, "tree.html", view)
+}
+
+// handleTable renders a table's schema and a paginated page of its rows. Pages
+// are selected with ?page=N (1-based).
+func (a *app) handleTable(w http.ResponseWriter, r *http.Request) {
+ repo, _, _, ok := a.loadRepoForBrowse(w, r)
+ if !ok {
+ return
+ }
+ sess, ok := a.openBrowse(w, r, repo)
+ if !ok {
+ return
+ }
+ defer sess.Close()
+
+ ref := chi.URLParam(r, "ref")
+ table := chi.URLParam(r, "table")
+
+ page := 1
+ if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 1 {
+ page = p
+ }
+ offset := (page - 1) * rowsPageSize
+
+ rows, err := sess.Rows(r.Context(), ref, table, offset, rowsPageSize)
+ if err != nil {
+ if errors.Is(err, browse.ErrRefNotFound) || errors.Is(err, browse.ErrTableNotFound) {
+ a.notFound(w, r)
+ return
+ }
+ http.Error(w, "failed to read rows", http.StatusInternalServerError)
+ return
+ }
+
+ totalPages := (rows.Total + rowsPageSize - 1) / rowsPageSize
+ if totalPages < 1 {
+ totalPages = 1
+ }
+
+ view := struct {
+ basePage
+ Repo *core.Repo
+ Ref string
+ Table string
+ Rows *browse.RowPage
+ Page int
+ TotalPages int
+ HasPrev bool
+ HasNext bool
+ }{
+ basePage: a.newBasePage(r, table+" — "+repo.OwnerName+"/"+repo.Name),
+ Repo: repo,
+ Ref: ref,
+ Table: table,
+ Rows: rows,
+ Page: page,
+ TotalPages: totalPages,
+ HasPrev: page > 1,
+ HasNext: page < totalPages,
+ }
+ a.render(w, http.StatusOK, "table.html", view)
+}
diff --git a/web/handlers_keys.go b/web/handlers_keys.go
new file mode 100644
index 0000000000000000000000000000000000000000..7b1e13d79b417ceb326a196fb7cdd4f43654fea8
--- /dev/null
+++ b/web/handlers_keys.go
@@ -0,0 +1,110 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "go.bigb.es/sourcehut-dolt/db"
+)
+
+// keysView is the dolt-key management page model.
+type keysView struct {
+ basePage
+ Keys []*db.DoltKey
+ Error string
+ Notice string
+}
+
+func (a *app) renderKeys(w http.ResponseWriter, r *http.Request, ac *authContext, status int, errMsg, notice string) {
+ keys, err := a.cfg.Repos.ListKeysByUser(r.Context(), ac.UserID)
+ if err != nil {
+ http.Error(w, "failed to list keys", http.StatusInternalServerError)
+ return
+ }
+ view := keysView{
+ basePage: a.newBasePage(r, "Dolt keys — "+serviceName),
+ Keys: keys,
+ Error: errMsg,
+ Notice: notice,
+ }
+ a.render(w, status, "keys.html", view)
+}
+
+// handleKeys renders the dolt-key page: the user's registered keys and the
+// add-key form. The page reads a `#` URL fragment (which
+// `dolt login` appends) into the form via a few lines of inline JS, but works
+// without JS too — the user can paste the key the CLI printed. Login required.
+func (a *app) handleKeys(w http.ResponseWriter, r *http.Request) {
+ ac := a.requireLogin(w, r)
+ if ac == nil {
+ return
+ }
+ a.renderKeys(w, r, ac, http.StatusOK, "", "")
+}
+
+// handleKeysPost adds or deletes a dolt key. A form carrying `delete_id` removes
+// that key; otherwise `pubkey` (the base32 string dolt emits) is decoded,
+// validated and registered. Login and a same-origin POST are required.
+func (a *app) handleKeysPost(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.renderKeys(w, r, ac, http.StatusBadRequest, "Malformed form submission.", "")
+ return
+ }
+
+ if idStr := r.PostFormValue("delete_id"); idStr != "" {
+ a.keysDelete(w, r, ac, idStr)
+ return
+ }
+ a.keysAdd(w, r, ac)
+}
+
+// keysAdd decodes and registers a dolt public key for the caller.
+func (a *app) keysAdd(w http.ResponseWriter, r *http.Request, ac *authContext) {
+ pubStr := strings.TrimSpace(r.PostFormValue("pubkey"))
+ comment := strings.TrimSpace(r.PostFormValue("comment"))
+
+ pubkey, kid, err := decodeDoltPubKey(pubStr)
+ if err != nil {
+ a.renderKeys(w, r, ac, http.StatusBadRequest, "Invalid public key: "+err.Error(), "")
+ return
+ }
+
+ if _, err := a.cfg.Repos.InsertKey(r.Context(), ac.UserID, kid, pubkey, comment); err != nil {
+ if errors.Is(err, db.ErrKeyExists) {
+ a.renderKeys(w, r, ac, http.StatusConflict, "That key is already registered.", "")
+ return
+ }
+ http.Error(w, "failed to register key", http.StatusInternalServerError)
+ return
+ }
+ a.renderKeys(w, r, ac, http.StatusOK, "", "Key added. You can now use `dolt clone`/`push` without --user.")
+}
+
+// keysDelete removes one of the caller's keys, scoped by user id so a user can
+// only delete their own keys.
+func (a *app) keysDelete(w http.ResponseWriter, r *http.Request, ac *authContext, idStr string) {
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ a.renderKeys(w, r, ac, http.StatusBadRequest, "Invalid key id.", "")
+ return
+ }
+ if err := a.cfg.Repos.DeleteKey(r.Context(), id, ac.UserID); err != nil {
+ if errors.Is(err, db.ErrNotFound) {
+ a.renderKeys(w, r, ac, http.StatusNotFound, "No such key.", "")
+ return
+ }
+ http.Error(w, "failed to delete key", http.StatusInternalServerError)
+ return
+ }
+ a.renderKeys(w, r, ac, http.StatusOK, "", "Key deleted.")
+}
diff --git a/web/handlers_repo.go b/web/handlers_repo.go
new file mode 100644
index 0000000000000000000000000000000000000000..d90f9bbf3b16b41b69062745ec65429ebe4e2b67
--- /dev/null
+++ b/web/handlers_repo.go
@@ -0,0 +1,249 @@
+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
+ }
+}
diff --git a/web/handlers_settings.go b/web/handlers_settings.go
new file mode 100644
index 0000000000000000000000000000000000000000..bf814e1a8a8fb9fa4368913acf3d29eb3585aeb7
--- /dev/null
+++ b/web/handlers_settings.go
@@ -0,0 +1,217 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+
+ "go.bigb.es/sourcehut-dolt/core"
+ "go.bigb.es/sourcehut-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
+ }
+}
diff --git a/web/pubkey.go b/web/pubkey.go
new file mode 100644
index 0000000000000000000000000000000000000000..a8b465864e8fd11d14b5305e48baad0c023fc5df
--- /dev/null
+++ b/web/pubkey.go
@@ -0,0 +1,36 @@
+package web
+
+import (
+ "fmt"
+
+ "github.com/dolthub/dolt/go/libraries/doltcore/creds"
+)
+
+// ed25519PubKeyLen is the raw length of an Ed25519 public key. dolt encodes it
+// as 52 base32 characters (creds.B32EncodedPubKeyLen) in its custom alphabet.
+const ed25519PubKeyLen = 32
+
+// decodeDoltPubKey decodes the base32 public-key string that the dolt CLI emits.
+// It is exactly the string `dolt login` appends to the login URL as a fragment
+// (creds.DoltCreds.PubKeyBase32Str: creds.B32CredsEncoding over the raw 32-byte
+// key, custom alphabet "0123456789abcdefghijklmnopqrstuv", no padding). It
+// returns the raw 32-byte key and its derived key id (kid =
+// base32(SHA-512/224(pubkey)) via creds.PubKeyToKIDStr), matching exactly what
+// the Bearer-JWT verifier in authn expects to look up.
+//
+// It validates the decoded length is exactly 32 bytes; a wrong length is a
+// malformed key and is rejected loudly rather than stored.
+func decodeDoltPubKey(s string) (pubkey []byte, kid string, err error) {
+ if s == "" {
+ return nil, "", fmt.Errorf("empty public key")
+ }
+ pubkey, err = creds.B32CredsEncoding.DecodeString(s)
+ if err != nil {
+ return nil, "", fmt.Errorf("invalid base32 public key: %w", err)
+ }
+ if len(pubkey) != ed25519PubKeyLen {
+ return nil, "", fmt.Errorf("public key must be %d bytes, got %d", ed25519PubKeyLen, len(pubkey))
+ }
+ kid = creds.PubKeyToKIDStr(pubkey)
+ return pubkey, kid, nil
+}
diff --git a/web/router.go b/web/router.go
new file mode 100644
index 0000000000000000000000000000000000000000..51b9bc53b4495d81704b7edf8fdac37a2ca92850
--- /dev/null
+++ b/web/router.go
@@ -0,0 +1,134 @@
+package web
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+
+ "go.bigb.es/sourcehut-dolt/core"
+)
+
+// app bundles the parsed templates, the discovered stylesheet href and the
+// injected config. Handlers are methods on *app so they share this state
+// without a global.
+type app struct {
+ cfg Config
+ templates templateSet
+ styleHref string
+}
+
+// Register mounts every dolt.sr.ht web route onto r. The caller (the Phase-3
+// main) installs the config/database/cookie middleware upstream on the router
+// group it passes here, then calls Register with the assembled Config.
+//
+// It parses templates and discovers the stylesheet once, at registration time,
+// so a broken template fails startup loudly rather than a request later. A
+// parse failure returns an error the caller must surface.
+func Register(r chi.Router, cfg Config) error {
+ if cfg.Repos == nil || cfg.Stores == nil || cfg.Browse == nil ||
+ cfg.Users == nil || cfg.RepoDiskPath == nil {
+ return fmt.Errorf("web: Register requires Repos, Stores, Browse, Users and RepoDiskPath")
+ }
+
+ templates, err := loadTemplates()
+ if err != nil {
+ return err
+ }
+
+ a := &app{
+ cfg: cfg,
+ templates: templates,
+ styleHref: discoverStyleHref(cfg.StaticDir),
+ }
+
+ r.Get("/", a.handleIndex)
+ r.Get("/create", a.handleCreateForm)
+ r.Post("/create", a.handleCreate)
+
+ r.Get("/settings/keys", a.handleKeys)
+ r.Post("/settings/keys", a.handleKeysPost)
+
+ r.Get("/~{user}", a.handleUser)
+ r.Get("/~{user}/{db}", a.handleOverview)
+ r.Get("/~{user}/{db}/log", a.handleLog)
+ r.Get("/~{user}/{db}/commit/{hash}", a.handleCommit)
+ r.Get("/~{user}/{db}/tree/{ref}", a.handleTree)
+ r.Get("/~{user}/{db}/table/{ref}/{table}", a.handleTable)
+ r.Get("/~{user}/{db}/settings", a.handleSettings)
+ r.Post("/~{user}/{db}/settings", a.handleSettingsPost)
+
+ r.Handle("/static/*", httpStaticHandler(a.cfg.StaticDir))
+
+ return nil
+}
+
+// --- shared response helpers -------------------------------------------------
+
+// notFound renders the 404 page. Used both for genuinely missing repos and to
+// hide the existence of PRIVATE repos the caller may not browse.
+func (a *app) notFound(w http.ResponseWriter, r *http.Request) {
+ view := struct {
+ basePage
+ }{basePage: a.newBasePage(r, "Not found — "+serviceName)}
+ a.render(w, http.StatusNotFound, "404.html", view)
+}
+
+// forbidden renders the 403 page for a denied but non-hidden request.
+func (a *app) forbidden(w http.ResponseWriter, r *http.Request, msg string) {
+ view := struct {
+ basePage
+ Message string
+ }{basePage: a.newBasePage(r, "Forbidden — "+serviceName), Message: msg}
+ a.render(w, http.StatusForbidden, "403.html", view)
+}
+
+// redirectLogin sends an unauthenticated caller to meta's login, returning them
+// to the current URL afterwards.
+func (a *app) redirectLogin(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, a.newBasePage(r, "").LoginURL, http.StatusSeeOther)
+}
+
+// loadRepoForBrowse loads the repo named by the {user}/{db} URL params and
+// enforces read (OpBrowse) authorization. On any denial it writes the response
+// (404 for hidden PRIVATE repos, 403 otherwise) and returns ok=false. On
+// success it returns the repo, the (possibly nil) caller and the caller's ACL
+// grant for reuse by the handler.
+func (a *app) loadRepoForBrowse(w http.ResponseWriter, r *http.Request) (repo *core.Repo, caller *core.Caller, aclMode *core.AccessMode, ok bool) {
+ owner := chi.URLParam(r, "user")
+ name := chi.URLParam(r, "db")
+
+ _, caller = callerOf(r.Context())
+
+ repo, err := a.cfg.Repos.GetRepoByOwnerAndName(r.Context(), owner, name)
+ if err != nil {
+ // A missing repo is reported as not found regardless of the caller.
+ a.notFound(w, r)
+ return nil, nil, nil, false
+ }
+
+ aclMode = a.effectiveACL(r, caller, repo)
+ if !core.Allowed(caller, repo, aclMode, core.OpBrowse) {
+ if core.NotFoundForPrivate(caller, repo, aclMode) {
+ a.notFound(w, r)
+ } else {
+ a.forbidden(w, r, "You do not have access to this database.")
+ }
+ return nil, nil, nil, false
+ }
+ return repo, caller, aclMode, true
+}
+
+// effectiveACL resolves the caller's ACL grant on repo, or nil for an anonymous
+// caller or a caller with no grant. A lookup error degrades to nil (no grant):
+// access then falls back to visibility, which never over-grants.
+func (a *app) effectiveACL(r *http.Request, caller *core.Caller, repo *core.Repo) *core.AccessMode {
+ if caller == nil {
+ return nil
+ }
+ mode, err := a.cfg.Repos.EffectiveAccess(r.Context(), caller.UserID, repo.ID)
+ if err != nil {
+ return nil
+ }
+ return mode
+}
diff --git a/web/templates.go b/web/templates.go
new file mode 100644
index 0000000000000000000000000000000000000000..8c0780b246529bbf73fe1aabb5f9f31e140450b1
--- /dev/null
+++ b/web/templates.go
@@ -0,0 +1,228 @@
+package web
+
+import (
+ "embed"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "net/http"
+ "net/url"
+ "os"
+ "sort"
+ "strings"
+ "time"
+)
+
+//go:embed templates/*.html templates/icons/*.svg
+var templateFS embed.FS
+
+// pageTemplates lists every content page. Each is parsed together with the
+// shared layout, nav and partials into its own *template.Template, so the
+// per-page {{define "content"}} blocks never collide.
+var pageTemplates = []string{
+ "index.html",
+ "create.html",
+ "user.html",
+ "overview.html",
+ "log.html",
+ "commit.html",
+ "tree.html",
+ "table.html",
+ "settings.html",
+ "keys.html",
+ "404.html",
+ "403.html",
+}
+
+// sharedTemplates are parsed into every page: the outer layout, the nav
+// fragment, and reusable partials (badges, pagination, etc.).
+var sharedTemplates = []string{
+ "templates/layout.html",
+ "templates/nav.html",
+ "templates/partials.html",
+}
+
+// templateSet maps a page name to its fully-parsed template (execute "layout").
+type templateSet map[string]*template.Template
+
+// loadTemplates parses every page template with the shared chrome and the
+// funcmap. It fails loudly (returns an error) on any parse problem so a broken
+// template surfaces at startup, never as a blank page at request time.
+func loadTemplates() (templateSet, error) {
+ icons, err := loadIcons()
+ if err != nil {
+ return nil, err
+ }
+ funcs := templateFuncs(icons)
+
+ set := make(templateSet, len(pageTemplates))
+ for _, page := range pageTemplates {
+ t := template.New("layout").Funcs(funcs)
+ files := append(append([]string{}, sharedTemplates...), "templates/"+page)
+ if _, err := t.ParseFS(templateFS, files...); err != nil {
+ return nil, fmt.Errorf("web: parse template %s: %w", page, err)
+ }
+ set[page] = t
+ }
+ return set, nil
+}
+
+// loadIcons reads every embedded icon SVG into a name→markup map for the icon
+// template func.
+func loadIcons() (map[string]template.HTML, error) {
+ entries, err := fs.ReadDir(templateFS, "templates/icons")
+ if err != nil {
+ return nil, fmt.Errorf("web: read icons dir: %w", err)
+ }
+ icons := make(map[string]template.HTML, len(entries))
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".svg") {
+ continue
+ }
+ data, err := templateFS.ReadFile("templates/icons/" + e.Name())
+ if err != nil {
+ return nil, fmt.Errorf("web: read icon %s: %w", e.Name(), err)
+ }
+ name := strings.TrimSuffix(e.Name(), ".svg")
+ icons[name] = template.HTML(fmt.Sprintf(
+ `%s `, name, data))
+ }
+ return icons, nil
+}
+
+// templateFuncs is the funcmap available in every template.
+func templateFuncs(icons map[string]template.HTML) template.FuncMap {
+ return template.FuncMap{
+ // icon renders a named inline SVG (from templates/icons). An unknown name
+ // yields empty output rather than a hard error, so a missing icon never
+ // crashes a page.
+ "icon": func(name string) template.HTML { return icons[name] },
+ // shorthash abbreviates a dolt/NBS hash to its first 8 characters, the
+ // convention used everywhere commits are listed.
+ "shorthash": shortHash,
+ // reltime renders a humanized relative time ("3 hours ago"), no deps.
+ "reltime": humanizeTime,
+ // abstime renders an absolute UTC timestamp for tooltips/detail.
+ "abstime": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05 UTC") },
+ // humansize renders a byte count as a human-readable size.
+ "humansize": humanizeSize,
+ "upper": strings.ToUpper,
+ // inc/dec support 1-based page arithmetic in pagination links.
+ "inc": func(n int) int { return n + 1 },
+ "dec": func(n int) int { return n - 1 },
+ // doltHost derives the host:port a `dolt login --auth-endpoint` expects
+ // from our origin URL (defaulting to :443 for https).
+ "doltHost": doltHost,
+ }
+}
+
+// doltHost renders the host:port for `dolt login --auth-endpoint` from an origin
+// URL. It appends the default TLS/plain port when the origin omits one.
+func doltHost(origin string) string {
+ u, err := url.Parse(origin)
+ if err != nil || u.Host == "" {
+ return origin
+ }
+ if u.Port() != "" {
+ return u.Host
+ }
+ if u.Scheme == "http" {
+ return u.Host + ":80"
+ }
+ return u.Host + ":443"
+}
+
+// shortHash returns the first 8 characters of h (or h itself if shorter).
+func shortHash(h string) string {
+ if len(h) <= 8 {
+ return h
+ }
+ return h[:8]
+}
+
+// humanizeTime renders t as a coarse relative time in the past. It is a small
+// self-contained helper (no new dependency) covering seconds→years.
+func humanizeTime(t time.Time) string {
+ d := time.Since(t)
+ if d < 0 {
+ return "just now"
+ }
+ switch {
+ case d < time.Minute:
+ return "just now"
+ case d < time.Hour:
+ return plural(int(d/time.Minute), "minute")
+ case d < 24*time.Hour:
+ return plural(int(d/time.Hour), "hour")
+ case d < 30*24*time.Hour:
+ return plural(int(d/(24*time.Hour)), "day")
+ case d < 365*24*time.Hour:
+ return plural(int(d/(30*24*time.Hour)), "month")
+ default:
+ return plural(int(d/(365*24*time.Hour)), "year")
+ }
+}
+
+func plural(n int, unit string) string {
+ if n == 1 {
+ return "1 " + unit + " ago"
+ }
+ return fmt.Sprintf("%d %ss ago", n, unit)
+}
+
+// humanizeSize renders a byte count with binary (1024) units.
+func humanizeSize(n uint64) string {
+ const unit = 1024
+ if n < unit {
+ return fmt.Sprintf("%d B", n)
+ }
+ div, exp := uint64(unit), 0
+ for m := n / unit; m >= unit; m /= unit {
+ div *= unit
+ exp++
+ }
+ return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
+}
+
+// discoverStyleHref returns the stylesheet href for the layout: the hashed
+// production asset if one is present in staticDir (main.min..css, served
+// under /static/), else the dev fallback /static/main.css. Globbing at startup
+// keeps the cache-busting filename out of the templates.
+func discoverStyleHref(staticDir string) string {
+ const fallback = "/static/main.css"
+ if staticDir == "" {
+ return fallback
+ }
+ matches, err := fs.Glob(os.DirFS(staticDir), "main.min.*.css")
+ if err != nil || len(matches) == 0 {
+ return fallback
+ }
+ sort.Strings(matches)
+ return "/static/" + matches[len(matches)-1]
+}
+
+// render executes the named page template with the layout, writing an HTML
+// response with the given status. A template execution error is a programming
+// error (bad template or view struct); it is logged and a 500 is written, but
+// never a partially-flushed page — we render into a buffer first.
+func (a *app) render(w http.ResponseWriter, status int, page string, data any) {
+ t, ok := a.templates[page]
+ if !ok {
+ http.Error(w, "template not found", http.StatusInternalServerError)
+ return
+ }
+ var buf strings.Builder
+ if err := t.ExecuteTemplate(&buf, "layout", data); err != nil {
+ http.Error(w, "template render error: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(buf.String()))
+}
+
+// httpStaticHandler serves files from staticDir under the /static/ prefix. It
+// is mounted by the router; in dev (empty staticDir) it 404s every asset.
+func httpStaticHandler(staticDir string) http.Handler {
+ return http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))
+}
diff --git a/web/templates/403.html b/web/templates/403.html
new file mode 100644
index 0000000000000000000000000000000000000000..1c794a5c76a120d9fd2115d9074744de2f67733f
--- /dev/null
+++ b/web/templates/403.html
@@ -0,0 +1,6 @@
+{{define "content" -}}
+
+403 — Forbidden
+{{if .Message}}{{.Message}}{{else}}You do not have access to this resource.{{end}}
+Return to the dashboard
+{{- end}}
diff --git a/web/templates/404.html b/web/templates/404.html
new file mode 100644
index 0000000000000000000000000000000000000000..8a6fb5495574de2088834712144c6f3ce9831716
--- /dev/null
+++ b/web/templates/404.html
@@ -0,0 +1,6 @@
+{{define "content" -}}
+
+404 — Not found
+The page or database you requested does not exist.
+Return to the dashboard
+{{- end}}
diff --git a/web/templates/commit.html b/web/templates/commit.html
new file mode 100644
index 0000000000000000000000000000000000000000..4ecbfbe3c706734847d525fc784136468f8c5d31
--- /dev/null
+++ b/web/templates/commit.html
@@ -0,0 +1,33 @@
+{{define "content" -}}
+
+{{.Summary.Hash}}
+
+Table changes
+{{if .Summary.Tables}}
+
+
+ Table Change Rows + Rows − Rows ~
+
+
+ {{range .Summary.Tables}}
+
+
+ {{.Name}}
+
+
+ {{if .Added}}added {{end}}
+ {{if .Dropped}}dropped {{end}}
+ {{if .SchemaChanged}}schema {{end}}
+ {{if and (not .Added) (not .Dropped) (not .SchemaChanged)}}data {{end}}
+
+ {{.RowsAdded}}
+ {{.RowsRemoved}}
+ {{.RowsModified}}
+
+ {{end}}
+
+
+{{else}}
+No table changes.
+{{end}}
+{{- end}}
diff --git a/web/templates/create.html b/web/templates/create.html
new file mode 100644
index 0000000000000000000000000000000000000000..421e193872ed9c30acbb6ba8c7980836dbfe34fb
--- /dev/null
+++ b/web/templates/create.html
@@ -0,0 +1,33 @@
+{{define "content" -}}
+Create database
+{{if .Error}}
+{{.Error}}
+{{end}}
+
+{{- end}}
diff --git a/web/templates/icons/caret-right.svg b/web/templates/icons/caret-right.svg
new file mode 100644
index 0000000000000000000000000000000000000000..39a772deda798608215e24a917dde64c70c93c8d
--- /dev/null
+++ b/web/templates/icons/caret-right.svg
@@ -0,0 +1 @@
+
diff --git a/web/templates/icons/circle.svg b/web/templates/icons/circle.svg
new file mode 100644
index 0000000000000000000000000000000000000000..92c0eacc5525ffdeb39b058aaed2bf01f043e3d7
--- /dev/null
+++ b/web/templates/icons/circle.svg
@@ -0,0 +1 @@
+
diff --git a/web/templates/icons/clock.svg b/web/templates/icons/clock.svg
new file mode 100644
index 0000000000000000000000000000000000000000..e4970068ec7aa7c40968d96b06837f9c94768e5a
--- /dev/null
+++ b/web/templates/icons/clock.svg
@@ -0,0 +1 @@
+
diff --git a/web/templates/icons/code-branch.svg b/web/templates/icons/code-branch.svg
new file mode 100644
index 0000000000000000000000000000000000000000..466a1bf6c25dcf36187c59f2d74e8d2dd337eb32
--- /dev/null
+++ b/web/templates/icons/code-branch.svg
@@ -0,0 +1 @@
+
diff --git a/web/templates/icons/exclamation-triangle.svg b/web/templates/icons/exclamation-triangle.svg
new file mode 100644
index 0000000000000000000000000000000000000000..be6ddbf56efcd01960fc517b9c605b61ac29d45d
--- /dev/null
+++ b/web/templates/icons/exclamation-triangle.svg
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/web/templates/icons/folder.svg b/web/templates/icons/folder.svg
new file mode 100644
index 0000000000000000000000000000000000000000..5000d70293ed9cef05d5f6ab59c38b1228a19905
--- /dev/null
+++ b/web/templates/icons/folder.svg
@@ -0,0 +1 @@
+
diff --git a/web/templates/icons/plus-square.svg b/web/templates/icons/plus-square.svg
new file mode 100644
index 0000000000000000000000000000000000000000..5f31c9984409a4cbd6d91a453a7ea0bcdcff101a
--- /dev/null
+++ b/web/templates/icons/plus-square.svg
@@ -0,0 +1 @@
+
diff --git a/web/templates/icons/user.svg b/web/templates/icons/user.svg
new file mode 100644
index 0000000000000000000000000000000000000000..e54b3a0a36b03de47e9b07b4d691b66248801418
--- /dev/null
+++ b/web/templates/icons/user.svg
@@ -0,0 +1 @@
+
diff --git a/web/templates/index.html b/web/templates/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..db1229ce42672ae38917617037d8b4c1cb797e6f
--- /dev/null
+++ b/web/templates/index.html
@@ -0,0 +1,26 @@
+{{define "content" -}}
+{{if .CurrentUser}}
+
+{{template "repoList" .Repos}}
+{{else}}
+
+{{.SiteName}} {{.SiteLabel}}
+
+ dolt.sr.ht hosts Dolt databases the way
+ git.sr.ht hosts git repositories: dolt clone, push and
+ pull over HTTPS, with an integrated web UI for browsing branches,
+ commits and tables.
+
+
+ Log in to create and manage
+ databases.
+
+{{end}}
+{{- end}}
diff --git a/web/templates/keys.html b/web/templates/keys.html
new file mode 100644
index 0000000000000000000000000000000000000000..a1c004d52d8fa90dff5ca7d38482c494f05f9da7
--- /dev/null
+++ b/web/templates/keys.html
@@ -0,0 +1,69 @@
+{{define "content" -}}
+Dolt keys
+{{if .Error}}{{.Error}}
{{end}}
+{{if .Notice}}{{.Notice}}
{{end}}
+
+
+ Associate a dolt Ed25519 credential to clone and push without a personal access
+ token, like a git SSH key. Run:
+
+dolt creds new
+dolt login --auth-endpoint {{doltHost .SelfOrigin}} --login-url {{.SelfOrigin}}/settings/keys
+
+ dolt login opens this page with your public key in the URL; the
+ field below is filled in automatically. Without JavaScript, paste the
+ pub key value the command printed.
+
+
+
+
+
+Your keys
+{{if .Keys}}
+
+ Key ID Comment Added Last used
+
+ {{range .Keys}}
+
+ {{.KID}}
+ {{.Comment}}
+ {{.Created | reltime}}
+ {{if .LastUsed}}{{.LastUsed | reltime}}{{else}}never{{end}}
+
+
+
+
+ {{end}}
+
+
+{{else}}
+No keys registered.
+{{end}}
+
+
+{{- end}}
diff --git a/web/templates/layout.html b/web/templates/layout.html
new file mode 100644
index 0000000000000000000000000000000000000000..087fec416521d08252016b1e6bbaae099daef24a
--- /dev/null
+++ b/web/templates/layout.html
@@ -0,0 +1,25 @@
+{{define "layout" -}}
+
+
+
+
+
+ {{.Title}}
+
+
+
+
+ {{if .ShowEnvBanner}}
+
+ {{.Environment | upper}} ENVIRONMENT
+
+ {{end}}
+
+ {{template "nav" .}}
+
+
+ {{template "content" .}}
+
+
+
+{{- end}}
diff --git a/web/templates/log.html b/web/templates/log.html
new file mode 100644
index 0000000000000000000000000000000000000000..3c059cac258cb8f396fc1ed48818cbdb8d6e9763
--- /dev/null
+++ b/web/templates/log.html
@@ -0,0 +1,44 @@
+{{define "content" -}}
+
+
+
+
+{{if .Commits}}
+
+
+ Commit Message Author Date
+
+
+ {{range .Commits}}
+
+
+
+ {{.Hash | shorthash}}
+
+
+ {{.Message}}
+ {{.Author}}
+ {{.Date | reltime}}
+
+ {{end}}
+
+
+{{else}}
+No commits on this branch.
+{{end}}
+
+{{if .NextHash}}
+
+ Older commits →
+
+{{end}}
+{{- end}}
diff --git a/web/templates/nav.html b/web/templates/nav.html
new file mode 100644
index 0000000000000000000000000000000000000000..f1a2293378a5330e1aaac7ba436c32a39c9218af
--- /dev/null
+++ b/web/templates/nav.html
@@ -0,0 +1,36 @@
+{{define "nav" -}}
+
+ {{icon "circle"}}
+
+ {{.SiteName}}
+ {{.SiteLabel}}
+
+
+
+ {{if .CurrentUser}}
+ {{range .Network}}
+ {{if ne .Site "hub.sr.ht"}}
+
+ {{.Name}}
+
+ {{end}}
+ {{end}}
+ {{end}}
+
+
+{{- end}}
diff --git a/web/templates/overview.html b/web/templates/overview.html
new file mode 100644
index 0000000000000000000000000000000000000000..50798ead4ef8835b9d4367b71ac7db7f678810ce
--- /dev/null
+++ b/web/templates/overview.html
@@ -0,0 +1,68 @@
+{{define "content" -}}
+
+
+ ~{{.Repo.OwnerName}} /{{.Repo.Name}}
+ {{template "visibilityBadge" .Repo.Visibility}}
+
+{{if .Repo.Description}}
+{{.Repo.Description}}
+{{end}}
+
+
+
Clone
+
With a meta.sr.ht personal access token (Basic auth):
+
export DOLT_REMOTE_PASSWORD=<your meta access token>
+dolt clone --user {{.Repo.OwnerName}} {{.CloneURL}}
+
Or, associate a dolt key once and clone with no
+ credentials (like a git SSH key):
+
dolt creds new
+dolt login --auth-endpoint {{doltHost .SelfOrigin}} --login-url {{.SelfOrigin}}/settings/keys
+dolt clone {{.CloneURL}}
+
+
+
+
+
{{icon "code-branch"}} Branches
+ {{if .Branches}}
+
+ {{range .Branches}}
+
+ {{.Name}}
+ {{if eq .Name $.DefaultBranch}}default {{end}}
+ {{.Head | shorthash}}
+
+ {{end}}
+
+ {{else}}
+
No branches.
+ {{end}}
+
+
+
{{icon "clock"}} Recent commits
+ {{if .BrowseError}}
+
Could not read history: {{.BrowseError}}
+ {{end}}
+ {{if .Commits}}
+
+
Full log →
+ {{else}}
+
No commits.
+ {{end}}
+
+
+{{- end}}
diff --git a/web/templates/partials.html b/web/templates/partials.html
new file mode 100644
index 0000000000000000000000000000000000000000..4620caad29d7d1097625ac600516298225a1fe97
--- /dev/null
+++ b/web/templates/partials.html
@@ -0,0 +1,25 @@
+{{define "visibilityBadge" -}}
+{{if eq (printf "%s" .) "PUBLIC" -}}
+public
+{{- else if eq (printf "%s" .) "UNLISTED" -}}
+unlisted
+{{- else -}}
+private
+{{- end}}
+{{- end}}
+
+{{define "repoList" -}}
+{{if .}}
+
+{{else}}
+No databases yet.
+{{end}}
+{{- end}}
diff --git a/web/templates/settings.html b/web/templates/settings.html
new file mode 100644
index 0000000000000000000000000000000000000000..c835f7a73f434d0ea6558c71fd96f8da6bd20266
--- /dev/null
+++ b/web/templates/settings.html
@@ -0,0 +1,74 @@
+{{define "content" -}}
+
+{{if .Error}}{{.Error}}
{{end}}
+{{if .Notice}}{{.Notice}}
{{end}}
+
+General
+
+
+
+Access control
+{{if .ACL}}
+
+ User Mode
+
+ {{range .ACL}}
+
+ ~{{.Username}}
+ {{.Mode}}
+
+
+
+
+ {{end}}
+
+
+{{else}}
+No collaborators.
+{{end}}
+
+
+
+
+Danger zone
+Deleting a database removes its metadata and its on-disk store permanently.
+
+{{- end}}
diff --git a/web/templates/table.html b/web/templates/table.html
new file mode 100644
index 0000000000000000000000000000000000000000..b838cc4201ebdc5c3b72e38927ebe0021ec8b2bc
--- /dev/null
+++ b/web/templates/table.html
@@ -0,0 +1,47 @@
+{{define "content" -}}
+
+
+ {{.Ref}} · {{.Rows.Total}} rows
+
+
+{{if .Rows.Columns}}
+
+
+
+ {{range .Rows.Columns}}{{.}} {{end}}
+
+
+ {{range .Rows.Rows}}
+ {{range .}}{{.}} {{end}}
+ {{end}}
+
+
+
+{{if not .Rows.Rows}}
+No rows on this page.
+{{end}}
+{{else}}
+This table has no columns.
+{{end}}
+
+
+
+
+{{- end}}
diff --git a/web/templates/tree.html b/web/templates/tree.html
new file mode 100644
index 0000000000000000000000000000000000000000..9785088c91faa00e9081b429f0000b4e9e252df9
--- /dev/null
+++ b/web/templates/tree.html
@@ -0,0 +1,31 @@
+{{define "content" -}}
+
+Tables at {{.Ref}}
+
+{{if .Tables}}
+{{range .Tables}}
+
+{{icon "folder"}} {{.Name}} {{.RowCount}} rows
+ rows
+
+
+
+ Column Type Key Null
+
+
+ {{range .Columns}}
+
+ {{.Name}}
+ {{.Type}}
+ {{if .PrimaryKey}}PK{{end}}
+ {{if .Nullable}}yes{{else}}no{{end}}
+
+ {{end}}
+
+
+{{end}}
+{{else}}
+No tables at this ref.
+{{end}}
+{{- end}}
diff --git a/web/templates/user.html b/web/templates/user.html
new file mode 100644
index 0000000000000000000000000000000000000000..b17de4a4ddf9d151911497a7140e116f940dd105
--- /dev/null
+++ b/web/templates/user.html
@@ -0,0 +1,5 @@
+{{define "content" -}}
+{{icon "user"}} ~{{.Owner}}
+Databases owned by ~{{.Owner}}.
+{{template "repoList" .Repos}}
+{{- end}}
diff --git a/web/web_test.go b/web/web_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..9e98357d1ed76e9b9f21cecbb18bb2a5f4aea424
--- /dev/null
+++ b/web/web_test.go
@@ -0,0 +1,636 @@
+package web
+
+import (
+ "context"
+ "crypto/rand"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "git.sr.ht/~sircmpwn/core-go/auth"
+ "github.com/dolthub/dolt/go/libraries/doltcore/creds"
+ "github.com/go-chi/chi/v5"
+ "github.com/vaughan0/go-ini"
+
+ "go.bigb.es/sourcehut-dolt/authn"
+ "go.bigb.es/sourcehut-dolt/browse"
+ "go.bigb.es/sourcehut-dolt/core"
+ "go.bigb.es/sourcehut-dolt/db"
+)
+
+const selfOrigin = "https://dolt.example"
+
+// testConfig synthesizes a config with the origins the chrome/CSRF checks read.
+func testConfig() ini.File {
+ return ini.File{
+ "sr.ht": ini.Section{
+ "environment": "development",
+ "site-name": "sr.ht",
+ "owner-name": "admin",
+ "owner-email": "admin@example.com",
+ },
+ "dolt.sr.ht": ini.Section{"origin": selfOrigin},
+ "meta.sr.ht": ini.Section{"origin": "https://meta.example"},
+ "git.sr.ht": ini.Section{"origin": "https://git.example"},
+ "todo.sr.ht": ini.Section{"origin": "https://todo.example"},
+ "paste.sr.ht": ini.Section{"origin": "https://paste.example"},
+ }
+}
+
+// --- fakes -------------------------------------------------------------------
+
+type fakeStore struct {
+ repos map[string]*core.Repo // key "owner/name"
+ byID map[int]*core.Repo
+ acls map[int]map[int]core.AccessMode // repoID -> userID -> mode
+ keys map[int][]*db.DoltKey // userID -> keys
+ nextID int
+ nextKeyID int
+
+ createErr error
+ createdCalls []*core.Repo
+ deletedRepos []int
+}
+
+func newFakeStore() *fakeStore {
+ return &fakeStore{
+ repos: map[string]*core.Repo{},
+ byID: map[int]*core.Repo{},
+ acls: map[int]map[int]core.AccessMode{},
+ keys: map[int][]*db.DoltKey{},
+ nextID: 1,
+ nextKeyID: 1,
+ }
+}
+
+func (f *fakeStore) add(r *core.Repo) *core.Repo {
+ r.ID = f.nextID
+ f.nextID++
+ f.repos[r.OwnerName+"/"+r.Name] = r
+ f.byID[r.ID] = r
+ return r
+}
+
+func (f *fakeStore) CreateRepo(_ context.Context, r *core.Repo) (*core.Repo, error) {
+ if f.createErr != nil {
+ return nil, f.createErr
+ }
+ if _, ok := f.repos[r.OwnerName+"/"+r.Name]; ok {
+ return nil, db.ErrNameTaken
+ }
+ cp := *r
+ out := f.add(&cp)
+ f.createdCalls = append(f.createdCalls, out)
+ return out, nil
+}
+
+func (f *fakeStore) GetRepoByOwnerAndName(_ context.Context, owner, name string) (*core.Repo, error) {
+ r, ok := f.repos[owner+"/"+name]
+ if !ok {
+ return nil, db.ErrNotFound
+ }
+ return r, nil
+}
+
+func (f *fakeStore) ListReposByOwner(_ context.Context, owner string, viewer *core.Caller) ([]*core.Repo, error) {
+ var out []*core.Repo
+ for _, r := range f.repos {
+ if r.OwnerName != owner {
+ continue
+ }
+ visible := r.Visibility == core.VisibilityPublic
+ if viewer != nil && (viewer.UserID == r.OwnerID || f.hasACL(r.ID, viewer.UserID)) {
+ visible = true
+ }
+ if visible {
+ out = append(out, r)
+ }
+ }
+ return out, nil
+}
+
+func (f *fakeStore) ListReposForDashboard(_ context.Context, userID int) ([]*core.Repo, error) {
+ var out []*core.Repo
+ for _, r := range f.byID {
+ if r.OwnerID == userID || f.hasACL(r.ID, userID) {
+ out = append(out, r)
+ }
+ }
+ return out, nil
+}
+
+func (f *fakeStore) UpdateRepo(_ context.Context, id int, description string, visibility core.Visibility) error {
+ r, ok := f.byID[id]
+ if !ok {
+ return db.ErrNotFound
+ }
+ r.Description = description
+ r.Visibility = visibility
+ return nil
+}
+
+func (f *fakeStore) DeleteRepo(_ context.Context, id int) error {
+ r, ok := f.byID[id]
+ if !ok {
+ return db.ErrNotFound
+ }
+ delete(f.byID, id)
+ delete(f.repos, r.OwnerName+"/"+r.Name)
+ f.deletedRepos = append(f.deletedRepos, id)
+ return nil
+}
+
+func (f *fakeStore) hasACL(repoID, userID int) bool {
+ m, ok := f.acls[repoID]
+ if !ok {
+ return false
+ }
+ _, ok = m[userID]
+ return ok
+}
+
+func (f *fakeStore) EffectiveAccess(_ context.Context, userID, repoID int) (*core.AccessMode, error) {
+ m, ok := f.acls[repoID]
+ if !ok {
+ return nil, nil
+ }
+ mode, ok := m[userID]
+ if !ok {
+ return nil, nil
+ }
+ return &mode, nil
+}
+
+func (f *fakeStore) ListACL(_ context.Context, repoID int) ([]*db.ACLEntry, error) {
+ var out []*db.ACLEntry
+ for uid, mode := range f.acls[repoID] {
+ out = append(out, &db.ACLEntry{RepoID: repoID, UserID: uid, Username: fmt.Sprintf("user%d", uid), Mode: mode})
+ }
+ return out, nil
+}
+
+func (f *fakeStore) UpsertACL(_ context.Context, repoID, userID int, mode core.AccessMode) error {
+ if f.acls[repoID] == nil {
+ f.acls[repoID] = map[int]core.AccessMode{}
+ }
+ f.acls[repoID][userID] = mode
+ return nil
+}
+
+func (f *fakeStore) DeleteACL(_ context.Context, repoID, userID int) error {
+ if !f.hasACL(repoID, userID) {
+ return db.ErrNotFound
+ }
+ delete(f.acls[repoID], userID)
+ return nil
+}
+
+func (f *fakeStore) InsertKey(_ context.Context, userID int, kid string, pubkey []byte, comment string) (*db.DoltKey, error) {
+ for _, ks := range f.keys {
+ for _, k := range ks {
+ if k.KID == kid {
+ return nil, db.ErrKeyExists
+ }
+ }
+ }
+ k := &db.DoltKey{ID: f.nextKeyID, UserID: userID, KID: kid, PubKey: pubkey, Comment: comment, Created: time.Now()}
+ f.nextKeyID++
+ f.keys[userID] = append(f.keys[userID], k)
+ return k, nil
+}
+
+func (f *fakeStore) ListKeysByUser(_ context.Context, userID int) ([]*db.DoltKey, error) {
+ return f.keys[userID], nil
+}
+
+func (f *fakeStore) DeleteKey(_ context.Context, id, userID int) error {
+ ks := f.keys[userID]
+ for i, k := range ks {
+ if k.ID == id {
+ f.keys[userID] = append(ks[:i], ks[i+1:]...)
+ return nil
+ }
+ }
+ return db.ErrNotFound
+}
+
+type fakeStoreManager struct {
+ initErr error
+ initCalls []string
+ deleteCalls []string
+ evictCalls []string
+}
+
+func (m *fakeStoreManager) InitStore(_ context.Context, absPath, _, _ string) error {
+ m.initCalls = append(m.initCalls, absPath)
+ return m.initErr
+}
+func (m *fakeStoreManager) DeleteStore(_ context.Context, _, absPath string) error {
+ m.deleteCalls = append(m.deleteCalls, absPath)
+ return nil
+}
+func (m *fakeStoreManager) Evict(diskPath string) error {
+ m.evictCalls = append(m.evictCalls, diskPath)
+ return nil
+}
+
+type fakeSession struct {
+ branches []browse.Branch
+ commits []browse.CommitInfo
+ tables []browse.TableInfo
+ rows *browse.RowPage
+ summary *browse.CommitDiff
+ closed bool
+}
+
+func (s *fakeSession) Branches(context.Context) ([]browse.Branch, error) { return s.branches, nil }
+func (s *fakeSession) Log(_ context.Context, _, _ string, _ int) ([]browse.CommitInfo, string, error) {
+ return s.commits, "", nil
+}
+func (s *fakeSession) Tables(_ context.Context, _ string) ([]browse.TableInfo, error) {
+ return s.tables, nil
+}
+func (s *fakeSession) Rows(_ context.Context, _, _ string, _, _ int) (*browse.RowPage, error) {
+ return s.rows, nil
+}
+func (s *fakeSession) CommitSummary(_ context.Context, _ string) (*browse.CommitDiff, error) {
+ return s.summary, nil
+}
+func (s *fakeSession) Close() error { s.closed = true; return nil }
+
+type fakeBrowse struct{ sess *fakeSession }
+
+func (b *fakeBrowse) Open(context.Context, string) (BrowseSession, error) {
+ if b.sess == nil {
+ return &fakeSession{}, nil
+ }
+ return b.sess, nil
+}
+
+type fakeUsers struct {
+ byName map[string]*core.Caller
+}
+
+func (u *fakeUsers) LookupUser(_ context.Context, username string) (*core.Caller, error) {
+ c, ok := u.byName[username]
+ if !ok {
+ return nil, errors.New("no such user")
+ }
+ return c, nil
+}
+
+// --- harness -----------------------------------------------------------------
+
+type harness struct {
+ router chi.Router
+ store *fakeStore
+ stores *fakeStoreManager
+ browse *fakeBrowse
+ users *fakeUsers
+}
+
+func newHarness(t *testing.T) *harness {
+ t.Helper()
+ store := newFakeStore()
+ stores := &fakeStoreManager{}
+ fb := &fakeBrowse{}
+ users := &fakeUsers{byName: map[string]*core.Caller{}}
+
+ cfg := Config{
+ Conf: testConfig(),
+ ReposRoot: "/var/lib/dolt",
+ StaticDir: "",
+ Stores: stores,
+ Repos: store,
+ Browse: fb,
+ Users: users,
+ RepoDiskPath: func(owner, name string) string {
+ return "/var/lib/dolt/~" + owner + "/" + name
+ },
+ }
+ r := chi.NewRouter()
+ if err := Register(r, cfg); err != nil {
+ t.Fatalf("Register: %v", err)
+ }
+ return &harness{router: r, store: store, stores: stores, browse: fb, users: users}
+}
+
+// do issues a request through the router, optionally with an authenticated
+// caller injected into the context (as OptionalCookieMiddleware would).
+func (h *harness) do(method, target string, caller *auth.AuthContext, form url.Values) *httptest.ResponseRecorder {
+ var req *http.Request
+ if form != nil {
+ req = httptest.NewRequest(method, target, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", selfOrigin) // same-origin by default
+ } else {
+ req = httptest.NewRequest(method, target, nil)
+ }
+ if caller != nil {
+ req = req.WithContext(authn.WithCaller(req.Context(), caller))
+ }
+ rec := httptest.NewRecorder()
+ h.router.ServeHTTP(rec, req)
+ return rec
+}
+
+func testCaller(id int, name string) *auth.AuthContext {
+ return &auth.AuthContext{UserID: id, Username: name, UserType: auth.USER_TYPE_USER, Email: name + "@example.com"}
+}
+
+// validDoltPubKeyStr returns a 52-char base32 dolt public key (over 32 random
+// bytes) in dolt's custom alphabet, exactly the shape `dolt login` emits.
+func validDoltPubKeyStr(t *testing.T) string {
+ t.Helper()
+ pub := make([]byte, ed25519PubKeyLen)
+ if _, err := rand.Read(pub); err != nil {
+ t.Fatalf("rand: %v", err)
+ }
+ return creds.B32CredsEncoding.EncodeToString(pub)
+}
+
+// --- tests -------------------------------------------------------------------
+
+func TestOverviewAnonymousPublicPrivate(t *testing.T) {
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "pub", OwnerID: 1, OwnerName: "alice", Path: "/p", Visibility: core.VisibilityPublic})
+ h.store.add(&core.Repo{Name: "sec", OwnerID: 1, OwnerName: "alice", Path: "/s", Visibility: core.VisibilityPrivate})
+
+ if rec := h.do("GET", "/~alice/pub", nil, nil); rec.Code != http.StatusOK {
+ t.Fatalf("public overview: got %d, want 200", rec.Code)
+ }
+ rec := h.do("GET", "/~alice/sec", nil, nil)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("private overview anon: got %d, want 404", rec.Code)
+ }
+ if strings.Contains(rec.Body.String(), "sec") && strings.Contains(rec.Body.String(), "clone") {
+ t.Fatalf("private repo leaked details to anonymous")
+ }
+}
+
+func TestPrivateVisibleToOwner(t *testing.T) {
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "sec", OwnerID: 7, OwnerName: "alice", Path: "/s", Visibility: core.VisibilityPrivate})
+ rec := h.do("GET", "/~alice/sec", testCaller(7, "alice"), nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("owner private overview: got %d, want 200", rec.Code)
+ }
+}
+
+func TestDashboardLists(t *testing.T) {
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "mine", OwnerID: 3, OwnerName: "bob", Path: "/m", Visibility: core.VisibilityPrivate})
+
+ rec := h.do("GET", "/", testCaller(3, "bob"), nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("dashboard: got %d", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "~bob/mine") {
+ t.Fatalf("dashboard missing owned repo; body=%s", rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "/create") {
+ t.Fatalf("dashboard missing create link")
+ }
+
+ // Anonymous dashboard shows the blurb, not the list.
+ anon := h.do("GET", "/", nil, nil)
+ if !strings.Contains(anon.Body.String(), "Log in") {
+ t.Fatalf("anon dashboard missing login blurb")
+ }
+}
+
+func TestCreateValidationAndSuccess(t *testing.T) {
+ h := newHarness(t)
+ caller := testCaller(5, "carol")
+
+ // Anonymous create is redirected to login.
+ if rec := h.do("GET", "/create", nil, nil); rec.Code != http.StatusSeeOther {
+ t.Fatalf("anon create form: got %d, want 303", rec.Code)
+ }
+
+ // Invalid name.
+ bad := h.do("POST", "/create", caller, url.Values{"name": {"bad name!"}, "visibility": {"PUBLIC"}})
+ if bad.Code != http.StatusBadRequest {
+ t.Fatalf("invalid name: got %d, want 400", bad.Code)
+ }
+
+ // Success.
+ ok := h.do("POST", "/create", caller, url.Values{"name": {"gooddb"}, "visibility": {"PUBLIC"}, "description": {"hi"}})
+ if ok.Code != http.StatusSeeOther {
+ t.Fatalf("create success: got %d, want 303; body=%s", ok.Code, ok.Body.String())
+ }
+ if got := ok.Header().Get("Location"); got != "/~carol/gooddb" {
+ t.Fatalf("create redirect: got %q", got)
+ }
+ if len(h.stores.initCalls) != 1 || h.stores.initCalls[0] != "/var/lib/dolt/~carol/gooddb" {
+ t.Fatalf("InitStore not called correctly: %v", h.stores.initCalls)
+ }
+ if _, err := h.store.GetRepoByOwnerAndName(context.Background(), "carol", "gooddb"); err != nil {
+ t.Fatalf("repo row not created: %v", err)
+ }
+}
+
+func TestCreateStoreFailureRollsBackRow(t *testing.T) {
+ h := newHarness(t)
+ h.stores.initErr = errors.New("disk full")
+ caller := testCaller(5, "carol")
+
+ rec := h.do("POST", "/create", caller, url.Values{"name": {"gooddb"}, "visibility": {"PUBLIC"}})
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("create with store failure: got %d, want 500", rec.Code)
+ }
+ if _, err := h.store.GetRepoByOwnerAndName(context.Background(), "carol", "gooddb"); !errors.Is(err, db.ErrNotFound) {
+ t.Fatalf("orphan repo row survived store failure: %v", err)
+ }
+ if len(h.store.deletedRepos) != 1 {
+ t.Fatalf("row not rolled back: %v", h.store.deletedRepos)
+ }
+}
+
+func TestCreateCSRFRejected(t *testing.T) {
+ h := newHarness(t)
+ caller := testCaller(5, "carol")
+ req := httptest.NewRequest("POST", "/create", strings.NewReader(url.Values{"name": {"x"}, "visibility": {"PUBLIC"}}.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", "https://evil.example")
+ req = req.WithContext(authn.WithCaller(req.Context(), caller))
+ rec := httptest.NewRecorder()
+ h.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("cross-origin create: got %d, want 403", rec.Code)
+ }
+}
+
+func TestSettingsOwnerGate(t *testing.T) {
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 10, OwnerName: "owner", Path: "/d", Visibility: core.VisibilityPublic})
+
+ // Anonymous → login redirect.
+ if rec := h.do("GET", "/~owner/db/settings", nil, nil); rec.Code != http.StatusSeeOther {
+ t.Fatalf("anon settings: got %d, want 303", rec.Code)
+ }
+ // Non-owner on a PUBLIC repo → 403.
+ if rec := h.do("GET", "/~owner/db/settings", testCaller(99, "intruder"), nil); rec.Code != http.StatusForbidden {
+ t.Fatalf("non-owner settings: got %d, want 403", rec.Code)
+ }
+ // Owner → 200.
+ if rec := h.do("GET", "/~owner/db/settings", testCaller(10, "owner"), nil); rec.Code != http.StatusOK {
+ t.Fatalf("owner settings: got %d, want 200", rec.Code)
+ }
+}
+
+func TestSettingsNonOwnerPrivateIs404(t *testing.T) {
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 10, OwnerName: "owner", Path: "/d", Visibility: core.VisibilityPrivate})
+ rec := h.do("GET", "/~owner/db/settings", testCaller(99, "intruder"), nil)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("non-owner private settings: got %d, want 404", rec.Code)
+ }
+}
+
+func TestSettingsUpdateAndDelete(t *testing.T) {
+ h := newHarness(t)
+ repo := h.store.add(&core.Repo{Name: "db", OwnerID: 10, OwnerName: "owner", Path: "/var/lib/dolt/~owner/db", Visibility: core.VisibilityPublic})
+ owner := testCaller(10, "owner")
+
+ upd := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"update"}, "description": {"new desc"}, "visibility": {"PRIVATE"}})
+ if upd.Code != http.StatusOK {
+ t.Fatalf("update: got %d", upd.Code)
+ }
+ if repo.Description != "new desc" || repo.Visibility != core.VisibilityPrivate {
+ t.Fatalf("update not applied: %+v", repo)
+ }
+
+ // Delete requires a matching name confirmation.
+ badDel := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"delete"}, "confirm_name": {"wrong"}})
+ if badDel.Code != http.StatusBadRequest {
+ t.Fatalf("delete wrong confirm: got %d, want 400", badDel.Code)
+ }
+ del := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"delete"}, "confirm_name": {"db"}})
+ if del.Code != http.StatusSeeOther {
+ t.Fatalf("delete: got %d, want 303", del.Code)
+ }
+ if len(h.stores.deleteCalls) != 1 || len(h.stores.evictCalls) != 1 {
+ t.Fatalf("store delete/evict not called: del=%v evict=%v", h.stores.deleteCalls, h.stores.evictCalls)
+ }
+}
+
+func TestSettingsACLAddRemove(t *testing.T) {
+ h := newHarness(t)
+ repo := h.store.add(&core.Repo{Name: "db", OwnerID: 10, OwnerName: "owner", Path: "/d", Visibility: core.VisibilityPublic})
+ h.users.byName["dave"] = &core.Caller{UserID: 42, Username: "dave"}
+ owner := testCaller(10, "owner")
+
+ add := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"acl_add"}, "username": {"dave"}, "mode": {"RW"}})
+ if add.Code != http.StatusOK {
+ t.Fatalf("acl add: got %d; body=%s", add.Code, add.Body.String())
+ }
+ if !h.store.hasACL(repo.ID, 42) {
+ t.Fatalf("acl not added")
+ }
+ // Unknown user rejected.
+ if bad := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"acl_add"}, "username": {"ghost"}, "mode": {"RO"}}); bad.Code != http.StatusBadRequest {
+ t.Fatalf("acl add unknown user: got %d, want 400", bad.Code)
+ }
+
+ rm := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"acl_remove"}, "user_id": {"42"}})
+ if rm.Code != http.StatusOK {
+ t.Fatalf("acl remove: got %d", rm.Code)
+ }
+ if h.store.hasACL(repo.ID, 42) {
+ t.Fatalf("acl not removed")
+ }
+}
+
+func TestKeysAddDeleteAndFragmentPage(t *testing.T) {
+ h := newHarness(t)
+ caller := testCaller(11, "keyuser")
+
+ // The page renders and contains the hash-fragment JS.
+ page := h.do("GET", "/settings/keys", caller, nil)
+ if page.Code != http.StatusOK {
+ t.Fatalf("keys page: got %d", page.Code)
+ }
+ if !strings.Contains(page.Body.String(), "window.location.hash") {
+ t.Fatalf("keys page missing hash-fragment JS")
+ }
+
+ // Add a key using a valid dolt base32 public key.
+ pub := validDoltPubKeyStr(t)
+ add := h.do("POST", "/settings/keys", caller, url.Values{"pubkey": {pub}, "comment": {"laptop"}})
+ if add.Code != http.StatusOK {
+ t.Fatalf("key add: got %d; body=%s", add.Code, add.Body.String())
+ }
+ keys, _ := h.store.ListKeysByUser(context.Background(), 11)
+ if len(keys) != 1 {
+ t.Fatalf("key not stored: %d", len(keys))
+ }
+
+ // Invalid key rejected.
+ if bad := h.do("POST", "/settings/keys", caller, url.Values{"pubkey": {"not-base32-!!"}}); bad.Code != http.StatusBadRequest {
+ t.Fatalf("invalid key: got %d, want 400", bad.Code)
+ }
+
+ // Delete.
+ del := h.do("POST", "/settings/keys", caller, url.Values{"delete_id": {fmt.Sprint(keys[0].ID)}})
+ if del.Code != http.StatusOK {
+ t.Fatalf("key delete: got %d", del.Code)
+ }
+ if ks, _ := h.store.ListKeysByUser(context.Background(), 11); len(ks) != 0 {
+ t.Fatalf("key not deleted")
+ }
+}
+
+func TestNavRendersNetworkAndActive(t *testing.T) {
+ h := newHarness(t)
+ rec := h.do("GET", "/", testCaller(1, "someone"), nil)
+ body := rec.Body.String()
+ // git.sr.ht and todo.sr.ht are network entries; paste is excluded.
+ if !strings.Contains(body, "https://git.example") || !strings.Contains(body, "https://todo.example") {
+ t.Fatalf("nav missing network entries; body=%s", body)
+ }
+ if strings.Contains(body, "https://paste.example") {
+ t.Fatalf("nav included excluded paste.sr.ht")
+ }
+ // Our own service is active.
+ if !strings.Contains(body, `nav-item active`) {
+ t.Fatalf("nav missing active class for self")
+ }
+}
+
+func TestLogAndTablePages(t *testing.T) {
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ h.browse.sess = &fakeSession{
+ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
+ commits: []browse.CommitInfo{
+ {Hash: "abcdef1234567890", Author: "alice", Message: "init", Date: time.Now().Add(-2 * time.Hour)},
+ },
+ rows: &browse.RowPage{Columns: []string{"id", "name"}, Rows: [][]string{{"1", "x "}}, Total: 1},
+ }
+
+ logRec := h.do("GET", "/~alice/db/log", nil, nil)
+ if logRec.Code != http.StatusOK {
+ t.Fatalf("log page: got %d", logRec.Code)
+ }
+ if !strings.Contains(logRec.Body.String(), "abcdef12") || !strings.Contains(logRec.Body.String(), "hours ago") {
+ t.Fatalf("log page missing short hash / reltime; body=%s", logRec.Body.String())
+ }
+
+ tblRec := h.do("GET", "/~alice/db/table/main/things", nil, nil)
+ if tblRec.Code != http.StatusOK {
+ t.Fatalf("table page: got %d", tblRec.Code)
+ }
+ // html/template must escape the cell content.
+ if strings.Contains(tblRec.Body.String(), "x ") {
+ t.Fatalf("table cell not HTML-escaped")
+ }
+ if !strings.Contains(tblRec.Body.String(), "<b>x</b>") {
+ t.Fatalf("table cell escaping wrong; body=%s", tblRec.Body.String())
+ }
+}