M Makefile => Makefile +10 -9
@@ 19,11 19,9 @@ SASSC_INCLUDE=-I$(ASSETS)/scss
all: build
-# Compile the service. Intentionally tolerant: cmd/ does not exist until
-# phase 3, so this is a no-op until then.
+# Compile the service into ./comparesrht.
build:
- @if [ -d cmd ]; then go build ./cmd/...; else \
- echo "cmd/ not present yet (added in phase 3); nothing to build"; fi
+ go build -o $(BIN) ./cmd/$(BIN)
test:
go test ./...
@@ 51,13 49,16 @@ bundle:
npx esbuild src/app.ts --bundle --minify --format=esm \
--outfile=../web/static/bundle.js
-# Local development run against ./config.ini. Fails until cmd/ exists (phase 3).
-run-dev:
- go run ./cmd/$(BIN)
+# Local development run. Requires a ./config.ini in the working directory (or
+# ../config.ini, /etc/sr.ht/config.ini): copy config.example.ini and fill in the
+# instance's shared [sr.ht]/[webhooks] keys, plus a [git.sr.ht] repos root and an
+# api-origin pointing at a real (or stubbed, see contrib/dev-stub) GraphQL API.
+# See the "Development" section of README.md for the full recipe.
+run-dev: build
+ ./$(BIN) -b localhost:5090
install: build
- mkdir -p $(DESTDIR)$(BINDIR)
- go build -o $(DESTDIR)$(BINDIR)/$(BIN) ./cmd/$(BIN)
+ install -Dm755 $(BIN) $(DESTDIR)$(BINDIR)/$(BIN)
mkdir -p $(DESTDIR)$(STATICDIR)
install -Dm644 -t $(DESTDIR)$(STATICDIR) web/static/*
A cmd/comparesrht/main.go => cmd/comparesrht/main.go +172 -0
@@ 0,0 1,172 @@
+// 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"
+ "os"
+ "strings"
+ "time"
+
+ "git.sr.ht/~sircmpwn/core-go/config"
+ coreserver "git.sr.ht/~sircmpwn/core-go/server"
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+ "github.com/sirupsen/logrus"
+ "github.com/vaughan0/go-ini"
+
+ "go.bigb.es/sourcehut-compare/authz"
+ "go.bigb.es/sourcehut-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
+)
+
+func main() {
+ logrus.SetFormatter(&logrus.TextFormatter{FullTimestamp: true})
+
+ // 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 {
+ logrus.Fatalf("initialize web server: %v", err)
+ }
+
+ // 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.
+ 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")
+ logrus.WithFields(logrus.Fields{
+ "bind": resolveBind(os.Args[1:]),
+ "repos": reposRoot,
+ "git.sr.ht-api": apiOrigin,
+ }).Info("compare.sr.ht starting")
+
+ // 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 via a
+// single logrus.Fatal 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 {
+ logrus.Fatalf("incomplete configuration; missing required keys:\n\t%s",
+ strings.Join(missing, "\n\t"))
+ }
+ 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
+}
A cmd/comparesrht/main_test.go => cmd/comparesrht/main_test.go +148 -0
@@ 0,0 1,148 @@
+package main
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/fernet/fernet-go"
+)
+
+// TestStartupSmoke builds the daemon, runs it against a synthesized minimal
+// config.ini (fresh crypto keys, an empty repos root), waits for /healthz to
+// answer 200, then sends SIGINT and asserts a clean (exit code 0) warm
+// shutdown. It exercises the real startup path end to end: config validation,
+// crypto init via server.New, web.New (which globs the embedded hashed CSS),
+// the middleware chain, and core-go's signal-driven Run loop.
+//
+// server.New's Run installs its warm-shutdown handler on SIGINT (the SourceHut
+// fleet convention); the deployed systemd unit sets KillSignal=SIGINT so
+// `systemctl stop` triggers the same graceful path this test verifies.
+func TestStartupSmoke(t *testing.T) {
+ if testing.Short() {
+ t.Skip("smoke test builds a binary; skipped in -short mode")
+ }
+ if _, err := exec.LookPath("go"); err != nil {
+ t.Skipf("go toolchain not available: %v", err)
+ }
+
+ // Build the daemon from the current package directory.
+ binDir := t.TempDir()
+ bin := filepath.Join(binDir, "comparesrht")
+ build := exec.Command("go", "build", "-o", bin, ".")
+ if out, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("go build: %v\n%s", err, out)
+ }
+
+ // Synthesize a config.ini with valid crypto keys and an empty repos root.
+ var fk fernet.Key
+ if err := fk.Generate(); err != nil {
+ t.Fatalf("generate fernet key: %v", err)
+ }
+ seed := make([]byte, 32)
+ if _, err := rand.Read(seed); err != nil {
+ t.Fatalf("generate webhook seed: %v", err)
+ }
+
+ confDir := t.TempDir() // becomes the process cwd; LoadConfig finds config.ini here
+ reposDir := t.TempDir()
+ confBody := fmt.Sprintf(`[sr.ht]
+network-key=%s
+site-name=sourcehut
+environment=development
+
+[webhooks]
+private-key=%s
+
+[compare.sr.ht]
+origin=http://compare.example
+
+[meta.sr.ht]
+origin=http://meta.example
+
+[git.sr.ht]
+origin=http://git.example
+repos=%s
+`, fk.Encode(), base64.StdEncoding.EncodeToString(seed), reposDir)
+ if err := os.WriteFile(filepath.Join(confDir, "config.ini"), []byte(confBody), 0o644); err != nil {
+ t.Fatalf("write config.ini: %v", err)
+ }
+
+ addr := freeAddr(t)
+
+ cmd := exec.Command(bin, "-b", addr)
+ cmd.Dir = confDir // so config.LoadConfig picks up ./config.ini
+ cmd.Stdout = os.Stderr
+ cmd.Stderr = os.Stderr
+ if err := cmd.Start(); err != nil {
+ t.Fatalf("start daemon: %v", err)
+ }
+ // Guarantee the child is reaped even if an assertion below fails early.
+ defer func() {
+ if cmd.ProcessState == nil {
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ }
+ }()
+
+ healthz := "http://" + addr + "/healthz"
+ if !waitForOK(healthz, 10*time.Second) {
+ t.Fatalf("daemon did not become healthy at %s within timeout", healthz)
+ }
+
+ // Warm shutdown: core-go's Run listens for SIGINT.
+ if err := cmd.Process.Signal(syscall.SIGINT); err != nil {
+ t.Fatalf("signal daemon: %v", err)
+ }
+
+ done := make(chan error, 1)
+ go func() { done <- cmd.Wait() }()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("daemon exited uncleanly after SIGINT: %v", err)
+ }
+ case <-time.After(10 * time.Second):
+ _ = cmd.Process.Kill()
+ t.Fatal("daemon did not shut down within 10s of SIGINT")
+ }
+}
+
+// freeAddr reserves and immediately releases a loopback TCP port, returning its
+// host:port. The daemon binds with SO_REUSEPORT so the brief release window is
+// harmless.
+func freeAddr(t *testing.T) string {
+ t.Helper()
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("reserve port: %v", err)
+ }
+ addr := l.Addr().String()
+ _ = l.Close()
+ return addr
+}
+
+// waitForOK polls url until it returns HTTP 200 or the deadline elapses.
+func waitForOK(url string, timeout time.Duration) bool {
+ deadline := time.Now().Add(timeout)
+ client := &http.Client{Timeout: time.Second}
+ for time.Now().Before(deadline) {
+ resp, err := client.Get(url)
+ if err == nil {
+ _ = resp.Body.Close()
+ if resp.StatusCode == http.StatusOK {
+ return true
+ }
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ return false
+}
M contrib/compare-srht.service => contrib/compare-srht.service +7 -1
@@ 8,7 8,13 @@ Type=simple
# /var/lib/git. compare.sr.ht is stateless — no database or Redis dependency.
User=git
Restart=always
-ExecStart=/usr/local/bin/comparesrht
+ExecStart=/usr/local/bin/comparesrht -b 127.0.0.1:5090
+
+# core-go's server.Run performs its warm shutdown on SIGINT (the SourceHut
+# fleet convention), not the systemd default SIGTERM. Send SIGINT on stop so
+# `systemctl stop`/restart drains in-flight requests instead of hard-killing.
+KillSignal=SIGINT
+TimeoutStopSec=35
# The service only ever reads: bare repos on disk and the shared config. It
# writes nothing, so lock the filesystem down.
A contrib/dev-stub/main.go => contrib/dev-stub/main.go +118 -0
@@ 0,0 1,118 @@
+// Command dev-stub is a throwaway fake git.sr.ht GraphQL API for local
+// development of compare.sr.ht. It answers the two queries compare.sr.ht's
+// authorizer issues (a single `user{repository}` lookup and the `me`
+// repository list) with fixed PUBLIC data, so you can drive the UI without a
+// real SourceHut instance or its internal-auth machinery.
+//
+// It ignores authentication entirely: every request — anonymous or "logged in"
+// — sees the same public repository, which is exactly what you want when
+// exercising the compare/commit pages against a directory of local bare repos.
+//
+// Usage:
+//
+// go run ./contrib/dev-stub -addr 127.0.0.1:5101
+//
+// Then point compare.sr.ht's config at it and start the daemon:
+//
+// [git.sr.ht]
+// api-origin=http://127.0.0.1:5101 # dev-stub serves POST /query here
+// repos=/path/to/local/bare/repos # {repos}/~{owner}/{name}
+//
+// make run-dev
+//
+// The repository NAME echoed back is taken from the query variables, so any
+// /~owner/<name> you visit resolves; put a matching bare repo at
+// {repos}/~owner/<name> for gitx to read. See README.md ("Development") for the
+// full recipe including forging a dev login cookie.
+//
+// stdlib only, no build-time dependencies. Not meant for production.
+package main
+
+import (
+ "encoding/json"
+ "flag"
+ "log"
+ "net/http"
+ "strings"
+)
+
+// repoNode is the repository shape compare.sr.ht's authorizer decodes for both
+// the single-repo lookup and the me.repositories list.
+type repoNode struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Visibility string `json:"visibility"`
+}
+
+type gqlRequest struct {
+ Query string `json:"query"`
+ Variables map[string]any `json:"variables"`
+}
+
+func main() {
+ addr := flag.String("addr", "127.0.0.1:5101", "address to listen on")
+ flag.Parse()
+
+ http.HandleFunc("/query", handleQuery)
+ http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte("ok\n"))
+ })
+
+ log.Printf("dev-stub git.sr.ht GraphQL API listening on http://%s/query", *addr)
+ log.Printf("every query resolves to a fixed PUBLIC repository (auth ignored)")
+ if err := http.ListenAndServe(*addr, nil); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func handleQuery(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "POST only", http.StatusMethodNotAllowed)
+ return
+ }
+ var req gqlRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeData(w, map[string]any{}) // let the client surface a decode-shaped miss
+ return
+ }
+
+ switch {
+ case strings.Contains(req.Query, "repositories("):
+ // me { repositories { results { ... } cursor } }
+ writeData(w, map[string]any{
+ "me": map[string]any{
+ "repositories": map[string]any{
+ "results": []repoNode{
+ {ID: 1, Name: "demo", Description: "a public demo repo", Visibility: "PUBLIC"},
+ {ID: 2, Name: "playground", Description: "scratch space", Visibility: "UNLISTED"},
+ },
+ "cursor": nil,
+ },
+ },
+ })
+ case strings.Contains(req.Query, "repository("):
+ // user(username:$u) { repository(name:$r) { ... } }
+ name, _ := req.Variables["r"].(string)
+ if name == "" {
+ name = "demo"
+ }
+ writeData(w, map[string]any{
+ "user": map[string]any{
+ "repository": repoNode{
+ ID: 1,
+ Name: name,
+ Description: "a public demo repo (dev-stub)",
+ Visibility: "PUBLIC",
+ },
+ },
+ })
+ default:
+ writeData(w, map[string]any{})
+ }
+}
+
+func writeData(w http.ResponseWriter, data map[string]any) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"data": data})
+}