package web
import (
"fmt"
"io/fs"
"net/http"
"os"
"github.com/go-chi/chi/v5"
"sourcecraft.dev/bigbes/sr-ht-ecore/assets"
"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
"sourcecraft.dev/bigbes/sr-ht-ecore/csrf"
"sourcecraft.dev/bigbes/sr-ht-ecore/internalauth"
"sourcecraft.dev/bigbes/sr-ht-ecore/middleware"
"sourcecraft.dev/bigbes/sr-ht-ecore/pages"
"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
// serviceName is our own service key: the config section, the JWT audience and
// the entry the shared switcher has to recognise as the current service. One
// constant, because a service that spelled its section differently in two
// places would appear in the instance's navigation and fail to find itself in
// it.
const serviceName = "dolt.sr.ht"
// The two names the static tree is searched for at startup: the hashed artefact
// `make css` produces, and the unhashed stylesheet `make static/main.css` leaves
// in a working copy.
const (
hashedStyleGlob = "main.min.*.css"
devStyleFile = "main.css"
)
// app bundles the parsed templates, the shared chrome and the injected config.
// Handlers are methods on *app so they share this state without a global.
type app struct {
cfg Config
// pages is sr-ht-ecore's page-template machinery: one parsed set per page in
// templates/, plus the shared error page.
pages pages.Set
// chrome is sr-ht-ecore's shared page frame: the brand, the service
// switcher, the login block and the environment banner, built once from the
// instance config and asked for a per-request chrome.Page (see page below).
chrome *chrome.Service
// static is the built asset tree, mounted under /static/ by ecore's assets
// handler and globbed once at startup for the hashed stylesheet.
static fs.FS
// views is a snapshot of the global registeredViews taken at Register time.
// Handlers read this (never the global) so tests can inject their own set.
views []View
}
// page builds the chrome for one request: the shared frame plus the per-page
//
. The caller sets any page-specific fields on its own view struct,
// which embeds the returned chrome.Page.
//
// The username handed over is the resolved caller's and not whatever the cookie
// said — an unreadable or expired cookie has already become anonymity by the
// time a handler runs — so the nav and the page content cannot disagree about
// who is looking.
func (a *app) page(r *http.Request, title string) chrome.Page {
var username string
if ac := authn.CallerFromContext(r.Context()); ac != nil {
username = ac.Username
}
return a.chrome.Page(r, title, username)
}
// 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 {
a, err := newApp(cfg)
if err != nil {
return err
}
a.mount(r)
return nil
}
// newApp validates cfg, parses templates and snapshots the view registry into a
// ready *app. Register uses it; tests build an *app directly so they can inspect
// and override its fields (e.g. app.views) before mounting.
func newApp(cfg Config) (*app, error) {
if cfg.Repos == nil || cfg.Stores == nil || cfg.Browse == nil ||
cfg.Users == nil || cfg.RepoDiskPath == nil {
return nil, fmt.Errorf("web: Register requires Repos, Stores, Browse, Users and RepoDiskPath")
}
if cfg.Conf == nil {
return nil, fmt.Errorf("web: Register requires Conf (the chrome and the origins are built from it)")
}
set, err := loadPages()
if err != nil {
return nil, err
}
// The static tree ships beside the binary rather than inside it (the Makefile
// installs it into $SHAREDIR), so the asset FS is an os.DirFS; assets takes
// an fs.FS precisely so both shapes work.
static := staticFS(cfg.StaticDir)
styleHref, err := assets.Resolve(static, hashedStyleGlob, assets.DefaultPrefix)
if err != nil {
return nil, fmt.Errorf("web: resolve the stylesheet: %w", err)
}
if styleHref == "" {
// The unhashed stylesheet of a working copy, linked only when it is
// really there: an href to a file this deployment does not ship would
// 404 once per page load, which is what an empty Resolve avoids. An
// empty href is guarded by the layout.
if _, err := fs.Stat(static, devStyleFile); err == nil {
styleHref = assets.NormalizePrefix(assets.DefaultPrefix) + devStyleFile
}
}
// The switcher, the brand and the login links come from the shared config
// read once here; the stylesheet is discovered separately because its name
// carries a build hash, which no config file can know.
chromeSvc := chrome.NewService(cfg.Conf, serviceName)
chromeSvc.StyleHref = styleHref
return &app{
cfg: cfg,
pages: set,
chrome: chromeSvc,
static: static,
// Snapshot the registry so all handlers see a stable set and tests can
// override it per-app without mutating the global.
views: append([]View{}, registeredViews...),
}, nil
}
// mount installs every dolt.sr.ht web route onto r. Split from Register so tests
// can mount an *app they retain a handle to.
func (a *app) mount(r chi.Router) {
// Every page here is rendered for one viewer behind meta's unified-login
// cookie at a URL that says nothing about who that is, so nothing this
// service answers may be reused for the next viewer. The static handler
// overrides this per asset, once it has found the file.
r.Use(middleware.PrivateCache)
// A panic is a bug like any other and is owed the same page. One that
// arrives after the response has started aborts the connection instead,
// because there is no status line left to send and half a page with an
// error appended to it is neither document.
r.Use(middleware.RecoverPanics(func(w http.ResponseWriter, r *http.Request, _ any) {
a.fail(w, r, http.StatusInternalServerError, "")
}))
// Service-to-service companion provisioning (git.sr.ht post-update hook).
// Guarded by internal-network + network-key auth, and deliberately mounted
// outside the same-origin group below: it is not a browser route, it carries
// no Origin or Referer, and the CSRF guard would refuse every call.
//
// The caller is pinned rather than left open. core-go's own check only asks
// that a token name *some* client and node, which on this endpoint would
// mean any program holding the instance's network key may provision a
// database for any user — and exactly one program on the instance has any
// business doing that, cmd/dolt-git-hook, whose ids these are. Widening this
// to a second caller should be a line changed here, not something that
// starts working by accident.
//
// The refusal is internalauth's own plain-text Deny (the nil handler): this
// endpoint answers a hook and not a browser, so the error page the rest of
// web/ renders would only be something for a Go client to discard.
r.With(internalauth.Guard(core.InternalClientID, core.InternalNodeID, nil)).
Post("/internal/repos", a.handleInternalCreate)
// Everything a browser reaches. The same-origin guard is the group's, not
// each mutating handler's: a predicate spelled per handler is protection
// somebody has to remember, and the form added next year is the one that
// goes out unguarded.
r.Group(func(r chi.Router) {
r.Use(csrf.Require(a.chrome.SelfOrigin(), a.denyCSRF))
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}/view/{view}", a.handleView)
r.Get("/~{user}/{db}/settings", a.handleSettings)
r.Post("/~{user}/{db}/settings", a.handleSettingsPost)
// The static tree, with the cache policy the hashed names imply and no
// directory listing — a listing of /static/ would publish this build's
// stylesheet hash, which nothing else on the surface discloses. An asset
// URL typed by hand lands on our own 404 rather than net/http's plain
// text.
r.Handle(assets.DefaultPrefix+"*",
assets.Handler(a.static, assets.DefaultPrefix, http.HandlerFunc(a.notFound)))
})
}
// denyCSRF renders the refusal of a mutation that cannot show it came from this
// site. It is a 403 and never a redirect: a redirect after a POST drops the
// body and would make a refused mutation look like one that worked.
func (a *app) denyCSRF(w http.ResponseWriter, r *http.Request) {
a.fail(w, r, http.StatusForbidden, csrf.Message)
}
// staticFS is the asset tree for a configured static directory.
//
// An unconfigured one yields an FS with nothing in it rather than os.DirFS(""),
// which resolves every name against the filesystem root: that is not "this
// build ships no assets" but "this build serves all of them".
func staticFS(dir string) fs.FS {
if dir == "" {
return emptyFS{}
}
return os.DirFS(dir)
}
// emptyFS is an fs.FS in which nothing exists.
type emptyFS struct{}
func (emptyFS) Open(name string) (fs.File, error) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
// --- shared response helpers -------------------------------------------------
// errorView is the dot of the shared error page: the chrome, and the payload in
// the .Data field the page reads.
type errorView struct {
chrome.Page
Data pages.ErrorData
}
// fail renders the shared error page. An empty message takes the standard
// sentence for the status, which is one of the reasons the page is shared: the
// visibility rules here require "somebody else's private database" and "no such
// database" to be indistinguishable, and two 404s whose prose differed would
// rebuild the distinction the status code was chosen to erase.
func (a *app) fail(w http.ResponseWriter, r *http.Request, status int, msg string) {
view := errorView{
Page: a.page(r, http.StatusText(status)+" — "+serviceName),
Data: pages.Error(status, msg).BackTo("/", "Return to the dashboard"),
}
a.render(w, status, pages.ErrorPage, view)
}
// 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) {
a.fail(w, r, http.StatusNotFound, "")
}
// forbidden renders the 403 page for a denied but non-hidden request.
func (a *app) forbidden(w http.ResponseWriter, r *http.Request, msg string) {
a.fail(w, r, http.StatusForbidden, msg)
}
// redirectLogin sends an unauthenticated caller to meta's login, returning them
// to the current URL afterwards.
//
// LoginURLFor rather than a whole Page for one field off it: building the page
// resolves the nav, the profile link and the brand for a response that is a
// Location header and nothing else.
func (a *app) redirectLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, a.chrome.LoginURLFor(r), 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
}