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 — 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 — 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> · 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 −</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> · 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}}&from={{.NextHash | urlquery}}">
+ Older commits →
+</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>
+ —
+ <a href="{{.LogoutURL}}">Log out</a>
+ </span>
+ {{else}}
+ <span class="navbar-text">
+ <a href="{{.LoginURL}}" rel="nofollow">Log in</a>
+ —
+ <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=<your meta access token>
+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 →</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>
+ · 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>
+ · {{.Table}}
+</h2>
+<p class="text-muted">
+ <code>{{.Ref}}</code> · {{.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}}">
+ ← 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 →
+ </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> · 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(), "<b>x</b>") {
+ t.Fatalf("table cell escaping wrong; body=%s", tblRec.Body.String())
+ }
+}