// Package web is the HTTP layer of dolt.sr.ht: the chi router, request
// handlers, and the html/template views for the database dashboard, browse
// pages, settings and dolt-key management.
//
// # The chrome is not ours
//
// The brand, the service switcher, the login block and the environment banner
// come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome, the one copy every
// custom service on this instance draws from. This package builds a single
// chrome.Service at startup (newApp), asks it for a chrome.Page per request
// (app.page), and embeds that Page in each handler's view struct so the shared
// partials find their fields on the dot they are handed. Nothing here rebuilds
// the switcher, re-derives a login URL or re-reads our own origin: the copies
// that used to live in web/chrome.go are what ecore exists to have deleted.
//
// # 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"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
"sourcecraft.dev/bigbes/sr-ht-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
// Git resolves the description of the owner's same-named git.sr.ht
// repository, so companion databases mirror it (see handleInternalCreate).
// nil disables mirroring entirely (tests, instances without git.sr.ht).
Git GitDescriber
// 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.
// That empty repo is an "Initialize data repository" commit, so it is the
// opt-in half of creation: see InitEmptyStore for why it is not the default.
InitStore(ctx context.Context, absPath, ownerName, ownerEmail string) error
// InitEmptyStore creates a bare store at absPath with NO commits and NO
// branches, so a client's first push lands as the initial history instead of
// being rejected as a non-fast-forward. dolt decides fast-forward on the
// client (actions.CanFastForward over the remotesapi), so an initial commit
// on our side cannot be forgiven by the server — it can only be not written.
// The cost is that a store with no commits cannot be dolt-cloned at all
// ("remote at that url contains no Dolt data"), which is why the empty
// overview page teaches push rather than clone. On any failure it must leave
// no partial store.
InitEmptyStore(ctx context.Context, absPath string) error
// DeleteStore removes the store at absPath, refusing anything outside root.
DeleteStore(ctx context.Context, root, absPath string) error
// MoveStore relocates the store at srcPath to dstPath — the on-disk half of
// a rename — refusing anything outside root and never overwriting an
// existing destination.
MoveStore(ctx context.Context, root, srcPath, dstPath 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)
// ListReposForViewer lists every database viewer may be shown, across all
// owners. It is the enumeration the cross-database ready page is built on:
// ListReposByOwner asks the same listing question about one owner, and
// ListReposForDashboard omits every PUBLIC database belonging to somebody
// else. Listing is not authorization — /ready still asks core.Allowed per
// database before it opens anything.
ListReposForViewer(ctx context.Context, 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
// RenameRepo moves the row to a new name and on-disk path together; the
// store on disk is moved separately by StoreManager.MoveStore.
RenameRepo(ctx context.Context, id int, name, path string) 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)
// TableHash is the content hash of one table at a ref, ok=false when the
// table does not exist there. It reads no rows, and only the Memory view
// asks for it: its revision walk uses the hash to skip every commit that did
// not touch config, so a board never pays for a history walk it does not do.
TableHash(ctx context.Context, refStr, table string) (string, bool, 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)
}
// GitDescriber looks up the description of owner's same-named repository on
// git.sr.ht. ok=false means it could not be resolved — no git twin, git.sr.ht
// unreachable — and the caller must leave the stored description alone;
// ("", true) means the twin exists and has no description.
type GitDescriber interface {
Description(ctx context.Context, owner, name string) (desc string, ok bool)
}
// 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)
}