// Command comparesrht is the compare.sr.ht daemon: a stateless HTTP service
// that renders git ref-to-ref diffs and single-commit views for a self-hosted
// SourceHut instance.
//
// It reuses core-go's server.New assembly so it shares the standard SourceHut
// daemon lifecycle (crypto initialization, -b/-m/-p flags, warm shutdown on
// SIGINT) with the rest of the fleet, but it deliberately does NOT call
// WithDefaultMiddleware: compare.sr.ht owns no Postgres or Redis and must serve
// anonymous viewers, so it installs its own lightweight middleware group on the
// anonymous router instead (see the web package documentation for the exact
// chain).
//
// Flags (parsed by core-go's server.New):
//
// -b addr bind address (repeatable); default 127.0.0.1:5090
// -d debug (verbose request logging in core-go)
// -m addr Prometheus metrics bind (default random port)
// -p addr pprof bind (default random localhost port)
//
// Configuration is loaded from the shared SourceHut config.ini via core-go's
// fixed search path (./config.ini, ../config.ini, /etc/sr.ht/config.ini,
// /etc/sr.ht/*.ini). All required keys are validated up front with clear
// messages so a misconfiguration fails loudly at startup rather than as a deep
// panic on the first request.
package main
import (
"fmt"
"log/slog"
"os"
"slices"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/vaughan0/go-ini"
"go.bigb.es/auxilia/scribe"
"sourcecraft.dev/bigbes/sr-ht-core/config"
coreserver "sourcecraft.dev/bigbes/sr-ht-core/server"
"sourcecraft.dev/bigbes/sr-ht-compare/authz"
"sourcecraft.dev/bigbes/sr-ht-compare/web"
)
const (
service = "compare.sr.ht"
defaultBind = "127.0.0.1:5090"
// authzTTL is how long the GraphQL authorizer memoizes positive and
// not-found repository lookups, sparing git.sr.ht a round trip per page.
authzTTL = 60 * time.Second
)
// initLogging installs the instance's log handler as slog's default.
//
// Setting the *default* is the load-bearing part, not the formatting: the
// packages that log below this one hold no logger of their own — the web tier
// calls slog's package functions, and so does ecore's panic-recovery
// middleware, whose report (method, path, panic, stack) would otherwise go to
// Go's plain stderr handler with the stack as one unreadable field.
//
// -d is read straight out of the argument vector because core-go's server.New
// owns the real flag parse and does not run until after this: a daemon that
// only became verbose once it had finished starting would be silent for exactly
// the part of its life an operator passes -d to watch.
func initLogging(debug bool) {
level := slog.LevelInfo
if debug {
level = slog.LevelDebug
}
slog.SetDefault(slog.New(scribe.NewTintHandler(
scribe.WithWriter(os.Stderr),
scribe.WithLevel(level),
scribe.WithSource(true),
scribe.WithTimeFormat(time.DateTime),
// Colour is for a terminal; under systemd stderr is the journal, where
// the escapes are noise in every stored record. Asked of the stdlib
// rather than of golang.org/x/term, which would be a whole new
// dependency for one predicate.
scribe.WithNoColor(!isTerminal(os.Stderr)),
// This daemon logs no credential deliberately, which is precisely why
// the masks are here: the one that leaks is the attribute somebody adds
// later, and a request or a cookie is the likeliest thing to be handed
// to a log line while debugging the very cookie path this service reads.
scribe.WithMaskKeys("token", "cookie", "authorization"),
scribe.WithMask(`(?i)(secret|token|api_?key|password)`, "***"),
)))
}
// isTerminal reports whether f is a character device — a tty rather than the
// pipe systemd, a build runner or a shell redirect hands the process.
func isTerminal(f *os.File) bool {
info, err := f.Stat()
return err == nil && info.Mode()&os.ModeCharDevice != 0
}
func main() {
initLogging(slices.Contains(os.Args[1:], "-d"))
// LoadConfig never panics on a missing file (it returns a nil ini.File);
// validateConfig turns any absent required key into a single clear fatal.
conf := config.LoadConfig()
apiOrigin := validateConfig(conf)
// server.New parses -b/-d/-m/-p from the argument vector and runs
// crypto.InitCrypto(conf) — the network-key and webhook key validated above
// are exactly what it needs, so this cannot fatal after validateConfig.
// It expects the full os.Args (core-go's getopt skips argv[0] as the
// program name, exactly as every upstream SourceHut daemon calls it).
srv := coreserver.New(service, defaultBind, conf, os.Args)
authorizer := authz.NewAuthorizer(authzTTL)
app, err := web.New(conf, authorizer)
if err != nil {
// slog has no Fatal, and the explicit exit is the better shape anyway:
// the line above is a log record like any other, and the decision to
// stop is visible on its own line rather than hidden in a logger call.
slog.Error("initialize the web server", scribe.Err(err))
os.Exit(1)
}
// Middleware chain per the web package contract (outermost first). This is
// the hand-rolled substitute for WithDefaultMiddleware: no database, no
// redis, and authz.Middleware never issues a 401 so anonymous browsing
// works. config.Middleware must be present because the GraphQL authorizer
// resolves git.sr.ht's API origin from config.ForContext at request time.
//
// server.New already froze the anonymous router for direct middleware
// registration (it built inline sub-routers during construction), so the
// group + middleware + routes are installed together inside a Group, which
// chi permits on a fresh inline mux sharing the same routing tree.
//
// Register installs three more of its own inside a nested group: the
// private cache policy, panic recovery through the service's error page,
// and the same-origin guard. chi's Recoverer stays here as the outer net
// for a panic in the two middlewares above, which are outside that group —
// it re-panics http.ErrAbortHandler, which is what the inner one raises for
// a panic arriving after the response has already started.
srv.AnonRouter().Group(func(r chi.Router) {
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(middleware.Logger)
r.Use(config.Middleware(conf, service))
r.Use(authz.Middleware())
app.Register(r)
})
reposRoot, _ := conf.Get("git.sr.ht", "repos")
slog.Info("compare.sr.ht starting",
"bind", resolveBind(os.Args[1:]),
"repos", reposRoot,
"git.sr.ht-api", apiOrigin,
)
// Run blocks until SIGINT, then performs a warm shutdown. systemd should
// stop this unit with KillSignal=SIGINT (see contrib/compare-srht.service).
srv.Run()
}
// validateConfig verifies every configuration key compare.sr.ht needs before it
// can serve or authorize a request. It reports all missing keys at once, in a
// single record followed by a single exit, so operators fix the config in one
// pass instead of discovering each gap on a separate restart. It returns the
// git.sr.ht internal API origin that GraphQL authorization will use (also
// logged at startup).
//
// This runs BEFORE anything can reach config.GetAPI, which panics when no
// origin candidate is configured, and before crypto.InitCrypto (invoked by
// server.New), which would otherwise fatal with a terse message on a missing
// network-key or webhook key.
func validateConfig(conf ini.File) string {
var missing []string
require := func(section, key string) {
if v, ok := conf.Get(section, key); !ok || strings.TrimSpace(v) == "" {
missing = append(missing, fmt.Sprintf("[%s] %s", section, key))
}
}
require("sr.ht", "network-key") // crypto: unified-login cookie fernet key
require("webhooks", "private-key") // crypto: webhook signing key (shared)
require("git.sr.ht", "repos") // bare repository root on disk
require("meta.sr.ht", "origin") // login/logout links in the nav
require("compare.sr.ht", "origin") // our own external origin
// git.sr.ht needs at least one internal API origin candidate; without it
// config.GetAPI panics on the first authorization request.
apiOrigin := firstConfigured(conf, "git.sr.ht",
"api-internal-origin", "internal-origin", "api-origin", "origin")
if apiOrigin == "" {
missing = append(missing,
"[git.sr.ht] one of api-internal-origin, internal-origin, api-origin, origin")
}
if len(missing) > 0 {
// One record and one exit, deliberately: an operator fixing a config
// wants every gap in front of them at once, and a line per missing key
// is a restart per missing key. The keys go in as a slice attribute
// rather than a joined string so the structured sinks keep them as a
// list — the tint handler still renders them on one line.
slog.Error("incomplete configuration", "missing", missing)
os.Exit(1)
}
return apiOrigin
}
// firstConfigured returns the value of the first present, non-empty key in
// section, or "" if none are set.
func firstConfigured(conf ini.File, section string, keys ...string) string {
for _, k := range keys {
if v, ok := conf.Get(section, k); ok && strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// resolveBind reconstructs the primary bind address server.New will use, for
// logging only. server.New owns the authoritative parse; this mirrors its -b
// handling (last -b wins here; the real server binds every -b given) and falls
// back to the same default.
func resolveBind(args []string) string {
bind := defaultBind
for i := 0; i < len(args); i++ {
switch a := args[i]; {
case a == "-b" && i+1 < len(args):
bind = args[i+1]
i++
case strings.HasPrefix(a, "-b") && len(a) > 2:
bind = a[2:]
}
}
return bind
}