// 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= 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. // // # 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..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$`) // 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 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), 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 }