~bigbes/sourcehut-dolt

944a35e9ed6b9baa0a59adb06df09085bc62d508 — Eugene Blikh 30 days ago 2b781a2
web: router, handlers, and sourcehut chrome

Chi route table per plan, SourceHut chrome port (nav/layout from
core.sr.ht Bootstrap structure), dual-flow clone box, dolt-key
association page with hash-fragment prefill, same-origin CSRF checks.
Handlers depend on small injected interfaces (StoreManager, RepoStore,
BrowseOpener, UserResolver) with production adapters included.
A web/adapters.go => web/adapters.go +107 -0
@@ 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
}

A web/chrome.go => web/chrome.go +150 -0
@@ 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
// <title>; 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)
}

A web/csrf.go => web/csrf.go +48 -0
@@ 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
}

A web/deps.go => web/deps.go +140 -0
@@ 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 <ReposRoot>/~<owner>/<name>. Passed to StoreManager.DeleteStore
	// as the containment root.
	ReposRoot string
	// StaticDir is the directory holding built static assets (the hashed
	// main.min.<sha>.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)
}

A web/handlers_browse.go => web/handlers_browse.go +217 -0
@@ 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=<hash> (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)
}

A web/handlers_keys.go => web/handlers_keys.go +110 -0
@@ 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 `#<pubkey-base32>` 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.")
}

A web/handlers_repo.go => web/handlers_repo.go +249 -0
@@ 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
	}
}

A web/handlers_settings.go => web/handlers_settings.go +217 -0
@@ 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
	}
}

A web/pubkey.go => web/pubkey.go +36 -0
@@ 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
}

A web/router.go => web/router.go +134 -0
@@ 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
}

A web/templates.go => web/templates.go +228 -0
@@ 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(
			`<span class="icon icon-%s" aria-hidden="true">%s</span>`, 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.<sha>.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)))
}

A web/templates/403.html => web/templates/403.html +6 -0
@@ 0,0 1,6 @@
{{define "content" -}}
<div class="header-extension"></div>
<h2>403 &mdash; Forbidden</h2>
<p>{{if .Message}}{{.Message}}{{else}}You do not have access to this resource.{{end}}</p>
<p><a href="/">Return to the dashboard</a></p>
{{- end}}

A web/templates/404.html => web/templates/404.html +6 -0
@@ 0,0 1,6 @@
{{define "content" -}}
<div class="header-extension"></div>
<h2>404 &mdash; Not found</h2>
<p>The page or database you requested does not exist.</p>
<p><a href="/">Return to the dashboard</a></p>
{{- end}}

A web/templates/commit.html => web/templates/commit.html +33 -0
@@ 0,0 1,33 @@
{{define "content" -}}
<h2><a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}">~{{.Repo.OwnerName}}/{{.Repo.Name}}</a> &middot; commit</h2>
<p><code>{{.Summary.Hash}}</code></p>

<h4>Table changes</h4>
{{if .Summary.Tables}}
<table class="table">
  <thead>
    <tr><th>Table</th><th>Change</th><th>Rows +</th><th>Rows &minus;</th><th>Rows ~</th></tr>
  </thead>
  <tbody>
    {{range .Summary.Tables}}
    <tr>
      <td>
        <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/table/{{$.Summary.Hash}}/{{.Name}}">{{.Name}}</a>
      </td>
      <td>
        {{if .Added}}<span class="badge badge-success">added</span>{{end}}
        {{if .Dropped}}<span class="badge badge-danger">dropped</span>{{end}}
        {{if .SchemaChanged}}<span class="badge badge-warning">schema</span>{{end}}
        {{if and (not .Added) (not .Dropped) (not .SchemaChanged)}}<span class="badge badge-info">data</span>{{end}}
      </td>
      <td>{{.RowsAdded}}</td>
      <td>{{.RowsRemoved}}</td>
      <td>{{.RowsModified}}</td>
    </tr>
    {{end}}
  </tbody>
</table>
{{else}}
<p class="text-muted">No table changes.</p>
{{end}}
{{- end}}

A web/templates/create.html => web/templates/create.html +33 -0
@@ 0,0 1,33 @@
{{define "content" -}}
<h2>Create database</h2>
{{if .Error}}
<div class="alert alert-danger">{{.Error}}</div>
{{end}}
<form method="POST" action="/create">
  <div class="form-group">
    <label for="name">Name</label>
    <input type="text" class="form-control" name="name" id="name"
      value="{{.Form.Name}}" required
      pattern="[a-zA-Z0-9](?:[a-zA-Z0-9_-]*[a-zA-Z0-9])?" maxlength="64"
      autofocus>
    <small class="form-text text-muted">
      Letters, digits, hyphen and underscore; must start and end with a letter or
      digit. This doubles as the SQL database name.
    </small>
  </div>
  <div class="form-group">
    <label for="description">Description</label>
    <input type="text" class="form-control" name="description" id="description"
      value="{{.Form.Description}}" maxlength="1024">
  </div>
  <div class="form-group">
    <label for="visibility">Visibility</label>
    <select class="form-control" name="visibility" id="visibility">
      <option value="PUBLIC" {{if eq .Form.Visibility "PUBLIC"}}selected{{end}}>Public — anyone can browse and clone</option>
      <option value="UNLISTED" {{if eq .Form.Visibility "UNLISTED"}}selected{{end}}>Unlisted — anyone with the link can browse and clone</option>
      <option value="PRIVATE" {{if eq .Form.Visibility "PRIVATE"}}selected{{end}}>Private — only you and collaborators</option>
    </select>
  </div>
  <button type="submit" class="btn btn-primary">Create</button>
</form>
{{- end}}

A web/templates/icons/caret-right.svg => web/templates/icons/caret-right.svg +1 -0
@@ 0,0 1,1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 512"><path d="M0 384.662V127.338c0-17.818 21.543-26.741 34.142-14.142l128.662 128.662c7.81 7.81 7.81 20.474 0 28.284L34.142 398.804C21.543 411.404 0 402.48 0 384.662z"/></svg>

A web/templates/icons/circle.svg => web/templates/icons/circle.svg +1 -0
@@ 0,0 1,1 @@
<svg width="22" height="22" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200z"/></svg>

A web/templates/icons/clock.svg => web/templates/icons/clock.svg +1 -0
@@ 0,0 1,1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200zm61.8-104.4l-84.9-61.7c-3.1-2.3-4.9-5.9-4.9-9.7V116c0-6.6 5.4-12 12-12h32c6.6 0 12 5.4 12 12v141.7l66.8 48.6c5.4 3.9 6.5 11.4 2.6 16.8L334.6 349c-3.9 5.3-11.4 6.5-16.8 2.6z"/></svg>

A web/templates/icons/code-branch.svg => web/templates/icons/code-branch.svg +1 -0
@@ 0,0 1,1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M384 144c0-44.2-35.8-80-80-80s-80 35.8-80 80c0 36.4 24.3 67.1 57.5 76.8-.6 16.1-4.2 28.5-11 36.9-15.4 19.2-49.3 22.4-85.2 25.7-28.2 2.6-57.4 5.4-81.3 16.9v-144c32.5-10.2 56-40.5 56-76.3 0-44.2-35.8-80-80-80S0 35.8 0 80c0 35.8 23.5 66.1 56 76.3v199.3C23.5 365.9 0 396.2 0 432c0 44.2 35.8 80 80 80s80-35.8 80-80c0-34-21.2-63.1-51.2-74.6 3.1-5.2 7.8-9.8 14.9-13.4 16.2-8.2 40.4-10.4 66.1-12.8 42.2-3.9 90-8.4 118.2-43.4 14-17.4 21.1-39.8 21.6-67.9 31.6-10.8 54.4-40.7 54.4-75.9zM80 64c8.8 0 16 7.2 16 16s-7.2 16-16 16-16-7.2-16-16 7.2-16 16-16zm0 384c-8.8 0-16-7.2-16-16s7.2-16 16-16 16 7.2 16 16-7.2 16-16 16zm224-320c8.8 0 16 7.2 16 16s-7.2 16-16 16-16-7.2-16-16 7.2-16 16-16z"/></svg>

A web/templates/icons/exclamation-triangle.svg => web/templates/icons/exclamation-triangle.svg +5 -0
@@ 0,0 1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"/></svg>
<!--
Font Awesome Free 5.3.1 by @fontawesome - https://fontawesome.com
License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
-->
\ No newline at end of file

A web/templates/icons/folder.svg => web/templates/icons/folder.svg +1 -0
@@ 0,0 1,1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M464 128H272l-64-64H48C21.49 64 0 85.49 0 112v288c0 26.51 21.49 48 48 48h416c26.51 0 48-21.49 48-48V176c0-26.51-21.49-48-48-48z"/></svg>

A web/templates/icons/plus-square.svg => web/templates/icons/plus-square.svg +1 -0
@@ 0,0 1,1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M352 240v32c0 6.6-5.4 12-12 12h-88v88c0 6.6-5.4 12-12 12h-32c-6.6 0-12-5.4-12-12v-88h-88c-6.6 0-12-5.4-12-12v-32c0-6.6 5.4-12 12-12h88v-88c0-6.6 5.4-12 12-12h32c6.6 0 12 5.4 12 12v88h88c6.6 0 12 5.4 12 12zm96-160v352c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V80c0-26.5 21.5-48 48-48h352c26.5 0 48 21.5 48 48zm-48 346V86c0-3.3-2.7-6-6-6H54c-3.3 0-6 2.7-6 6v340c0 3.3 2.7 6 6 6h340c3.3 0 6-2.7 6-6z"/></svg>

A web/templates/icons/user.svg => web/templates/icons/user.svg +1 -0
@@ 0,0 1,1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M224 256c70.7 0 128-57.3 128-128S294.7 0 224 0 96 57.3 96 128s57.3 128 128 128zm89.6 32h-16.7c-22.2 10.2-46.9 16-72.9 16s-50.6-5.8-72.9-16h-16.7C60.2 288 0 348.2 0 422.4V464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48v-41.6c0-74.2-60.2-134.4-134.4-134.4z"/></svg>

A web/templates/index.html => web/templates/index.html +26 -0
@@ 0,0 1,26 @@
{{define "content" -}}
{{if .CurrentUser}}
<div class="row">
  <div class="col-md-8">
    <h2>Your databases</h2>
  </div>
  <div class="col-md-4 text-right">
    <a href="/create" class="btn btn-primary">{{icon "plus-square"}} Create database</a>
  </div>
</div>
{{template "repoList" .Repos}}
{{else}}
<div class="header-extension"></div>
<h2>{{.SiteName}} <span class="text-danger">{{.SiteLabel}}</span></h2>
<p>
  dolt.sr.ht hosts <a href="https://www.dolthub.com/">Dolt</a> databases the way
  git.sr.ht hosts git repositories: <code>dolt clone</code>, <code>push</code> and
  <code>pull</code> over HTTPS, with an integrated web UI for browsing branches,
  commits and tables.
</p>
<p>
  <a href="{{.LoginURL}}" rel="nofollow">Log in</a> to create and manage
  databases.
</p>
{{end}}
{{- end}}

A web/templates/keys.html => web/templates/keys.html +69 -0
@@ 0,0 1,69 @@
{{define "content" -}}
<h2>Dolt keys</h2>
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
{{if .Notice}}<div class="alert alert-success">{{.Notice}}</div>{{end}}

<p>
  Associate a dolt Ed25519 credential to clone and push without a personal access
  token, like a git SSH key. Run:
</p>
<pre>dolt creds new
dolt login --auth-endpoint {{doltHost .SelfOrigin}} --login-url {{.SelfOrigin}}/settings/keys</pre>
<p>
  <code>dolt login</code> opens this page with your public key in the URL; the
  field below is filled in automatically. Without JavaScript, paste the
  <code>pub key</code> value the command printed.
</p>

<form method="POST" action="/settings/keys">
  <div class="form-group">
    <label for="pubkey">Public key</label>
    <input type="text" class="form-control" name="pubkey" id="pubkey"
      autocomplete="off" spellcheck="false">
  </div>
  <div class="form-group">
    <label for="comment">Comment (optional)</label>
    <input type="text" class="form-control" name="comment" id="comment" maxlength="256">
  </div>
  <button type="submit" class="btn btn-primary">Add key</button>
</form>

<div class="header-extension"></div>
<h4>Your keys</h4>
{{if .Keys}}
<table class="table">
  <thead><tr><th>Key ID</th><th>Comment</th><th>Added</th><th>Last used</th><th></th></tr></thead>
  <tbody>
    {{range .Keys}}
    <tr>
      <td><code>{{.KID}}</code></td>
      <td>{{.Comment}}</td>
      <td class="text-muted" title="{{.Created | abstime}}">{{.Created | reltime}}</td>
      <td class="text-muted">{{if .LastUsed}}{{.LastUsed | reltime}}{{else}}never{{end}}</td>
      <td>
        <form method="POST" action="/settings/keys" style="display:inline">
          <input type="hidden" name="delete_id" value="{{.ID}}">
          <button type="submit" class="btn btn-sm btn-danger">Delete</button>
        </form>
      </td>
    </tr>
    {{end}}
  </tbody>
</table>
{{else}}
<p class="text-muted">No keys registered.</p>
{{end}}

<script>
  // Prefill the public key from the URL fragment that `dolt login` appends
  // (#<pubkey-base32>). Progressive enhancement only: the page works without JS.
  (function () {
    if (window.location.hash.length > 1) {
      var field = document.getElementById("pubkey");
      if (field && !field.value) {
        field.value = decodeURIComponent(window.location.hash.substring(1));
      }
    }
  })();
</script>
{{- end}}

A web/templates/layout.html => web/templates/layout.html +25 -0
@@ 0,0 1,25 @@
{{define "layout" -}}
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{.Title}}</title>
    <link rel="icon" type="image/svg+xml" href="/static/logo.svg" />
    <link rel="stylesheet" href="{{.StyleHref}}">
  </head>
  <body>
    {{if .ShowEnvBanner}}
    <div style="background: #228800; color: white; font-weight: bold; width: 100%; text-align: center">
      {{.Environment | upper}} ENVIRONMENT
    </div>
    {{end}}
    <nav class="container navbar navbar-light navbar-expand-sm">
      {{template "nav" .}}
    </nav>
    <div class="container">
      {{template "content" .}}
    </div>
  </body>
</html>
{{- end}}

A web/templates/log.html => web/templates/log.html +44 -0
@@ 0,0 1,44 @@
{{define "content" -}}
<h2><a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}">~{{.Repo.OwnerName}}/{{.Repo.Name}}</a> &middot; log</h2>

<form method="GET" class="form-inline">
  <label for="branch" class="mr-2">Branch</label>
  <select class="form-control mr-2" name="branch" id="branch" onchange="this.form.submit()">
    {{range .Branches}}
    <option value="{{.Name}}" {{if eq .Name $.Branch}}selected{{end}}>{{.Name}}</option>
    {{end}}
  </select>
  <noscript><button type="submit" class="btn btn-secondary">Go</button></noscript>
</form>

{{if .Commits}}
<table class="table">
  <thead>
    <tr><th>Commit</th><th>Message</th><th>Author</th><th>Date</th></tr>
  </thead>
  <tbody>
    {{range .Commits}}
    <tr>
      <td>
        <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.Hash}}">
          <code>{{.Hash | shorthash}}</code>
        </a>
      </td>
      <td>{{.Message}}</td>
      <td class="text-muted">{{.Author}}</td>
      <td class="text-muted" title="{{.Date | abstime}}">{{.Date | reltime}}</td>
    </tr>
    {{end}}
  </tbody>
</table>
{{else}}
<p class="text-muted">No commits on this branch.</p>
{{end}}

{{if .NextHash}}
<a class="btn btn-secondary"
   href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/log?branch={{.Branch | urlquery}}&amp;from={{.NextHash | urlquery}}">
  Older commits &rarr;
</a>
{{end}}
{{- end}}

A web/templates/nav.html => web/templates/nav.html +36 -0
@@ 0,0 1,36 @@
{{define "nav" -}}
<span class="navbar-brand">
  {{icon "circle"}}
  <a class="navbar-brand" href="/">
    {{.SiteName}}
    <span class="text-danger">{{.SiteLabel}}</span>
  </a>
</span>
<ul class="navbar-nav">
  {{if .CurrentUser}}
  {{range .Network}}
  {{if ne .Site "hub.sr.ht"}}
  <li class="nav-item {{if .Active}}active{{end}}">
    <a class="nav-link" href="{{.Origin}}">{{.Name}}</a>
  </li>
  {{end}}
  {{end}}
  {{end}}
</ul>
<div class="login">
  {{if .CurrentUser}}
  <span class="navbar-text">
    Logged in as
    <a href="{{.MetaOrigin}}/profile">{{.CurrentUser.Username}}</a>
    &mdash;
    <a href="{{.LogoutURL}}">Log out</a>
  </span>
  {{else}}
  <span class="navbar-text">
    <a href="{{.LoginURL}}" rel="nofollow">Log in</a>
    &mdash;
    <a href="{{.MetaOrigin}}">Register</a>
  </span>
  {{end}}
</div>
{{- end}}

A web/templates/overview.html => web/templates/overview.html +68 -0
@@ 0,0 1,68 @@
{{define "content" -}}
<div class="header-extension"></div>
<h2>
  <a href="/~{{.Repo.OwnerName}}">~{{.Repo.OwnerName}}</a>/{{.Repo.Name}}
  {{template "visibilityBadge" .Repo.Visibility}}
</h2>
{{if .Repo.Description}}
<p>{{.Repo.Description}}</p>
{{end}}

<div class="clone-url">
  <h4>Clone</h4>
  <p>With a meta.sr.ht personal access token (Basic auth):</p>
  <pre>export DOLT_REMOTE_PASSWORD=&lt;your meta access token&gt;
dolt clone --user {{.Repo.OwnerName}} {{.CloneURL}}</pre>
  <p>Or, associate a <a href="/settings/keys">dolt key</a> once and clone with no
  credentials (like a git SSH key):</p>
  <pre>dolt creds new
dolt login --auth-endpoint {{doltHost .SelfOrigin}} --login-url {{.SelfOrigin}}/settings/keys
dolt clone {{.CloneURL}}</pre>
</div>

<div class="row">
  <div class="col-md-4">
    <h4>{{icon "code-branch"}} Branches</h4>
    {{if .Branches}}
    <ul>
      {{range .Branches}}
      <li>
        <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{.Name}}">{{.Name}}</a>
        {{if eq .Name $.DefaultBranch}}<span class="badge badge-secondary">default</span>{{end}}
        <code class="text-muted">{{.Head | shorthash}}</code>
      </li>
      {{end}}
    </ul>
    {{else}}
    <p class="text-muted">No branches.</p>
    {{end}}
  </div>
  <div class="col-md-8">
    <h4>{{icon "clock"}} Recent commits</h4>
    {{if .BrowseError}}
    <div class="alert alert-warning">Could not read history: {{.BrowseError}}</div>
    {{end}}
    {{if .Commits}}
    <table class="table">
      <tbody>
        {{range .Commits}}
        <tr>
          <td>
            <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.Hash}}">
              <code>{{.Hash | shorthash}}</code>
            </a>
          </td>
          <td>{{.Message}}</td>
          <td class="text-muted">{{.Author}}</td>
          <td class="text-muted" title="{{.Date | abstime}}">{{.Date | reltime}}</td>
        </tr>
        {{end}}
      </tbody>
    </table>
    <a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/log">Full log &rarr;</a>
    {{else}}
    <p class="text-muted">No commits.</p>
    {{end}}
  </div>
</div>
{{- end}}

A web/templates/partials.html => web/templates/partials.html +25 -0
@@ 0,0 1,25 @@
{{define "visibilityBadge" -}}
{{if eq (printf "%s" .) "PUBLIC" -}}
<span class="badge badge-success">public</span>
{{- else if eq (printf "%s" .) "UNLISTED" -}}
<span class="badge badge-warning">unlisted</span>
{{- else -}}
<span class="badge badge-secondary">private</span>
{{- end}}
{{- end}}

{{define "repoList" -}}
{{if .}}
<div class="repo-list">
  {{range .}}
  <span>
    <a href="/~{{.OwnerName}}/{{.Name}}">~{{.OwnerName}}/{{.Name}}</a>
    {{template "visibilityBadge" .Visibility}}
  </span>
  <span class="text-muted">{{.Description}}</span>
  {{end}}
</div>
{{else}}
<p class="text-muted">No databases yet.</p>
{{end}}
{{- end}}

A web/templates/settings.html => web/templates/settings.html +74 -0
@@ 0,0 1,74 @@
{{define "content" -}}
<h2>
  <a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}">~{{.Repo.OwnerName}}/{{.Repo.Name}}</a>
  &middot; settings
</h2>
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
{{if .Notice}}<div class="alert alert-success">{{.Notice}}</div>{{end}}

<h4>General</h4>
<form method="POST">
  <input type="hidden" name="action" value="update">
  <div class="form-group">
    <label for="description">Description</label>
    <input type="text" class="form-control" name="description" id="description"
      value="{{.Repo.Description}}" maxlength="1024">
  </div>
  <div class="form-group">
    <label for="visibility">Visibility</label>
    <select class="form-control" name="visibility" id="visibility">
      <option value="PUBLIC" {{if eq (printf "%s" .Repo.Visibility) "PUBLIC"}}selected{{end}}>Public</option>
      <option value="UNLISTED" {{if eq (printf "%s" .Repo.Visibility) "UNLISTED"}}selected{{end}}>Unlisted</option>
      <option value="PRIVATE" {{if eq (printf "%s" .Repo.Visibility) "PRIVATE"}}selected{{end}}>Private</option>
    </select>
  </div>
  <button type="submit" class="btn btn-primary">Save</button>
</form>

<div class="header-extension"></div>
<h4>Access control</h4>
{{if .ACL}}
<table class="table">
  <thead><tr><th>User</th><th>Mode</th><th></th></tr></thead>
  <tbody>
    {{range .ACL}}
    <tr>
      <td>~{{.Username}}</td>
      <td>{{.Mode}}</td>
      <td>
        <form method="POST" style="display:inline">
          <input type="hidden" name="action" value="acl_remove">
          <input type="hidden" name="user_id" value="{{.UserID}}">
          <button type="submit" class="btn btn-sm btn-danger">Remove</button>
        </form>
      </td>
    </tr>
    {{end}}
  </tbody>
</table>
{{else}}
<p class="text-muted">No collaborators.</p>
{{end}}

<form method="POST" class="form-inline">
  <input type="hidden" name="action" value="acl_add">
  <input type="text" class="form-control mr-2" name="username" placeholder="username" required>
  <select class="form-control mr-2" name="mode">
    <option value="RO">Read-only</option>
    <option value="RW">Read-write</option>
  </select>
  <button type="submit" class="btn btn-primary">Add collaborator</button>
</form>

<div class="header-extension"></div>
<h4 class="text-danger">Danger zone</h4>
<p>Deleting a database removes its metadata and its on-disk store permanently.</p>
<form method="POST" onsubmit="return confirm('Delete {{.Repo.Name}}? This cannot be undone.')">
  <input type="hidden" name="action" value="delete">
  <div class="form-group">
    <label for="confirm_name">Type <code>{{.Repo.Name}}</code> to confirm</label>
    <input type="text" class="form-control" name="confirm_name" id="confirm_name" autocomplete="off">
  </div>
  <button type="submit" class="btn btn-danger">Delete database</button>
</form>
{{- end}}

A web/templates/table.html => web/templates/table.html +47 -0
@@ 0,0 1,47 @@
{{define "content" -}}
<h2>
  <a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}">~{{.Repo.OwnerName}}/{{.Repo.Name}}</a>
  &middot; {{.Table}}
</h2>
<p class="text-muted">
  <code>{{.Ref}}</code> &middot; {{.Rows.Total}} rows
</p>

{{if .Rows.Columns}}
<div style="overflow-x: auto">
<table class="table table-sm">
  <thead>
    <tr>{{range .Rows.Columns}}<th>{{.}}</th>{{end}}</tr>
  </thead>
  <tbody>
    {{range .Rows.Rows}}
    <tr>{{range .}}<td>{{.}}</td>{{end}}</tr>
    {{end}}
  </tbody>
</table>
</div>
{{if not .Rows.Rows}}
<p class="text-muted">No rows on this page.</p>
{{end}}
{{else}}
<p class="text-muted">This table has no columns.</p>
{{end}}

<nav aria-label="rows pagination">
  <ul class="pagination">
    <li class="page-item {{if not .HasPrev}}disabled{{end}}">
      <a class="page-link"
         href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/table/{{.Ref | urlquery}}/{{.Table | urlquery}}?page={{dec .Page}}">
        &larr; Previous
      </a>
    </li>
    <li class="page-item disabled"><span class="page-link">Page {{.Page}} of {{.TotalPages}}</span></li>
    <li class="page-item {{if not .HasNext}}disabled{{end}}">
      <a class="page-link"
         href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/table/{{.Ref | urlquery}}/{{.Table | urlquery}}?page={{inc .Page}}">
        Next &rarr;
      </a>
    </li>
  </ul>
</nav>
{{- end}}

A web/templates/tree.html => web/templates/tree.html +31 -0
@@ 0,0 1,31 @@
{{define "content" -}}
<h2><a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}">~{{.Repo.OwnerName}}/{{.Repo.Name}}</a> &middot; tree</h2>
<p class="text-muted">Tables at <code>{{.Ref}}</code></p>

{{if .Tables}}
{{range .Tables}}
<div class="header-extension"></div>
<h4>{{icon "folder"}} {{.Name}} <small class="text-muted">{{.RowCount}} rows</small>
  <a class="btn btn-sm btn-secondary"
     href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/table/{{$.Ref}}/{{.Name}}">rows</a>
</h4>
<table class="table table-sm">
  <thead>
    <tr><th>Column</th><th>Type</th><th>Key</th><th>Null</th></tr>
  </thead>
  <tbody>
    {{range .Columns}}
    <tr>
      <td><code>{{.Name}}</code></td>
      <td>{{.Type}}</td>
      <td>{{if .PrimaryKey}}PK{{end}}</td>
      <td>{{if .Nullable}}yes{{else}}no{{end}}</td>
    </tr>
    {{end}}
  </tbody>
</table>
{{end}}
{{else}}
<p class="text-muted">No tables at this ref.</p>
{{end}}
{{- end}}

A web/templates/user.html => web/templates/user.html +5 -0
@@ 0,0 1,5 @@
{{define "content" -}}
<h2>{{icon "user"}} ~{{.Owner}}</h2>
<p class="text-muted">Databases owned by ~{{.Owner}}.</p>
{{template "repoList" .Repos}}
{{- end}}

A web/web_test.go => web/web_test.go +636 -0
@@ 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", "<b>x</b>"}}, 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(), "<b>x</b>") {
		t.Fatalf("table cell not HTML-escaped")
	}
	if !strings.Contains(tblRec.Body.String(), "&lt;b&gt;x&lt;/b&gt;") {
		t.Fatalf("table cell escaping wrong; body=%s", tblRec.Body.String())
	}
}