package web import ( "fmt" "net/http" "github.com/go-chi/chi/v5" "sourcecraft.dev/bigbes/sr-ht-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 // 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 } // 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") } templates, err := loadTemplates() if err != nil { return nil, err } return &app{ cfg: cfg, templates: templates, styleHref: discoverStyleHref(cfg.StaticDir), // 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 { 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 }