// 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 /~/. Passed to StoreManager.DeleteStore // as the containment root. ReposRoot string // StaticDir is the directory holding built static assets (the hashed // main.min..css and logo.svg). The CSS filename is discovered from it at // Register time; "" falls back to the dev stylesheet /static/main.css. StaticDir string // Stores manages the on-disk NBS chunk stores. Satisfied in production by a // storage-backed adapter (Phase 3); web never imports storage/. Stores StoreManager // Repos is the metadata store (repositories, ACLs, dolt keys). Satisfied in // production by dbAdapter over db.Store; fakes are used in tests. Repos RepoStore // Browse opens read-only handles to bare stores for the browse pages. // Satisfied in production by browseAdapter over browse.Open. Browse BrowseOpener // Users resolves a SourceHut username to its account (for ACL add-by-username), // mirroring the meta profile on first sight. Satisfied in production by a // core-go auth.LookupUser adapter. Users UserResolver // 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 // 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 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) }