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 }