package web
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
"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"
// 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
templates templateSet
// 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
// 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)")
}
templates, err := loadTemplates()
if err != nil {
return nil, err
}
// 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 = discoverStyleHref(cfg.StaticDir)
return &app{
cfg: cfg,
templates: templates,
chrome: chromeSvc,
// 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) {
r.Get("/", a.handleIndex)
r.Get("/create", a.handleCreateForm)
r.Post("/create", a.handleCreate)
// Service-to-service companion provisioning (git.sr.ht post-update hook).
// Guarded by internal-network + network-key auth, not the cookie/CSRF the
// browser routes use.
r.With(internalAuthGuard).Post("/internal/repos", a.handleInternalCreate)
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)
r.Handle("/static/*", httpStaticHandler(a.cfg.StaticDir))
}
// --- 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 {
chrome.Page
}{Page: a.page(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 {
chrome.Page
Message string
}{Page: a.page(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.page(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
}