// Package web is spec.sr.ht's read plane in a browser: the SourceHut chrome
// ported to Go html/templates, a space's document tree, a rendered document
// with its metadata and backlinks, and keyword search — all served from one chi
// router the daemon mounts.
//
// It is compare.sr.ht's web/ package with the diff views replaced by document
// views: the nav/service-switcher, login block, environment banner, error page,
// embedded-static pattern and hashed-asset resolution are the same code shaped
// the same way, because "no upstream SourceHut modification" means every
// service reimplements that chrome and two of them already agreed on how.
//
// # URL grammar
//
// The design pins this, so it is spelled out here rather than left to the
// router: a document's address carries no extension. The extension is a format
// selector and never part of the document's identity.
//
// /~user/space/specs/0007-storage rendered HTML
// /~user/space/specs/0007-storage.md raw source (frontmatter + body)
// /~user/space/specs/0007-storage.json metadata + body
// …?rev=<sha> any of the three, pinned
//
// An absent ?rev= means the approved head — service.ApprovedRev — because
// serving drafts by default would poison every downstream agent context with
// unreviewed text.
//
// Two routes sit outside the space grammar: /inbox is the review queue, and
// /tokens redirects to tokens.sr.ht, which issues every agent credential on the
// instance. /tokens was spec's own mint-list-revoke page until that credential
// stopped being spec's to mint.
//
// # Who may read
//
// The instance has one human. There are no visibility levels, so the read ACL
// is one line: the owner and its agents may read, and everyone else may not.
// An anonymous browser asking for a page is redirected to meta.sr.ht's login
// (there is no login flow of our own); an anonymous client asking for .md or
// .json gets a 401, because redirecting a bot to an HTML login page tells it
// nothing.
//
// # What the cmd layer must wire
//
// [Server.Handler] returns a router with everything this package needs already
// installed, so the daemon can mount it at "/". A caller that owns its own
// router and middleware stack uses [Server.Register] instead; it installs
// routes only, and assumes authn.Resolver.Middleware is already applied.
//
// # Assets are embedded
//
// static/ is compiled into the binary by //go:embed. `make css` rewrites
// web/static/main.min.<hash>.css on disk and a *running* daemon will not notice
// — the CSS is baked in at `go build` time, so the build order is css then
// build then restart. A binary built with no stylesheet present logs a loud
// warning at startup and renders unstyled rather than refusing to start:
// missing CSS degrades presentation, not correctness.
package web
import (
"fmt"
"io/fs"
"log"
"net/http"
"path"
"regexp"
"github.com/vaughan0/go-ini"
"sourcecraft.dev/bigbes/sr-ht-core/config"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/doc"
)
// hashedCSSRe matches the content-addressed stylesheet name so it can be served
// with an immutable cache lifetime (the hash changes whenever the bytes do).
var hashedCSSRe = regexp.MustCompile(`^main\.min\.[0-9a-f]{6,}\.css$`)
// tokensSection is tokens.sr.ht's config section, spelled the way the instance's
// config.ini spells it. service.TokensSection is the same string read for the
// internal origin; this package cannot import service/ (the dependency arrow
// runs the other way), so the constant is here rather than shared.
const tokensSection = "tokens.sr.ht"
// Options is everything a Server needs. Every field is required; New says which
// one is missing rather than failing later inside a handler.
type Options struct {
// Conf is the shared SourceHut config.ini. The nav switcher, the site name,
// the environment banner and the meta.sr.ht login origin all come from it,
// and it is read once at startup rather than per request.
Conf ini.File
// Reader is the read surface over spaces and documents. NewReader adapts a
// *service.Service to it.
Reader Reader
// Searcher is the keyword index. *search.Index satisfies it directly.
Searcher Searcher
// Resolver turns the unified-login cookie or an agent bearer token into a
// principal. Handler installs its middleware; Register does not.
Resolver *authn.Resolver
}
// Server holds the immutable configuration a request handler needs. It is built
// once at startup and is safe for concurrent use.
type Server struct {
reader Reader
searcher Searcher
resolver *authn.Resolver
renderer *doc.Renderer
siteName string
environment string
metaOrigin string
origin string
hubOrigin string
cssHref string
// tokensOrigin is [tokens.sr.ht] origin in its *external* form. The only
// thing this package does with it is redirect a browser there, and a browser
// cannot reach the internal origin the bearer validator uses. Empty when the
// instance config has no such section, which handleTokens answers rather
// than papers over with a redirect to nowhere.
tokensOrigin string
nav []navItem
staticFileServer http.Handler
}
// New assembles a Server from the shared SourceHut config.
//
// [spec.sr.ht] origin and [meta.sr.ht] origin are required: without the first
// there is no return_to to hand meta, and without the second there is no login
// at all. A missing key is a clear error rather than a panic, so the daemon can
// fail startup loudly.
func New(opts Options) (*Server, error) {
if opts.Conf == nil {
return nil, fmt.Errorf("web: config is required")
}
if opts.Reader == nil {
return nil, fmt.Errorf("web: Reader is required")
}
if opts.Searcher == nil {
return nil, fmt.Errorf("web: Searcher is required")
}
if opts.Resolver == nil {
return nil, fmt.Errorf("web: authn Resolver is required")
}
origin := config.GetOrigin(opts.Conf, authn.ConfigSection, true)
if origin == "" {
return nil, fmt.Errorf("web: [%s] origin is required", authn.ConfigSection)
}
metaOrigin := config.GetOrigin(opts.Conf, "meta.sr.ht", true)
if metaOrigin == "" {
return nil, fmt.Errorf("web: [meta.sr.ht] origin is required")
}
cssHref, err := resolveCSSHref()
if err != nil {
return nil, err
}
if cssHref == "" {
log.Printf("web: no main.min.*.css embedded in this binary — pages will " +
"render unstyled; run `make css` before `go build`")
}
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
return nil, fmt.Errorf("web: sub static FS: %w", err)
}
return &Server{
reader: opts.Reader,
searcher: opts.Searcher,
resolver: opts.Resolver,
renderer: doc.NewRenderer(),
siteName: config.GetString(opts.Conf, "sr.ht", "site-name", "sourcehut"),
environment: config.GetString(opts.Conf, "sr.ht", "environment", "production"),
metaOrigin: metaOrigin,
origin: origin,
hubOrigin: config.GetOrigin(opts.Conf, "hub.sr.ht", true),
tokensOrigin: config.GetOrigin(opts.Conf, tokensSection, true),
cssHref: cssHref,
nav: buildNav(opts.Conf),
staticFileServer: http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))),
}, nil
}
// resolveCSSHref globs the embedded static FS for the content-addressed
// stylesheet and returns its site-absolute URL, or "" when the binary was built
// without one.
//
// Absence is reported rather than substituted: there is no placeholder href to
// invent, and a link to a stylesheet that is not there would 404 on every page
// load instead of saying what is wrong once, at startup.
func resolveCSSHref() (string, error) {
matches, err := fs.Glob(staticFS, "static/main.min.*.css")
if err != nil {
return "", fmt.Errorf("web: glob stylesheet: %w", err)
}
if len(matches) == 0 {
return "", nil
}
return "/static/" + path.Base(matches[0]), nil
}