From 944a35e9ed6b9baa0a59adb06df09085bc62d508 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 18 Jul 2026 21:13:39 +0300 Subject: [PATCH] web: router, handlers, and sourcehut chrome Chi route table per plan, SourceHut chrome port (nav/layout from core.sr.ht Bootstrap structure), dual-flow clone box, dolt-key association page with hash-fragment prefill, same-origin CSRF checks. Handlers depend on small injected interfaces (StoreManager, RepoStore, BrowseOpener, UserResolver) with production adapters included. --- web/adapters.go | 107 ++++ web/chrome.go | 150 +++++ web/csrf.go | 48 ++ web/deps.go | 140 ++++ web/handlers_browse.go | 217 +++++++ web/handlers_keys.go | 110 ++++ web/handlers_repo.go | 249 ++++++++ web/handlers_settings.go | 217 +++++++ web/pubkey.go | 36 ++ web/router.go | 134 ++++ web/templates.go | 228 +++++++ web/templates/403.html | 6 + web/templates/404.html | 6 + web/templates/commit.html | 33 + web/templates/create.html | 33 + web/templates/icons/caret-right.svg | 1 + web/templates/icons/circle.svg | 1 + web/templates/icons/clock.svg | 1 + web/templates/icons/code-branch.svg | 1 + web/templates/icons/exclamation-triangle.svg | 5 + web/templates/icons/folder.svg | 1 + web/templates/icons/plus-square.svg | 1 + web/templates/icons/user.svg | 1 + web/templates/index.html | 26 + web/templates/keys.html | 69 ++ web/templates/layout.html | 25 + web/templates/log.html | 44 ++ web/templates/nav.html | 36 ++ web/templates/overview.html | 68 ++ web/templates/partials.html | 25 + web/templates/settings.html | 74 +++ web/templates/table.html | 47 ++ web/templates/tree.html | 31 + web/templates/user.html | 5 + web/web_test.go | 636 +++++++++++++++++++ 35 files changed, 2812 insertions(+) create mode 100644 web/adapters.go create mode 100644 web/chrome.go create mode 100644 web/csrf.go create mode 100644 web/deps.go create mode 100644 web/handlers_browse.go create mode 100644 web/handlers_keys.go create mode 100644 web/handlers_repo.go create mode 100644 web/handlers_settings.go create mode 100644 web/pubkey.go create mode 100644 web/router.go create mode 100644 web/templates.go create mode 100644 web/templates/403.html create mode 100644 web/templates/404.html create mode 100644 web/templates/commit.html create mode 100644 web/templates/create.html create mode 100644 web/templates/icons/caret-right.svg create mode 100644 web/templates/icons/circle.svg create mode 100644 web/templates/icons/clock.svg create mode 100644 web/templates/icons/code-branch.svg create mode 100644 web/templates/icons/exclamation-triangle.svg create mode 100644 web/templates/icons/folder.svg create mode 100644 web/templates/icons/plus-square.svg create mode 100644 web/templates/icons/user.svg create mode 100644 web/templates/index.html create mode 100644 web/templates/keys.html create mode 100644 web/templates/layout.html create mode 100644 web/templates/log.html create mode 100644 web/templates/nav.html create mode 100644 web/templates/overview.html create mode 100644 web/templates/partials.html create mode 100644 web/templates/settings.html create mode 100644 web/templates/table.html create mode 100644 web/templates/tree.html create mode 100644 web/templates/user.html create mode 100644 web/web_test.go diff --git a/web/adapters.go b/web/adapters.go new file mode 100644 index 0000000000000000000000000000000000000000..484c08f7823146ae34777d870bc8fceb9f81d2e4 --- /dev/null +++ b/web/adapters.go @@ -0,0 +1,107 @@ +package web + +import ( + "context" + + "git.sr.ht/~sircmpwn/core-go/auth" + + "go.bigb.es/sourcehut-dolt/authn" + "go.bigb.es/sourcehut-dolt/browse" + "go.bigb.es/sourcehut-dolt/core" + "go.bigb.es/sourcehut-dolt/db" +) + +// This file holds the production adapters that satisfy the small interfaces in +// deps.go over the real db, browse and core-go layers. Tests do not use these; +// they inject their own fakes. Each adapter is a zero-value struct with a +// compile-time interface assertion so a signature drift in a committed package +// breaks the build here rather than at Phase-3 wiring time. + +// DBAdapter satisfies RepoStore over the request-scoped db.Store. It holds no +// connection: every method builds a Store from the *sql.DB the core-go database +// middleware installed in ctx (db.FromContext), so one value serves all +// requests and leaks nothing. +type DBAdapter struct{} + +var _ RepoStore = DBAdapter{} + +func (DBAdapter) CreateRepo(ctx context.Context, r *core.Repo) (*core.Repo, error) { + return db.FromContext(ctx).CreateRepo(ctx, r) +} + +func (DBAdapter) GetRepoByOwnerAndName(ctx context.Context, owner, name string) (*core.Repo, error) { + return db.FromContext(ctx).GetRepoByOwnerAndName(ctx, owner, name) +} + +func (DBAdapter) ListReposByOwner(ctx context.Context, owner string, viewer *core.Caller) ([]*core.Repo, error) { + return db.FromContext(ctx).ListReposByOwner(ctx, owner, viewer) +} + +func (DBAdapter) ListReposForDashboard(ctx context.Context, userID int) ([]*core.Repo, error) { + return db.FromContext(ctx).ListReposForDashboard(ctx, userID) +} + +func (DBAdapter) UpdateRepo(ctx context.Context, id int, description string, visibility core.Visibility) error { + return db.FromContext(ctx).UpdateRepo(ctx, id, description, visibility) +} + +func (DBAdapter) DeleteRepo(ctx context.Context, id int) error { + return db.FromContext(ctx).DeleteRepo(ctx, id) +} + +func (DBAdapter) EffectiveAccess(ctx context.Context, userID, repoID int) (*core.AccessMode, error) { + return db.FromContext(ctx).EffectiveAccess(ctx, userID, repoID) +} + +func (DBAdapter) ListACL(ctx context.Context, repoID int) ([]*db.ACLEntry, error) { + return db.FromContext(ctx).ListACL(ctx, repoID) +} + +func (DBAdapter) UpsertACL(ctx context.Context, repoID, userID int, mode core.AccessMode) error { + return db.FromContext(ctx).UpsertACL(ctx, repoID, userID, mode) +} + +func (DBAdapter) DeleteACL(ctx context.Context, repoID, userID int) error { + return db.FromContext(ctx).DeleteACL(ctx, repoID, userID) +} + +func (DBAdapter) InsertKey(ctx context.Context, userID int, kid string, pubkey []byte, comment string) (*db.DoltKey, error) { + return db.FromContext(ctx).InsertKey(ctx, userID, kid, pubkey, comment) +} + +func (DBAdapter) ListKeysByUser(ctx context.Context, userID int) ([]*db.DoltKey, error) { + return db.FromContext(ctx).ListKeysByUser(ctx, userID) +} + +func (DBAdapter) DeleteKey(ctx context.Context, id, userID int) error { + return db.FromContext(ctx).DeleteKey(ctx, id, userID) +} + +// BrowseAdapter satisfies BrowseOpener over browse.Open. The returned *browse.DB +// already implements every BrowseSession method plus Close. +type BrowseAdapter struct{} + +var _ BrowseOpener = BrowseAdapter{} + +func (BrowseAdapter) Open(ctx context.Context, diskPath string) (BrowseSession, error) { + dbh, err := browse.Open(ctx, diskPath) + if err != nil { + return nil, err + } + return dbh, nil +} + +// MetaUserResolver satisfies UserResolver via core-go's auth.LookupUser, which +// mirrors the meta.sr.ht profile into the local user table and yields the +// account's UserID. Used to resolve an ACL grantee by username. +type MetaUserResolver struct{} + +var _ UserResolver = MetaUserResolver{} + +func (MetaUserResolver) LookupUser(ctx context.Context, username string) (*core.Caller, error) { + var ac auth.AuthContext + if err := auth.LookupUser(ctx, username, &ac); err != nil { + return nil, err + } + return authn.AsCoreCaller(&ac), nil +} diff --git a/web/chrome.go b/web/chrome.go new file mode 100644 index 0000000000000000000000000000000000000000..67c07a2f341bb7db0ca8bef9c12616569fa75ccf --- /dev/null +++ b/web/chrome.go @@ -0,0 +1,150 @@ +package web + +import ( + "net/http" + "net/url" + "sort" + "strings" + + "git.sr.ht/~sircmpwn/core-go/auth" + "git.sr.ht/~sircmpwn/core-go/config" + "github.com/vaughan0/go-ini" + + "go.bigb.es/sourcehut-dolt/authn" +) + +// serviceName is our own service key in the shared config and nav. +const serviceName = "dolt.sr.ht" + +// networkOrder is upstream core.sr.ht's fixed nav ordering (flask.py +// _network_order). Services present in the config but not listed here sort +// after these, alphabetically. paste.sr.ht and pages.sr.ht are excluded from +// the network entirely (they have no user-facing nav), mirroring _network. +var networkOrder = []string{ + "hub.sr.ht", + "git.sr.ht", + "hg.sr.ht", + "lists.sr.ht", + "todo.sr.ht", + "builds.sr.ht", + "man.sr.ht", + "meta.sr.ht", +} + +var networkExcluded = map[string]bool{ + "paste.sr.ht": true, + "pages.sr.ht": true, +} + +// navEntry is one service link in the shared nav. +type navEntry struct { + // Name is the leading segment of the service (e.g. "git" for git.sr.ht), + // rendered as the link text exactly as upstream nav.html does. + Name string + // Site is the full service key (e.g. "git.sr.ht"). + Site string + // Origin is the external URL for the service. + Origin string + // Active is true for our own service, which highlights it in the nav. + Active bool +} + +// basePage is the chrome model shared by every rendered page. Handler view +// structs embed it so templates reference its fields directly (e.g. .SiteName). +type basePage struct { + Title string + Site string + SiteLabel string + SiteName string + Environment string + ShowEnvBanner bool + Network []navEntry + MetaOrigin string + SelfOrigin string + LoginURL string + LogoutURL string + StyleHref string + // CurrentUser is the authenticated caller, or nil for an anonymous request. + CurrentUser *auth.AuthContext +} + +// buildNetwork returns the ordered nav entries for conf: every section ending +// ".sr.ht" that is not excluded, ordered by networkOrder then alphabetically +// for unknown services. Origins are resolved externally via config.GetOrigin. +func buildNetwork(conf ini.File) []navEntry { + var sites []string + for section := range conf { + if !strings.HasSuffix(section, ".sr.ht") || networkExcluded[section] { + continue + } + sites = append(sites, section) + } + + orderIndex := func(s string) int { + for i, n := range networkOrder { + if n == s { + return i + } + } + return len(networkOrder) // unknown services sort after the fixed set + } + sort.Slice(sites, func(i, j int) bool { + oi, oj := orderIndex(sites[i]), orderIndex(sites[j]) + if oi != oj { + return oi < oj + } + return sites[i] < sites[j] // stable, alphabetical among unknowns + }) + + entries := make([]navEntry, 0, len(sites)) + for _, s := range sites { + entries = append(entries, navEntry{ + Name: strings.SplitN(s, ".", 2)[0], + Site: s, + Origin: config.GetOrigin(conf, s, true), + Active: s == serviceName, + }) + } + return entries +} + +// newBasePage builds the chrome model for a request. Title is the per-page +// ; the caller sets any page-specific fields on its own view struct. +func (a *app) newBasePage(r *http.Request, title string) basePage { + conf := a.cfg.Conf + env := config.GetString(conf, "sr.ht", "environment", "development") + self := config.GetOrigin(conf, serviceName, true) + meta := config.GetOrigin(conf, "meta.sr.ht", true) + + return basePage{ + Title: title, + Site: serviceName, + SiteLabel: strings.SplitN(serviceName, ".", 2)[0], + SiteName: config.GetString(conf, "sr.ht", "site-name", "sr.ht"), + Environment: env, + ShowEnvBanner: env != "production", + Network: buildNetwork(conf), + MetaOrigin: meta, + SelfOrigin: self, + LoginURL: loginURL(self, meta, r), + LogoutURL: logoutURL(self, meta), + StyleHref: a.styleHref, + CurrentUser: authn.CallerFromContext(r.Context()), + } +} + +// loginURL mirrors core.sr.ht flask.py: {meta}/login?return_to={self+full_path}. +// full_path is the request path with its query string, so login round-trips the +// user back to exactly where they were. +func loginURL(self, meta string, r *http.Request) string { + returnTo := self + r.URL.EscapedPath() + if r.URL.RawQuery != "" { + returnTo += "?" + r.URL.RawQuery + } + return meta + "/login?return_to=" + url.QueryEscape(returnTo) +} + +// logoutURL mirrors core.sr.ht flask.py: {meta}/logout?return_to={self origin}. +func logoutURL(self, meta string) string { + return meta + "/logout?return_to=" + url.QueryEscape(self) +} diff --git a/web/csrf.go b/web/csrf.go new file mode 100644 index 0000000000000000000000000000000000000000..07f674db46f2f8347695696e9b0736b43ade94f9 --- /dev/null +++ b/web/csrf.go @@ -0,0 +1,48 @@ +package web + +import ( + "net/http" + "net/url" +) + +// checkSameOrigin is dolt.sr.ht's CSRF defence for state-changing POSTs. core-go +// ships no CSRF helper, so we keep it simple and explicit: a mutating request +// must carry an Origin (or, failing that, a Referer) header whose scheme+host +// matches our own configured origin. Cross-site form posts from a browser always +// send an Origin that differs from ours, so this blocks them; same-origin form +// submissions from our own pages always match. +// +// Rationale and limits (documented deliberately): we trust the Origin/Referer +// header, which browsers set and script cannot forge cross-origin. A request +// with NEITHER header is rejected — our own forms are same-origin and browsers +// send Origin on form POSTs, so a missing header signals a non-browser or +// stripped request, which we decline rather than wave through. This is a +// header-check, not a token scheme; it is sufficient because dolt.sr.ht uses the +// shared unified-login cookie (SameSite handling lives in meta) and has no +// cross-origin embedding. +func (a *app) checkSameOrigin(r *http.Request) bool { + selfOrigin := a.newBasePage(r, "").SelfOrigin + self, err := url.Parse(selfOrigin) + if err != nil || self.Host == "" { + return false + } + + if origin := r.Header.Get("Origin"); origin != "" { + return originMatches(origin, self) + } + if referer := r.Header.Get("Referer"); referer != "" { + return originMatches(referer, self) + } + // No Origin and no Referer: refuse rather than assume same-origin. + return false +} + +// originMatches reports whether raw (a full URL from an Origin or Referer +// header) has the same scheme and host as self. +func originMatches(raw string, self *url.URL) bool { + u, err := url.Parse(raw) + if err != nil { + return false + } + return u.Scheme == self.Scheme && u.Host == self.Host +} diff --git a/web/deps.go b/web/deps.go new file mode 100644 index 0000000000000000000000000000000000000000..6d7f257a496fb7dcd095806e8869e7a6cf03022f --- /dev/null +++ b/web/deps.go @@ -0,0 +1,140 @@ +// Package web is the HTTP layer of dolt.sr.ht: the chi router, request +// handlers, SourceHut nav/chrome, and the html/template views for the database +// dashboard, browse pages, settings and dolt-key management. +// +// # Dependency injection +// +// web is deliberately decoupled from the packages that touch Postgres, disk and +// the remotesapi. It depends directly only on the pure/committed packages it +// renders (core, browse) and authn (for the caller in the request context). +// Everything with side effects — the metadata store, the on-disk store manager, +// the browse opener, and username resolution against meta — is reached through +// SMALL local interfaces declared here and satisfied by thin adapters (see +// adapters.go for the production wiring, and the tests for fakes). This keeps +// httptest coverage free of Postgres and dolt internals, and lets the Phase-3 +// main assemble the real Config without web importing storage/ or remoteapi/. +package web + +import ( + "context" + + "github.com/vaughan0/go-ini" + + "git.sr.ht/~sircmpwn/core-go/auth" + + "go.bigb.es/sourcehut-dolt/authn" + "go.bigb.es/sourcehut-dolt/browse" + "go.bigb.es/sourcehut-dolt/core" + "go.bigb.es/sourcehut-dolt/db" +) + +// Config carries everything the router and handlers need. The Phase-3 main +// builds one and passes it to Register. +type Config struct { + // Conf is the shared instance config (the same ini.File every *.sr.ht + // service reads). Used to render the nav/chrome and resolve origins. + Conf ini.File + // ReposRoot is the absolute directory holding the bare NBS stores, one per + // database at <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) +} diff --git a/web/handlers_browse.go b/web/handlers_browse.go new file mode 100644 index 0000000000000000000000000000000000000000..d87196d083260b388d6d3923ecb5ca777be6621f --- /dev/null +++ b/web/handlers_browse.go @@ -0,0 +1,217 @@ +package web + +import ( + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "go.bigb.es/sourcehut-dolt/browse" + "go.bigb.es/sourcehut-dolt/core" +) + +const ( + // logPageSize is the number of commits per /log page. + logPageSize = 20 + // rowsPageSize is the number of rows per /table page. + rowsPageSize = 50 +) + +// openBrowse opens a browse session for repo, writing a 500 and returning +// ok=false on failure. The caller must Close the returned session. +func (a *app) openBrowse(w http.ResponseWriter, r *http.Request, repo *core.Repo) (BrowseSession, bool) { + sess, err := a.cfg.Browse.Open(r.Context(), repo.Path) + if err != nil { + http.Error(w, "failed to open database", http.StatusInternalServerError) + return nil, false + } + return sess, true +} + +// handleLog renders a paginated commit log for a branch (or ref). Pages after +// the first are reached via ?from=<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) +} diff --git a/web/handlers_keys.go b/web/handlers_keys.go new file mode 100644 index 0000000000000000000000000000000000000000..7b1e13d79b417ceb326a196fb7cdd4f43654fea8 --- /dev/null +++ b/web/handlers_keys.go @@ -0,0 +1,110 @@ +package web + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "go.bigb.es/sourcehut-dolt/db" +) + +// keysView is the dolt-key management page model. +type keysView struct { + basePage + Keys []*db.DoltKey + Error string + Notice string +} + +func (a *app) renderKeys(w http.ResponseWriter, r *http.Request, ac *authContext, status int, errMsg, notice string) { + keys, err := a.cfg.Repos.ListKeysByUser(r.Context(), ac.UserID) + if err != nil { + http.Error(w, "failed to list keys", http.StatusInternalServerError) + return + } + view := keysView{ + basePage: a.newBasePage(r, "Dolt keys — "+serviceName), + Keys: keys, + Error: errMsg, + Notice: notice, + } + a.render(w, status, "keys.html", view) +} + +// handleKeys renders the dolt-key page: the user's registered keys and the +// add-key form. The page reads a `#<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.") +} diff --git a/web/handlers_repo.go b/web/handlers_repo.go new file mode 100644 index 0000000000000000000000000000000000000000..d90f9bbf3b16b41b69062745ec65429ebe4e2b67 --- /dev/null +++ b/web/handlers_repo.go @@ -0,0 +1,249 @@ +package web + +import ( + "errors" + "net/http" + "strings" + + "git.sr.ht/~sircmpwn/core-go/config" + "github.com/go-chi/chi/v5" + + "go.bigb.es/sourcehut-dolt/browse" + "go.bigb.es/sourcehut-dolt/core" + "go.bigb.es/sourcehut-dolt/db" +) + +// overviewCommitLimit is how many recent commits the database overview shows. +const overviewCommitLimit = 10 + +// handleIndex renders the dashboard: the signed-in user's databases (owned + +// ACL) with a create link, or an anonymous welcome blurb. +func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) { + ac, caller := callerOf(r.Context()) + + view := struct { + basePage + Repos []*core.Repo + }{basePage: a.newBasePage(r, serviceName)} + + if ac != nil { + repos, err := a.cfg.Repos.ListReposForDashboard(r.Context(), caller.UserID) + if err != nil { + http.Error(w, "failed to list databases", http.StatusInternalServerError) + return + } + view.Repos = repos + } + a.render(w, http.StatusOK, "index.html", view) +} + +// handleCreateForm renders the new-database form. Login is required. +func (a *app) handleCreateForm(w http.ResponseWriter, r *http.Request) { + if ac := a.requireLogin(w, r); ac == nil { + return + } + a.renderCreate(w, r, http.StatusOK, createForm{Visibility: string(core.VisibilityPublic)}, "") +} + +// createForm is the create page's sticky form state. +type createForm struct { + Name string + Description string + Visibility string +} + +func (a *app) renderCreate(w http.ResponseWriter, r *http.Request, status int, form createForm, errMsg string) { + view := struct { + basePage + Form createForm + Error string + }{ + basePage: a.newBasePage(r, "Create database — "+serviceName), + Form: form, + Error: errMsg, + } + a.render(w, status, "create.html", view) +} + +// handleCreate processes the new-database form. It validates the name, creates +// the metadata row, then the on-disk store; on store-init failure it removes the +// just-created row so no orphan metadata survives. Login and a same-origin POST +// are required. +func (a *app) handleCreate(w http.ResponseWriter, r *http.Request) { + ac := a.requireLogin(w, r) + if ac == nil { + return + } + if !a.checkSameOrigin(r) { + a.forbidden(w, r, "Cross-origin request rejected.") + return + } + if err := r.ParseForm(); err != nil { + a.renderCreate(w, r, http.StatusBadRequest, createForm{}, "Malformed form submission.") + return + } + + form := createForm{ + Name: strings.TrimSpace(r.PostFormValue("name")), + Description: strings.TrimSpace(r.PostFormValue("description")), + Visibility: r.PostFormValue("visibility"), + } + + visibility, ok := parseVisibility(form.Visibility) + if !ok { + a.renderCreate(w, r, http.StatusBadRequest, form, "Invalid visibility.") + return + } + if err := core.ValidateName(form.Name); err != nil { + a.renderCreate(w, r, http.StatusBadRequest, form, err.Error()) + return + } + + owner := ac.Username + ownerName, ownerEmail := config.GetOwner(a.cfg.Conf) + if ownerEmail == "" { + ownerEmail = ac.Email + } + if ownerName == "" { + ownerName = owner + } + + diskPath := a.cfg.RepoDiskPath(owner, form.Name) + repo := &core.Repo{ + Name: form.Name, + Description: form.Description, + OwnerID: ac.UserID, + OwnerName: owner, + Path: diskPath, + Visibility: visibility, + } + + // Insert the metadata row first: a name collision (ErrNameTaken) is caught + // before we ever touch disk. Then create the on-disk store; if that fails, + // remove the row we just inserted so metadata and disk never diverge. + created, err := a.cfg.Repos.CreateRepo(r.Context(), repo) + if err != nil { + if errors.Is(err, db.ErrNameTaken) { + a.renderCreate(w, r, http.StatusConflict, form, + "You already have a database with that name.") + return + } + http.Error(w, "failed to create database", http.StatusInternalServerError) + return + } + + if err := a.cfg.Stores.InitStore(r.Context(), diskPath, ownerName, ownerEmail); err != nil { + // InitStore self-cleans its directory; undo the metadata row too. + _ = a.cfg.Repos.DeleteRepo(r.Context(), created.ID) + http.Error(w, "failed to initialize database store", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/~"+owner+"/"+form.Name, http.StatusSeeOther) +} + +// handleUser renders a single user's visible databases (~user listing). +func (a *app) handleUser(w http.ResponseWriter, r *http.Request) { + owner := chi.URLParam(r, "user") + _, caller := callerOf(r.Context()) + + repos, err := a.cfg.Repos.ListReposByOwner(r.Context(), owner, caller) + if err != nil { + http.Error(w, "failed to list databases", http.StatusInternalServerError) + return + } + + view := struct { + basePage + Owner string + Repos []*core.Repo + }{ + basePage: a.newBasePage(r, "~"+owner+" — "+serviceName), + Owner: owner, + Repos: repos, + } + a.render(w, http.StatusOK, "user.html", view) +} + +// handleOverview renders the database overview: description, visibility badge, +// branch list, latest commits, and a clone box showing both auth flows. +func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) { + repo, _, _, ok := a.loadRepoForBrowse(w, r) + if !ok { + return + } + + var ( + branches []browse.Branch + defBr string + commits []browse.CommitInfo + browseErr string + ) + if sess, err := a.cfg.Browse.Open(r.Context(), repo.Path); err == nil { + defer sess.Close() + if bs, err := sess.Branches(r.Context()); err == nil { + branches = bs + defBr = browse.DefaultBranch(bs) + if defBr != "" { + if cs, _, err := sess.Log(r.Context(), defBr, "", overviewCommitLimit); err == nil { + commits = cs + } else { + browseErr = err.Error() + } + } + } else { + browseErr = err.Error() + } + } else { + browseErr = err.Error() + } + + view := struct { + basePage + Repo *core.Repo + Branches []browse.Branch + DefaultBranch string + Commits []browse.CommitInfo + CloneURL string + BrowseError string + }{ + basePage: a.newBasePage(r, repo.OwnerName+"/"+repo.Name+" — "+serviceName), + Repo: repo, + Branches: branches, + DefaultBranch: defBr, + Commits: commits, + CloneURL: a.cloneURL(r, repo), + BrowseError: browseErr, + } + a.render(w, http.StatusOK, "overview.html", view) +} + +// cloneURL builds the HTTPS clone URL for repo: {self origin}/~owner/name. +func (a *app) cloneURL(r *http.Request, repo *core.Repo) string { + return a.newBasePage(r, "").SelfOrigin + "/~" + repo.OwnerName + "/" + repo.Name +} + +// requireLogin returns the authenticated caller, or nil after redirecting an +// anonymous request to the login page. +func (a *app) requireLogin(w http.ResponseWriter, r *http.Request) *authContext { + ac, _ := callerOf(r.Context()) + if ac == nil { + a.redirectLogin(w, r) + return nil + } + return ac +} + +// parseVisibility validates and maps a form visibility string. +func parseVisibility(s string) (core.Visibility, bool) { + switch core.Visibility(s) { + case core.VisibilityPublic: + return core.VisibilityPublic, true + case core.VisibilityUnlisted: + return core.VisibilityUnlisted, true + case core.VisibilityPrivate: + return core.VisibilityPrivate, true + default: + return "", false + } +} diff --git a/web/handlers_settings.go b/web/handlers_settings.go new file mode 100644 index 0000000000000000000000000000000000000000..bf814e1a8a8fb9fa4368913acf3d29eb3585aeb7 --- /dev/null +++ b/web/handlers_settings.go @@ -0,0 +1,217 @@ +package web + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + "go.bigb.es/sourcehut-dolt/core" + "go.bigb.es/sourcehut-dolt/db" +) + +// loadRepoForAdmin loads the {user}/{db} repo and enforces the owner-only admin +// gate for settings. Login is required (anonymous → login redirect). A missing +// repo, or a hidden PRIVATE repo the caller cannot even browse, is reported as +// not found; a visible repo the caller does not own is a plain 403. On any of +// these it writes the response and returns ok=false. +func (a *app) loadRepoForAdmin(w http.ResponseWriter, r *http.Request) (repo *core.Repo, ac *authContext, ok bool) { + ac = a.requireLogin(w, r) + if ac == nil { + return nil, nil, false + } + _, caller := callerOf(r.Context()) + + owner := chi.URLParam(r, "user") + name := chi.URLParam(r, "db") + repo, err := a.cfg.Repos.GetRepoByOwnerAndName(r.Context(), owner, name) + if err != nil { + a.notFound(w, r) + return nil, nil, false + } + + if caller.UserID != repo.OwnerID { + aclMode := a.effectiveACL(r, caller, repo) + if core.NotFoundForPrivate(caller, repo, aclMode) { + a.notFound(w, r) + } else { + a.forbidden(w, r, "Only the owner may change database settings.") + } + return nil, nil, false + } + return repo, ac, true +} + +// settingsView is the settings page model. +type settingsView struct { + basePage + Repo *core.Repo + ACL []*db.ACLEntry + Error string + Notice string +} + +func (a *app) renderSettings(w http.ResponseWriter, r *http.Request, status int, repo *core.Repo, errMsg, notice string) { + acl, err := a.cfg.Repos.ListACL(r.Context(), repo.ID) + if err != nil { + http.Error(w, "failed to list access", http.StatusInternalServerError) + return + } + view := settingsView{ + basePage: a.newBasePage(r, "Settings — "+repo.OwnerName+"/"+repo.Name), + Repo: repo, + ACL: acl, + Error: errMsg, + Notice: notice, + } + a.render(w, status, "settings.html", view) +} + +// handleSettings renders the settings page (description/visibility, ACLs, danger +// zone). Owner only. +func (a *app) handleSettings(w http.ResponseWriter, r *http.Request) { + repo, _, ok := a.loadRepoForAdmin(w, r) + if !ok { + return + } + a.renderSettings(w, r, http.StatusOK, repo, "", "") +} + +// handleSettingsPost dispatches the settings form on its "action" field: +// update (description + visibility), acl_add, acl_remove, or delete. Owner only, +// same-origin only. +func (a *app) handleSettingsPost(w http.ResponseWriter, r *http.Request) { + repo, _, ok := a.loadRepoForAdmin(w, r) + if !ok { + return + } + if !a.checkSameOrigin(r) { + a.forbidden(w, r, "Cross-origin request rejected.") + return + } + if err := r.ParseForm(); err != nil { + a.renderSettings(w, r, http.StatusBadRequest, repo, "Malformed form submission.", "") + return + } + + switch r.PostFormValue("action") { + case "update": + a.settingsUpdate(w, r, repo) + case "acl_add": + a.settingsACLAdd(w, r, repo) + case "acl_remove": + a.settingsACLRemove(w, r, repo) + case "delete": + a.settingsDelete(w, r, repo) + default: + a.renderSettings(w, r, http.StatusBadRequest, repo, "Unknown action.", "") + } +} + +// settingsUpdate applies the description + visibility change. +func (a *app) settingsUpdate(w http.ResponseWriter, r *http.Request, repo *core.Repo) { + description := strings.TrimSpace(r.PostFormValue("description")) + visibility, ok := parseVisibility(r.PostFormValue("visibility")) + if !ok { + a.renderSettings(w, r, http.StatusBadRequest, repo, "Invalid visibility.", "") + return + } + if err := a.cfg.Repos.UpdateRepo(r.Context(), repo.ID, description, visibility); err != nil { + http.Error(w, "failed to update database", http.StatusInternalServerError) + return + } + repo.Description = description + repo.Visibility = visibility + a.renderSettings(w, r, http.StatusOK, repo, "", "Settings saved.") +} + +// settingsACLAdd grants (or updates) an ACL entry for a username. The grantee is +// resolved via the user resolver, which mirrors the meta profile on first sight. +func (a *app) settingsACLAdd(w http.ResponseWriter, r *http.Request, repo *core.Repo) { + username := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("username")), "~") + mode, ok := parseAccessMode(r.PostFormValue("mode")) + if !ok { + a.renderSettings(w, r, http.StatusBadRequest, repo, "Invalid access mode.", "") + return + } + if username == "" { + a.renderSettings(w, r, http.StatusBadRequest, repo, "A username is required.", "") + return + } + + grantee, err := a.cfg.Users.LookupUser(r.Context(), username) + if err != nil || grantee == nil { + a.renderSettings(w, r, http.StatusBadRequest, repo, + "No such user: "+username, "") + return + } + if grantee.UserID == repo.OwnerID { + a.renderSettings(w, r, http.StatusBadRequest, repo, + "The owner already has full access.", "") + return + } + if err := a.cfg.Repos.UpsertACL(r.Context(), repo.ID, grantee.UserID, mode); err != nil { + http.Error(w, "failed to grant access", http.StatusInternalServerError) + return + } + a.renderSettings(w, r, http.StatusOK, repo, "", "Access granted to "+username+".") +} + +// settingsACLRemove revokes an ACL entry by user id. +func (a *app) settingsACLRemove(w http.ResponseWriter, r *http.Request, repo *core.Repo) { + userID, err := strconv.Atoi(r.PostFormValue("user_id")) + if err != nil { + a.renderSettings(w, r, http.StatusBadRequest, repo, "Invalid user.", "") + return + } + if err := a.cfg.Repos.DeleteACL(r.Context(), repo.ID, userID); err != nil { + if errors.Is(err, db.ErrNotFound) { + a.renderSettings(w, r, http.StatusNotFound, repo, "No such access entry.", "") + return + } + http.Error(w, "failed to revoke access", http.StatusInternalServerError) + return + } + a.renderSettings(w, r, http.StatusOK, repo, "", "Access revoked.") +} + +// settingsDelete deletes the database after a name-confirmation check: the row, +// then the on-disk store, then the served-cache handle. The confirmation guards +// against accidental deletion. +func (a *app) settingsDelete(w http.ResponseWriter, r *http.Request, repo *core.Repo) { + if r.PostFormValue("confirm_name") != repo.Name { + a.renderSettings(w, r, http.StatusBadRequest, repo, + "Type the database name exactly to confirm deletion.", "") + return + } + + if err := a.cfg.Repos.DeleteRepo(r.Context(), repo.ID); err != nil { + http.Error(w, "failed to delete database", http.StatusInternalServerError) + return + } + if err := a.cfg.Stores.DeleteStore(r.Context(), a.cfg.ReposRoot, repo.Path); err != nil { + http.Error(w, "database record removed but store deletion failed: "+err.Error(), + http.StatusInternalServerError) + return + } + if err := a.cfg.Stores.Evict(repo.Path); err != nil { + http.Error(w, "store deleted but cache eviction failed: "+err.Error(), + http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// parseAccessMode validates and maps a form access-mode string. +func parseAccessMode(s string) (core.AccessMode, bool) { + switch core.AccessMode(s) { + case core.AccessRO: + return core.AccessRO, true + case core.AccessRW: + return core.AccessRW, true + default: + return "", false + } +} diff --git a/web/pubkey.go b/web/pubkey.go new file mode 100644 index 0000000000000000000000000000000000000000..a8b465864e8fd11d14b5305e48baad0c023fc5df --- /dev/null +++ b/web/pubkey.go @@ -0,0 +1,36 @@ +package web + +import ( + "fmt" + + "github.com/dolthub/dolt/go/libraries/doltcore/creds" +) + +// ed25519PubKeyLen is the raw length of an Ed25519 public key. dolt encodes it +// as 52 base32 characters (creds.B32EncodedPubKeyLen) in its custom alphabet. +const ed25519PubKeyLen = 32 + +// decodeDoltPubKey decodes the base32 public-key string that the dolt CLI emits. +// It is exactly the string `dolt login` appends to the login URL as a fragment +// (creds.DoltCreds.PubKeyBase32Str: creds.B32CredsEncoding over the raw 32-byte +// key, custom alphabet "0123456789abcdefghijklmnopqrstuv", no padding). It +// returns the raw 32-byte key and its derived key id (kid = +// base32(SHA-512/224(pubkey)) via creds.PubKeyToKIDStr), matching exactly what +// the Bearer-JWT verifier in authn expects to look up. +// +// It validates the decoded length is exactly 32 bytes; a wrong length is a +// malformed key and is rejected loudly rather than stored. +func decodeDoltPubKey(s string) (pubkey []byte, kid string, err error) { + if s == "" { + return nil, "", fmt.Errorf("empty public key") + } + pubkey, err = creds.B32CredsEncoding.DecodeString(s) + if err != nil { + return nil, "", fmt.Errorf("invalid base32 public key: %w", err) + } + if len(pubkey) != ed25519PubKeyLen { + return nil, "", fmt.Errorf("public key must be %d bytes, got %d", ed25519PubKeyLen, len(pubkey)) + } + kid = creds.PubKeyToKIDStr(pubkey) + return pubkey, kid, nil +} diff --git a/web/router.go b/web/router.go new file mode 100644 index 0000000000000000000000000000000000000000..51b9bc53b4495d81704b7edf8fdac37a2ca92850 --- /dev/null +++ b/web/router.go @@ -0,0 +1,134 @@ +package web + +import ( + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + + "go.bigb.es/sourcehut-dolt/core" +) + +// app bundles the parsed templates, the discovered stylesheet href and the +// injected config. Handlers are methods on *app so they share this state +// without a global. +type app struct { + cfg Config + templates templateSet + styleHref string +} + +// Register mounts every dolt.sr.ht web route onto r. The caller (the Phase-3 +// main) installs the config/database/cookie middleware upstream on the router +// group it passes here, then calls Register with the assembled Config. +// +// It parses templates and discovers the stylesheet once, at registration time, +// so a broken template fails startup loudly rather than a request later. A +// parse failure returns an error the caller must surface. +func Register(r chi.Router, cfg Config) error { + if cfg.Repos == nil || cfg.Stores == nil || cfg.Browse == nil || + cfg.Users == nil || cfg.RepoDiskPath == nil { + return fmt.Errorf("web: Register requires Repos, Stores, Browse, Users and RepoDiskPath") + } + + templates, err := loadTemplates() + if err != nil { + return err + } + + a := &app{ + cfg: cfg, + templates: templates, + styleHref: discoverStyleHref(cfg.StaticDir), + } + + r.Get("/", a.handleIndex) + r.Get("/create", a.handleCreateForm) + r.Post("/create", a.handleCreate) + + r.Get("/settings/keys", a.handleKeys) + r.Post("/settings/keys", a.handleKeysPost) + + r.Get("/~{user}", a.handleUser) + r.Get("/~{user}/{db}", a.handleOverview) + r.Get("/~{user}/{db}/log", a.handleLog) + r.Get("/~{user}/{db}/commit/{hash}", a.handleCommit) + r.Get("/~{user}/{db}/tree/{ref}", a.handleTree) + r.Get("/~{user}/{db}/table/{ref}/{table}", a.handleTable) + r.Get("/~{user}/{db}/settings", a.handleSettings) + r.Post("/~{user}/{db}/settings", a.handleSettingsPost) + + r.Handle("/static/*", httpStaticHandler(a.cfg.StaticDir)) + + return nil +} + +// --- shared response helpers ------------------------------------------------- + +// notFound renders the 404 page. Used both for genuinely missing repos and to +// hide the existence of PRIVATE repos the caller may not browse. +func (a *app) notFound(w http.ResponseWriter, r *http.Request) { + view := struct { + basePage + }{basePage: a.newBasePage(r, "Not found — "+serviceName)} + a.render(w, http.StatusNotFound, "404.html", view) +} + +// forbidden renders the 403 page for a denied but non-hidden request. +func (a *app) forbidden(w http.ResponseWriter, r *http.Request, msg string) { + view := struct { + basePage + Message string + }{basePage: a.newBasePage(r, "Forbidden — "+serviceName), Message: msg} + a.render(w, http.StatusForbidden, "403.html", view) +} + +// redirectLogin sends an unauthenticated caller to meta's login, returning them +// to the current URL afterwards. +func (a *app) redirectLogin(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, a.newBasePage(r, "").LoginURL, http.StatusSeeOther) +} + +// loadRepoForBrowse loads the repo named by the {user}/{db} URL params and +// enforces read (OpBrowse) authorization. On any denial it writes the response +// (404 for hidden PRIVATE repos, 403 otherwise) and returns ok=false. On +// success it returns the repo, the (possibly nil) caller and the caller's ACL +// grant for reuse by the handler. +func (a *app) loadRepoForBrowse(w http.ResponseWriter, r *http.Request) (repo *core.Repo, caller *core.Caller, aclMode *core.AccessMode, ok bool) { + owner := chi.URLParam(r, "user") + name := chi.URLParam(r, "db") + + _, caller = callerOf(r.Context()) + + repo, err := a.cfg.Repos.GetRepoByOwnerAndName(r.Context(), owner, name) + if err != nil { + // A missing repo is reported as not found regardless of the caller. + a.notFound(w, r) + return nil, nil, nil, false + } + + aclMode = a.effectiveACL(r, caller, repo) + if !core.Allowed(caller, repo, aclMode, core.OpBrowse) { + if core.NotFoundForPrivate(caller, repo, aclMode) { + a.notFound(w, r) + } else { + a.forbidden(w, r, "You do not have access to this database.") + } + return nil, nil, nil, false + } + return repo, caller, aclMode, true +} + +// effectiveACL resolves the caller's ACL grant on repo, or nil for an anonymous +// caller or a caller with no grant. A lookup error degrades to nil (no grant): +// access then falls back to visibility, which never over-grants. +func (a *app) effectiveACL(r *http.Request, caller *core.Caller, repo *core.Repo) *core.AccessMode { + if caller == nil { + return nil + } + mode, err := a.cfg.Repos.EffectiveAccess(r.Context(), caller.UserID, repo.ID) + if err != nil { + return nil + } + return mode +} diff --git a/web/templates.go b/web/templates.go new file mode 100644 index 0000000000000000000000000000000000000000..8c0780b246529bbf73fe1aabb5f9f31e140450b1 --- /dev/null +++ b/web/templates.go @@ -0,0 +1,228 @@ +package web + +import ( + "embed" + "fmt" + "html/template" + "io/fs" + "net/http" + "net/url" + "os" + "sort" + "strings" + "time" +) + +//go:embed templates/*.html templates/icons/*.svg +var templateFS embed.FS + +// pageTemplates lists every content page. Each is parsed together with the +// shared layout, nav and partials into its own *template.Template, so the +// per-page {{define "content"}} blocks never collide. +var pageTemplates = []string{ + "index.html", + "create.html", + "user.html", + "overview.html", + "log.html", + "commit.html", + "tree.html", + "table.html", + "settings.html", + "keys.html", + "404.html", + "403.html", +} + +// sharedTemplates are parsed into every page: the outer layout, the nav +// fragment, and reusable partials (badges, pagination, etc.). +var sharedTemplates = []string{ + "templates/layout.html", + "templates/nav.html", + "templates/partials.html", +} + +// templateSet maps a page name to its fully-parsed template (execute "layout"). +type templateSet map[string]*template.Template + +// loadTemplates parses every page template with the shared chrome and the +// funcmap. It fails loudly (returns an error) on any parse problem so a broken +// template surfaces at startup, never as a blank page at request time. +func loadTemplates() (templateSet, error) { + icons, err := loadIcons() + if err != nil { + return nil, err + } + funcs := templateFuncs(icons) + + set := make(templateSet, len(pageTemplates)) + for _, page := range pageTemplates { + t := template.New("layout").Funcs(funcs) + files := append(append([]string{}, sharedTemplates...), "templates/"+page) + if _, err := t.ParseFS(templateFS, files...); err != nil { + return nil, fmt.Errorf("web: parse template %s: %w", page, err) + } + set[page] = t + } + return set, nil +} + +// loadIcons reads every embedded icon SVG into a name→markup map for the icon +// template func. +func loadIcons() (map[string]template.HTML, error) { + entries, err := fs.ReadDir(templateFS, "templates/icons") + if err != nil { + return nil, fmt.Errorf("web: read icons dir: %w", err) + } + icons := make(map[string]template.HTML, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".svg") { + continue + } + data, err := templateFS.ReadFile("templates/icons/" + e.Name()) + if err != nil { + return nil, fmt.Errorf("web: read icon %s: %w", e.Name(), err) + } + name := strings.TrimSuffix(e.Name(), ".svg") + icons[name] = template.HTML(fmt.Sprintf( + `<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))) +} diff --git a/web/templates/403.html b/web/templates/403.html new file mode 100644 index 0000000000000000000000000000000000000000..1c794a5c76a120d9fd2115d9074744de2f67733f --- /dev/null +++ b/web/templates/403.html @@ -0,0 +1,6 @@ +{{define "content" -}} +<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}} diff --git a/web/templates/404.html b/web/templates/404.html new file mode 100644 index 0000000000000000000000000000000000000000..8a6fb5495574de2088834712144c6f3ce9831716 --- /dev/null +++ b/web/templates/404.html @@ -0,0 +1,6 @@ +{{define "content" -}} +<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}} diff --git a/web/templates/commit.html b/web/templates/commit.html new file mode 100644 index 0000000000000000000000000000000000000000..4ecbfbe3c706734847d525fc784136468f8c5d31 --- /dev/null +++ b/web/templates/commit.html @@ -0,0 +1,33 @@ +{{define "content" -}} +<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}} diff --git a/web/templates/create.html b/web/templates/create.html new file mode 100644 index 0000000000000000000000000000000000000000..421e193872ed9c30acbb6ba8c7980836dbfe34fb --- /dev/null +++ b/web/templates/create.html @@ -0,0 +1,33 @@ +{{define "content" -}} +<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}} diff --git a/web/templates/icons/caret-right.svg b/web/templates/icons/caret-right.svg new file mode 100644 index 0000000000000000000000000000000000000000..39a772deda798608215e24a917dde64c70c93c8d --- /dev/null +++ b/web/templates/icons/caret-right.svg @@ -0,0 +1 @@ +<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> diff --git a/web/templates/icons/circle.svg b/web/templates/icons/circle.svg new file mode 100644 index 0000000000000000000000000000000000000000..92c0eacc5525ffdeb39b058aaed2bf01f043e3d7 --- /dev/null +++ b/web/templates/icons/circle.svg @@ -0,0 +1 @@ +<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> diff --git a/web/templates/icons/clock.svg b/web/templates/icons/clock.svg new file mode 100644 index 0000000000000000000000000000000000000000..e4970068ec7aa7c40968d96b06837f9c94768e5a --- /dev/null +++ b/web/templates/icons/clock.svg @@ -0,0 +1 @@ +<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> diff --git a/web/templates/icons/code-branch.svg b/web/templates/icons/code-branch.svg new file mode 100644 index 0000000000000000000000000000000000000000..466a1bf6c25dcf36187c59f2d74e8d2dd337eb32 --- /dev/null +++ b/web/templates/icons/code-branch.svg @@ -0,0 +1 @@ +<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> diff --git a/web/templates/icons/exclamation-triangle.svg b/web/templates/icons/exclamation-triangle.svg new file mode 100644 index 0000000000000000000000000000000000000000..be6ddbf56efcd01960fc517b9c605b61ac29d45d --- /dev/null +++ b/web/templates/icons/exclamation-triangle.svg @@ -0,0 +1,5 @@ +<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 diff --git a/web/templates/icons/folder.svg b/web/templates/icons/folder.svg new file mode 100644 index 0000000000000000000000000000000000000000..5000d70293ed9cef05d5f6ab59c38b1228a19905 --- /dev/null +++ b/web/templates/icons/folder.svg @@ -0,0 +1 @@ +<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> diff --git a/web/templates/icons/plus-square.svg b/web/templates/icons/plus-square.svg new file mode 100644 index 0000000000000000000000000000000000000000..5f31c9984409a4cbd6d91a453a7ea0bcdcff101a --- /dev/null +++ b/web/templates/icons/plus-square.svg @@ -0,0 +1 @@ +<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> diff --git a/web/templates/icons/user.svg b/web/templates/icons/user.svg new file mode 100644 index 0000000000000000000000000000000000000000..e54b3a0a36b03de47e9b07b4d691b66248801418 --- /dev/null +++ b/web/templates/icons/user.svg @@ -0,0 +1 @@ +<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> diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000000000000000000000000000000000000..db1229ce42672ae38917617037d8b4c1cb797e6f --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,26 @@ +{{define "content" -}} +{{if .CurrentUser}} +<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}} diff --git a/web/templates/keys.html b/web/templates/keys.html new file mode 100644 index 0000000000000000000000000000000000000000..a1c004d52d8fa90dff5ca7d38482c494f05f9da7 --- /dev/null +++ b/web/templates/keys.html @@ -0,0 +1,69 @@ +{{define "content" -}} +<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}} diff --git a/web/templates/layout.html b/web/templates/layout.html new file mode 100644 index 0000000000000000000000000000000000000000..087fec416521d08252016b1e6bbaae099daef24a --- /dev/null +++ b/web/templates/layout.html @@ -0,0 +1,25 @@ +{{define "layout" -}} +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>{{.Title}} + + + + + {{if .ShowEnvBanner}} +
+ {{.Environment | upper}} ENVIRONMENT +
+ {{end}} + +
+ {{template "content" .}} +
+ + +{{- end}} diff --git a/web/templates/log.html b/web/templates/log.html new file mode 100644 index 0000000000000000000000000000000000000000..3c059cac258cb8f396fc1ed48818cbdb8d6e9763 --- /dev/null +++ b/web/templates/log.html @@ -0,0 +1,44 @@ +{{define "content" -}} +

~{{.Repo.OwnerName}}/{{.Repo.Name}} · log

+ +
+ + + +
+ +{{if .Commits}} + + + + + + {{range .Commits}} + + + + + + + {{end}} + +
CommitMessageAuthorDate
+ + {{.Hash | shorthash}} + + {{.Message}}{{.Author}}{{.Date | reltime}}
+{{else}} +

No commits on this branch.

+{{end}} + +{{if .NextHash}} + + Older commits → + +{{end}} +{{- end}} diff --git a/web/templates/nav.html b/web/templates/nav.html new file mode 100644 index 0000000000000000000000000000000000000000..f1a2293378a5330e1aaac7ba436c32a39c9218af --- /dev/null +++ b/web/templates/nav.html @@ -0,0 +1,36 @@ +{{define "nav" -}} + + {{icon "circle"}} + + {{.SiteName}} + {{.SiteLabel}} + + + +
+ {{if .CurrentUser}} + + Logged in as + {{.CurrentUser.Username}} + — + Log out + + {{else}} + + Log in + — + Register + + {{end}} +
+{{- end}} diff --git a/web/templates/overview.html b/web/templates/overview.html new file mode 100644 index 0000000000000000000000000000000000000000..50798ead4ef8835b9d4367b71ac7db7f678810ce --- /dev/null +++ b/web/templates/overview.html @@ -0,0 +1,68 @@ +{{define "content" -}} +
+

+ ~{{.Repo.OwnerName}}/{{.Repo.Name}} + {{template "visibilityBadge" .Repo.Visibility}} +

+{{if .Repo.Description}} +

{{.Repo.Description}}

+{{end}} + +
+

Clone

+

With a meta.sr.ht personal access token (Basic auth):

+
export DOLT_REMOTE_PASSWORD=<your meta access token>
+dolt clone --user {{.Repo.OwnerName}} {{.CloneURL}}
+

Or, associate a dolt key once and clone with no + credentials (like a git SSH key):

+
dolt creds new
+dolt login --auth-endpoint {{doltHost .SelfOrigin}} --login-url {{.SelfOrigin}}/settings/keys
+dolt clone {{.CloneURL}}
+
+ +
+
+

{{icon "code-branch"}} Branches

+ {{if .Branches}} +
    + {{range .Branches}} +
  • + {{.Name}} + {{if eq .Name $.DefaultBranch}}default{{end}} + {{.Head | shorthash}} +
  • + {{end}} +
+ {{else}} +

No branches.

+ {{end}} +
+
+

{{icon "clock"}} Recent commits

+ {{if .BrowseError}} +
Could not read history: {{.BrowseError}}
+ {{end}} + {{if .Commits}} + + + {{range .Commits}} + + + + + + + {{end}} + +
+ + {{.Hash | shorthash}} + + {{.Message}}{{.Author}}{{.Date | reltime}}
+ Full log → + {{else}} +

No commits.

+ {{end}} +
+
+{{- end}} diff --git a/web/templates/partials.html b/web/templates/partials.html new file mode 100644 index 0000000000000000000000000000000000000000..4620caad29d7d1097625ac600516298225a1fe97 --- /dev/null +++ b/web/templates/partials.html @@ -0,0 +1,25 @@ +{{define "visibilityBadge" -}} +{{if eq (printf "%s" .) "PUBLIC" -}} +public +{{- else if eq (printf "%s" .) "UNLISTED" -}} +unlisted +{{- else -}} +private +{{- end}} +{{- end}} + +{{define "repoList" -}} +{{if .}} +
+ {{range .}} + + ~{{.OwnerName}}/{{.Name}} + {{template "visibilityBadge" .Visibility}} + + {{.Description}} + {{end}} +
+{{else}} +

No databases yet.

+{{end}} +{{- end}} diff --git a/web/templates/settings.html b/web/templates/settings.html new file mode 100644 index 0000000000000000000000000000000000000000..c835f7a73f434d0ea6558c71fd96f8da6bd20266 --- /dev/null +++ b/web/templates/settings.html @@ -0,0 +1,74 @@ +{{define "content" -}} +

+ ~{{.Repo.OwnerName}}/{{.Repo.Name}} + · settings +

+{{if .Error}}
{{.Error}}
{{end}} +{{if .Notice}}
{{.Notice}}
{{end}} + +

General

+
+ +
+ + +
+
+ + +
+ +
+ +
+

Access control

+{{if .ACL}} + + + + {{range .ACL}} + + + + + + {{end}} + +
UserMode
~{{.Username}}{{.Mode}} +
+ + + +
+
+{{else}} +

No collaborators.

+{{end}} + +
+ + + + +
+ +
+

Danger zone

+

Deleting a database removes its metadata and its on-disk store permanently.

+
+ +
+ + +
+ +
+{{- end}} diff --git a/web/templates/table.html b/web/templates/table.html new file mode 100644 index 0000000000000000000000000000000000000000..b838cc4201ebdc5c3b72e38927ebe0021ec8b2bc --- /dev/null +++ b/web/templates/table.html @@ -0,0 +1,47 @@ +{{define "content" -}} +

+ ~{{.Repo.OwnerName}}/{{.Repo.Name}} + · {{.Table}} +

+

+ {{.Ref}} · {{.Rows.Total}} rows +

+ +{{if .Rows.Columns}} +
+ + + {{range .Rows.Columns}}{{end}} + + + {{range .Rows.Rows}} + {{range .}}{{end}} + {{end}} + +
{{.}}
{{.}}
+
+{{if not .Rows.Rows}} +

No rows on this page.

+{{end}} +{{else}} +

This table has no columns.

+{{end}} + + +{{- end}} diff --git a/web/templates/tree.html b/web/templates/tree.html new file mode 100644 index 0000000000000000000000000000000000000000..9785088c91faa00e9081b429f0000b4e9e252df9 --- /dev/null +++ b/web/templates/tree.html @@ -0,0 +1,31 @@ +{{define "content" -}} +

~{{.Repo.OwnerName}}/{{.Repo.Name}} · tree

+

Tables at {{.Ref}}

+ +{{if .Tables}} +{{range .Tables}} +
+

{{icon "folder"}} {{.Name}} {{.RowCount}} rows + rows +

+ + + + + + {{range .Columns}} + + + + + + + {{end}} + +
ColumnTypeKeyNull
{{.Name}}{{.Type}}{{if .PrimaryKey}}PK{{end}}{{if .Nullable}}yes{{else}}no{{end}}
+{{end}} +{{else}} +

No tables at this ref.

+{{end}} +{{- end}} diff --git a/web/templates/user.html b/web/templates/user.html new file mode 100644 index 0000000000000000000000000000000000000000..b17de4a4ddf9d151911497a7140e116f940dd105 --- /dev/null +++ b/web/templates/user.html @@ -0,0 +1,5 @@ +{{define "content" -}} +

{{icon "user"}} ~{{.Owner}}

+

Databases owned by ~{{.Owner}}.

+{{template "repoList" .Repos}} +{{- end}} diff --git a/web/web_test.go b/web/web_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9e98357d1ed76e9b9f21cecbb18bb2a5f4aea424 --- /dev/null +++ b/web/web_test.go @@ -0,0 +1,636 @@ +package web + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "git.sr.ht/~sircmpwn/core-go/auth" + "github.com/dolthub/dolt/go/libraries/doltcore/creds" + "github.com/go-chi/chi/v5" + "github.com/vaughan0/go-ini" + + "go.bigb.es/sourcehut-dolt/authn" + "go.bigb.es/sourcehut-dolt/browse" + "go.bigb.es/sourcehut-dolt/core" + "go.bigb.es/sourcehut-dolt/db" +) + +const selfOrigin = "https://dolt.example" + +// testConfig synthesizes a config with the origins the chrome/CSRF checks read. +func testConfig() ini.File { + return ini.File{ + "sr.ht": ini.Section{ + "environment": "development", + "site-name": "sr.ht", + "owner-name": "admin", + "owner-email": "admin@example.com", + }, + "dolt.sr.ht": ini.Section{"origin": selfOrigin}, + "meta.sr.ht": ini.Section{"origin": "https://meta.example"}, + "git.sr.ht": ini.Section{"origin": "https://git.example"}, + "todo.sr.ht": ini.Section{"origin": "https://todo.example"}, + "paste.sr.ht": ini.Section{"origin": "https://paste.example"}, + } +} + +// --- fakes ------------------------------------------------------------------- + +type fakeStore struct { + repos map[string]*core.Repo // key "owner/name" + byID map[int]*core.Repo + acls map[int]map[int]core.AccessMode // repoID -> userID -> mode + keys map[int][]*db.DoltKey // userID -> keys + nextID int + nextKeyID int + + createErr error + createdCalls []*core.Repo + deletedRepos []int +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + repos: map[string]*core.Repo{}, + byID: map[int]*core.Repo{}, + acls: map[int]map[int]core.AccessMode{}, + keys: map[int][]*db.DoltKey{}, + nextID: 1, + nextKeyID: 1, + } +} + +func (f *fakeStore) add(r *core.Repo) *core.Repo { + r.ID = f.nextID + f.nextID++ + f.repos[r.OwnerName+"/"+r.Name] = r + f.byID[r.ID] = r + return r +} + +func (f *fakeStore) CreateRepo(_ context.Context, r *core.Repo) (*core.Repo, error) { + if f.createErr != nil { + return nil, f.createErr + } + if _, ok := f.repos[r.OwnerName+"/"+r.Name]; ok { + return nil, db.ErrNameTaken + } + cp := *r + out := f.add(&cp) + f.createdCalls = append(f.createdCalls, out) + return out, nil +} + +func (f *fakeStore) GetRepoByOwnerAndName(_ context.Context, owner, name string) (*core.Repo, error) { + r, ok := f.repos[owner+"/"+name] + if !ok { + return nil, db.ErrNotFound + } + return r, nil +} + +func (f *fakeStore) ListReposByOwner(_ context.Context, owner string, viewer *core.Caller) ([]*core.Repo, error) { + var out []*core.Repo + for _, r := range f.repos { + if r.OwnerName != owner { + continue + } + visible := r.Visibility == core.VisibilityPublic + if viewer != nil && (viewer.UserID == r.OwnerID || f.hasACL(r.ID, viewer.UserID)) { + visible = true + } + if visible { + out = append(out, r) + } + } + return out, nil +} + +func (f *fakeStore) ListReposForDashboard(_ context.Context, userID int) ([]*core.Repo, error) { + var out []*core.Repo + for _, r := range f.byID { + if r.OwnerID == userID || f.hasACL(r.ID, userID) { + out = append(out, r) + } + } + return out, nil +} + +func (f *fakeStore) UpdateRepo(_ context.Context, id int, description string, visibility core.Visibility) error { + r, ok := f.byID[id] + if !ok { + return db.ErrNotFound + } + r.Description = description + r.Visibility = visibility + return nil +} + +func (f *fakeStore) DeleteRepo(_ context.Context, id int) error { + r, ok := f.byID[id] + if !ok { + return db.ErrNotFound + } + delete(f.byID, id) + delete(f.repos, r.OwnerName+"/"+r.Name) + f.deletedRepos = append(f.deletedRepos, id) + return nil +} + +func (f *fakeStore) hasACL(repoID, userID int) bool { + m, ok := f.acls[repoID] + if !ok { + return false + } + _, ok = m[userID] + return ok +} + +func (f *fakeStore) EffectiveAccess(_ context.Context, userID, repoID int) (*core.AccessMode, error) { + m, ok := f.acls[repoID] + if !ok { + return nil, nil + } + mode, ok := m[userID] + if !ok { + return nil, nil + } + return &mode, nil +} + +func (f *fakeStore) ListACL(_ context.Context, repoID int) ([]*db.ACLEntry, error) { + var out []*db.ACLEntry + for uid, mode := range f.acls[repoID] { + out = append(out, &db.ACLEntry{RepoID: repoID, UserID: uid, Username: fmt.Sprintf("user%d", uid), Mode: mode}) + } + return out, nil +} + +func (f *fakeStore) UpsertACL(_ context.Context, repoID, userID int, mode core.AccessMode) error { + if f.acls[repoID] == nil { + f.acls[repoID] = map[int]core.AccessMode{} + } + f.acls[repoID][userID] = mode + return nil +} + +func (f *fakeStore) DeleteACL(_ context.Context, repoID, userID int) error { + if !f.hasACL(repoID, userID) { + return db.ErrNotFound + } + delete(f.acls[repoID], userID) + return nil +} + +func (f *fakeStore) InsertKey(_ context.Context, userID int, kid string, pubkey []byte, comment string) (*db.DoltKey, error) { + for _, ks := range f.keys { + for _, k := range ks { + if k.KID == kid { + return nil, db.ErrKeyExists + } + } + } + k := &db.DoltKey{ID: f.nextKeyID, UserID: userID, KID: kid, PubKey: pubkey, Comment: comment, Created: time.Now()} + f.nextKeyID++ + f.keys[userID] = append(f.keys[userID], k) + return k, nil +} + +func (f *fakeStore) ListKeysByUser(_ context.Context, userID int) ([]*db.DoltKey, error) { + return f.keys[userID], nil +} + +func (f *fakeStore) DeleteKey(_ context.Context, id, userID int) error { + ks := f.keys[userID] + for i, k := range ks { + if k.ID == id { + f.keys[userID] = append(ks[:i], ks[i+1:]...) + return nil + } + } + return db.ErrNotFound +} + +type fakeStoreManager struct { + initErr error + initCalls []string + deleteCalls []string + evictCalls []string +} + +func (m *fakeStoreManager) InitStore(_ context.Context, absPath, _, _ string) error { + m.initCalls = append(m.initCalls, absPath) + return m.initErr +} +func (m *fakeStoreManager) DeleteStore(_ context.Context, _, absPath string) error { + m.deleteCalls = append(m.deleteCalls, absPath) + return nil +} +func (m *fakeStoreManager) Evict(diskPath string) error { + m.evictCalls = append(m.evictCalls, diskPath) + return nil +} + +type fakeSession struct { + branches []browse.Branch + commits []browse.CommitInfo + tables []browse.TableInfo + rows *browse.RowPage + summary *browse.CommitDiff + closed bool +} + +func (s *fakeSession) Branches(context.Context) ([]browse.Branch, error) { return s.branches, nil } +func (s *fakeSession) Log(_ context.Context, _, _ string, _ int) ([]browse.CommitInfo, string, error) { + return s.commits, "", nil +} +func (s *fakeSession) Tables(_ context.Context, _ string) ([]browse.TableInfo, error) { + return s.tables, nil +} +func (s *fakeSession) Rows(_ context.Context, _, _ string, _, _ int) (*browse.RowPage, error) { + return s.rows, nil +} +func (s *fakeSession) CommitSummary(_ context.Context, _ string) (*browse.CommitDiff, error) { + return s.summary, nil +} +func (s *fakeSession) Close() error { s.closed = true; return nil } + +type fakeBrowse struct{ sess *fakeSession } + +func (b *fakeBrowse) Open(context.Context, string) (BrowseSession, error) { + if b.sess == nil { + return &fakeSession{}, nil + } + return b.sess, nil +} + +type fakeUsers struct { + byName map[string]*core.Caller +} + +func (u *fakeUsers) LookupUser(_ context.Context, username string) (*core.Caller, error) { + c, ok := u.byName[username] + if !ok { + return nil, errors.New("no such user") + } + return c, nil +} + +// --- harness ----------------------------------------------------------------- + +type harness struct { + router chi.Router + store *fakeStore + stores *fakeStoreManager + browse *fakeBrowse + users *fakeUsers +} + +func newHarness(t *testing.T) *harness { + t.Helper() + store := newFakeStore() + stores := &fakeStoreManager{} + fb := &fakeBrowse{} + users := &fakeUsers{byName: map[string]*core.Caller{}} + + cfg := Config{ + Conf: testConfig(), + ReposRoot: "/var/lib/dolt", + StaticDir: "", + Stores: stores, + Repos: store, + Browse: fb, + Users: users, + RepoDiskPath: func(owner, name string) string { + return "/var/lib/dolt/~" + owner + "/" + name + }, + } + r := chi.NewRouter() + if err := Register(r, cfg); err != nil { + t.Fatalf("Register: %v", err) + } + return &harness{router: r, store: store, stores: stores, browse: fb, users: users} +} + +// do issues a request through the router, optionally with an authenticated +// caller injected into the context (as OptionalCookieMiddleware would). +func (h *harness) do(method, target string, caller *auth.AuthContext, form url.Values) *httptest.ResponseRecorder { + var req *http.Request + if form != nil { + req = httptest.NewRequest(method, target, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", selfOrigin) // same-origin by default + } else { + req = httptest.NewRequest(method, target, nil) + } + if caller != nil { + req = req.WithContext(authn.WithCaller(req.Context(), caller)) + } + rec := httptest.NewRecorder() + h.router.ServeHTTP(rec, req) + return rec +} + +func testCaller(id int, name string) *auth.AuthContext { + return &auth.AuthContext{UserID: id, Username: name, UserType: auth.USER_TYPE_USER, Email: name + "@example.com"} +} + +// validDoltPubKeyStr returns a 52-char base32 dolt public key (over 32 random +// bytes) in dolt's custom alphabet, exactly the shape `dolt login` emits. +func validDoltPubKeyStr(t *testing.T) string { + t.Helper() + pub := make([]byte, ed25519PubKeyLen) + if _, err := rand.Read(pub); err != nil { + t.Fatalf("rand: %v", err) + } + return creds.B32CredsEncoding.EncodeToString(pub) +} + +// --- tests ------------------------------------------------------------------- + +func TestOverviewAnonymousPublicPrivate(t *testing.T) { + h := newHarness(t) + h.store.add(&core.Repo{Name: "pub", OwnerID: 1, OwnerName: "alice", Path: "/p", Visibility: core.VisibilityPublic}) + h.store.add(&core.Repo{Name: "sec", OwnerID: 1, OwnerName: "alice", Path: "/s", Visibility: core.VisibilityPrivate}) + + if rec := h.do("GET", "/~alice/pub", nil, nil); rec.Code != http.StatusOK { + t.Fatalf("public overview: got %d, want 200", rec.Code) + } + rec := h.do("GET", "/~alice/sec", nil, nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("private overview anon: got %d, want 404", rec.Code) + } + if strings.Contains(rec.Body.String(), "sec") && strings.Contains(rec.Body.String(), "clone") { + t.Fatalf("private repo leaked details to anonymous") + } +} + +func TestPrivateVisibleToOwner(t *testing.T) { + h := newHarness(t) + h.store.add(&core.Repo{Name: "sec", OwnerID: 7, OwnerName: "alice", Path: "/s", Visibility: core.VisibilityPrivate}) + rec := h.do("GET", "/~alice/sec", testCaller(7, "alice"), nil) + if rec.Code != http.StatusOK { + t.Fatalf("owner private overview: got %d, want 200", rec.Code) + } +} + +func TestDashboardLists(t *testing.T) { + h := newHarness(t) + h.store.add(&core.Repo{Name: "mine", OwnerID: 3, OwnerName: "bob", Path: "/m", Visibility: core.VisibilityPrivate}) + + rec := h.do("GET", "/", testCaller(3, "bob"), nil) + if rec.Code != http.StatusOK { + t.Fatalf("dashboard: got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "~bob/mine") { + t.Fatalf("dashboard missing owned repo; body=%s", rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "/create") { + t.Fatalf("dashboard missing create link") + } + + // Anonymous dashboard shows the blurb, not the list. + anon := h.do("GET", "/", nil, nil) + if !strings.Contains(anon.Body.String(), "Log in") { + t.Fatalf("anon dashboard missing login blurb") + } +} + +func TestCreateValidationAndSuccess(t *testing.T) { + h := newHarness(t) + caller := testCaller(5, "carol") + + // Anonymous create is redirected to login. + if rec := h.do("GET", "/create", nil, nil); rec.Code != http.StatusSeeOther { + t.Fatalf("anon create form: got %d, want 303", rec.Code) + } + + // Invalid name. + bad := h.do("POST", "/create", caller, url.Values{"name": {"bad name!"}, "visibility": {"PUBLIC"}}) + if bad.Code != http.StatusBadRequest { + t.Fatalf("invalid name: got %d, want 400", bad.Code) + } + + // Success. + ok := h.do("POST", "/create", caller, url.Values{"name": {"gooddb"}, "visibility": {"PUBLIC"}, "description": {"hi"}}) + if ok.Code != http.StatusSeeOther { + t.Fatalf("create success: got %d, want 303; body=%s", ok.Code, ok.Body.String()) + } + if got := ok.Header().Get("Location"); got != "/~carol/gooddb" { + t.Fatalf("create redirect: got %q", got) + } + if len(h.stores.initCalls) != 1 || h.stores.initCalls[0] != "/var/lib/dolt/~carol/gooddb" { + t.Fatalf("InitStore not called correctly: %v", h.stores.initCalls) + } + if _, err := h.store.GetRepoByOwnerAndName(context.Background(), "carol", "gooddb"); err != nil { + t.Fatalf("repo row not created: %v", err) + } +} + +func TestCreateStoreFailureRollsBackRow(t *testing.T) { + h := newHarness(t) + h.stores.initErr = errors.New("disk full") + caller := testCaller(5, "carol") + + rec := h.do("POST", "/create", caller, url.Values{"name": {"gooddb"}, "visibility": {"PUBLIC"}}) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("create with store failure: got %d, want 500", rec.Code) + } + if _, err := h.store.GetRepoByOwnerAndName(context.Background(), "carol", "gooddb"); !errors.Is(err, db.ErrNotFound) { + t.Fatalf("orphan repo row survived store failure: %v", err) + } + if len(h.store.deletedRepos) != 1 { + t.Fatalf("row not rolled back: %v", h.store.deletedRepos) + } +} + +func TestCreateCSRFRejected(t *testing.T) { + h := newHarness(t) + caller := testCaller(5, "carol") + req := httptest.NewRequest("POST", "/create", strings.NewReader(url.Values{"name": {"x"}, "visibility": {"PUBLIC"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "https://evil.example") + req = req.WithContext(authn.WithCaller(req.Context(), caller)) + rec := httptest.NewRecorder() + h.router.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("cross-origin create: got %d, want 403", rec.Code) + } +} + +func TestSettingsOwnerGate(t *testing.T) { + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 10, OwnerName: "owner", Path: "/d", Visibility: core.VisibilityPublic}) + + // Anonymous → login redirect. + if rec := h.do("GET", "/~owner/db/settings", nil, nil); rec.Code != http.StatusSeeOther { + t.Fatalf("anon settings: got %d, want 303", rec.Code) + } + // Non-owner on a PUBLIC repo → 403. + if rec := h.do("GET", "/~owner/db/settings", testCaller(99, "intruder"), nil); rec.Code != http.StatusForbidden { + t.Fatalf("non-owner settings: got %d, want 403", rec.Code) + } + // Owner → 200. + if rec := h.do("GET", "/~owner/db/settings", testCaller(10, "owner"), nil); rec.Code != http.StatusOK { + t.Fatalf("owner settings: got %d, want 200", rec.Code) + } +} + +func TestSettingsNonOwnerPrivateIs404(t *testing.T) { + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 10, OwnerName: "owner", Path: "/d", Visibility: core.VisibilityPrivate}) + rec := h.do("GET", "/~owner/db/settings", testCaller(99, "intruder"), nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("non-owner private settings: got %d, want 404", rec.Code) + } +} + +func TestSettingsUpdateAndDelete(t *testing.T) { + h := newHarness(t) + repo := h.store.add(&core.Repo{Name: "db", OwnerID: 10, OwnerName: "owner", Path: "/var/lib/dolt/~owner/db", Visibility: core.VisibilityPublic}) + owner := testCaller(10, "owner") + + upd := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"update"}, "description": {"new desc"}, "visibility": {"PRIVATE"}}) + if upd.Code != http.StatusOK { + t.Fatalf("update: got %d", upd.Code) + } + if repo.Description != "new desc" || repo.Visibility != core.VisibilityPrivate { + t.Fatalf("update not applied: %+v", repo) + } + + // Delete requires a matching name confirmation. + badDel := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"delete"}, "confirm_name": {"wrong"}}) + if badDel.Code != http.StatusBadRequest { + t.Fatalf("delete wrong confirm: got %d, want 400", badDel.Code) + } + del := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"delete"}, "confirm_name": {"db"}}) + if del.Code != http.StatusSeeOther { + t.Fatalf("delete: got %d, want 303", del.Code) + } + if len(h.stores.deleteCalls) != 1 || len(h.stores.evictCalls) != 1 { + t.Fatalf("store delete/evict not called: del=%v evict=%v", h.stores.deleteCalls, h.stores.evictCalls) + } +} + +func TestSettingsACLAddRemove(t *testing.T) { + h := newHarness(t) + repo := h.store.add(&core.Repo{Name: "db", OwnerID: 10, OwnerName: "owner", Path: "/d", Visibility: core.VisibilityPublic}) + h.users.byName["dave"] = &core.Caller{UserID: 42, Username: "dave"} + owner := testCaller(10, "owner") + + add := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"acl_add"}, "username": {"dave"}, "mode": {"RW"}}) + if add.Code != http.StatusOK { + t.Fatalf("acl add: got %d; body=%s", add.Code, add.Body.String()) + } + if !h.store.hasACL(repo.ID, 42) { + t.Fatalf("acl not added") + } + // Unknown user rejected. + if bad := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"acl_add"}, "username": {"ghost"}, "mode": {"RO"}}); bad.Code != http.StatusBadRequest { + t.Fatalf("acl add unknown user: got %d, want 400", bad.Code) + } + + rm := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"acl_remove"}, "user_id": {"42"}}) + if rm.Code != http.StatusOK { + t.Fatalf("acl remove: got %d", rm.Code) + } + if h.store.hasACL(repo.ID, 42) { + t.Fatalf("acl not removed") + } +} + +func TestKeysAddDeleteAndFragmentPage(t *testing.T) { + h := newHarness(t) + caller := testCaller(11, "keyuser") + + // The page renders and contains the hash-fragment JS. + page := h.do("GET", "/settings/keys", caller, nil) + if page.Code != http.StatusOK { + t.Fatalf("keys page: got %d", page.Code) + } + if !strings.Contains(page.Body.String(), "window.location.hash") { + t.Fatalf("keys page missing hash-fragment JS") + } + + // Add a key using a valid dolt base32 public key. + pub := validDoltPubKeyStr(t) + add := h.do("POST", "/settings/keys", caller, url.Values{"pubkey": {pub}, "comment": {"laptop"}}) + if add.Code != http.StatusOK { + t.Fatalf("key add: got %d; body=%s", add.Code, add.Body.String()) + } + keys, _ := h.store.ListKeysByUser(context.Background(), 11) + if len(keys) != 1 { + t.Fatalf("key not stored: %d", len(keys)) + } + + // Invalid key rejected. + if bad := h.do("POST", "/settings/keys", caller, url.Values{"pubkey": {"not-base32-!!"}}); bad.Code != http.StatusBadRequest { + t.Fatalf("invalid key: got %d, want 400", bad.Code) + } + + // Delete. + del := h.do("POST", "/settings/keys", caller, url.Values{"delete_id": {fmt.Sprint(keys[0].ID)}}) + if del.Code != http.StatusOK { + t.Fatalf("key delete: got %d", del.Code) + } + if ks, _ := h.store.ListKeysByUser(context.Background(), 11); len(ks) != 0 { + t.Fatalf("key not deleted") + } +} + +func TestNavRendersNetworkAndActive(t *testing.T) { + h := newHarness(t) + rec := h.do("GET", "/", testCaller(1, "someone"), nil) + body := rec.Body.String() + // git.sr.ht and todo.sr.ht are network entries; paste is excluded. + if !strings.Contains(body, "https://git.example") || !strings.Contains(body, "https://todo.example") { + t.Fatalf("nav missing network entries; body=%s", body) + } + if strings.Contains(body, "https://paste.example") { + t.Fatalf("nav included excluded paste.sr.ht") + } + // Our own service is active. + if !strings.Contains(body, `nav-item active`) { + t.Fatalf("nav missing active class for self") + } +} + +func TestLogAndTablePages(t *testing.T) { + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) + h.browse.sess = &fakeSession{ + branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, + commits: []browse.CommitInfo{ + {Hash: "abcdef1234567890", Author: "alice", Message: "init", Date: time.Now().Add(-2 * time.Hour)}, + }, + rows: &browse.RowPage{Columns: []string{"id", "name"}, Rows: [][]string{{"1", "x"}}, Total: 1}, + } + + logRec := h.do("GET", "/~alice/db/log", nil, nil) + if logRec.Code != http.StatusOK { + t.Fatalf("log page: got %d", logRec.Code) + } + if !strings.Contains(logRec.Body.String(), "abcdef12") || !strings.Contains(logRec.Body.String(), "hours ago") { + t.Fatalf("log page missing short hash / reltime; body=%s", logRec.Body.String()) + } + + tblRec := h.do("GET", "/~alice/db/table/main/things", nil, nil) + if tblRec.Code != http.StatusOK { + t.Fatalf("table page: got %d", tblRec.Code) + } + // html/template must escape the cell content. + if strings.Contains(tblRec.Body.String(), "x") { + t.Fatalf("table cell not HTML-escaped") + } + if !strings.Contains(tblRec.Body.String(), "<b>x</b>") { + t.Fatalf("table cell escaping wrong; body=%s", tblRec.Body.String()) + } +}