repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

9.8 KB · 287 lines · Go Raw History
  1// repos.bythewood.me is a git remote with a browse UI on top: push to it over
  2// HTTPS with a token, or let it mirror the GitHub account. Everything git is a
  3// subprocess; see git.go for the rules that keep shelling out safe.
  4package main
  5
  6import (
  7	"context"
  8	"embed"
  9	"flag"
 10	"fmt"
 11	"io/fs"
 12	"log/slog"
 13	"net/http"
 14	"os"
 15	"strings"
 16	"time"
 17
 18	"repos.bythewood.me/web"
 19)
 20
 21//go:embed templates
 22var templateFS embed.FS
 23
 24const listenAddr = ":8000"
 25
 26// csp allows 'unsafe-inline' in style-src because chroma emits inline style
 27// attributes, https: in img-src for README badges, and analytics.bythewood.me
 28// plus inline script for the collector loader in base.html.
 29func csp() string {
 30	return strings.Join([]string{
 31		"default-src 'self'",
 32		"script-src 'self' 'unsafe-inline' https://analytics.bythewood.me",
 33		"style-src 'self' 'unsafe-inline'",
 34		"img-src 'self' data: https:",
 35		"font-src 'self'",
 36		"connect-src 'self' https://analytics.bythewood.me",
 37		"base-uri 'self'",
 38		"form-action 'self'",
 39		"frame-ancestors 'self'",
 40	}, "; ")
 41}
 42
 43// The template sets, shared with the tests, which parse them for real. A page
 44// listed here with no file behind it parses at boot and not at build, so
 45// nothing but doing it catches a template that was deleted and left listed.
 46var (
 47	layoutTemplates = []string{"base.html", "partials.html", "pushhelp.html"}
 48	pageTemplates   = []string{
 49		"index.html", "repo.html", "tree.html", "blob.html",
 50		"log.html", "commit.html", "branches.html", "tags.html",
 51		"settings.html", "notfound.html",
 52	}
 53)
 54
 55func main() {
 56	web.SetupLogging()
 57
 58	healthcheck := flag.Bool("healthcheck", false, "probe a running server on this host and exit")
 59	flag.Parse()
 60
 61	if *healthcheck {
 62		if err := web.HealthCheck("http://127.0.0.1:8000/healthz", 3*time.Second); err != nil {
 63			slog.Info(fmt.Sprintf("healthcheck: %v", err))
 64			os.Exit(1)
 65		}
 66		return
 67	}
 68
 69	shipper := web.ShipLogs("repos", web.HTTPSink())
 70	defer shipper.Close()
 71
 72	cfg := LoadConfig()
 73	if err := os.MkdirAll(cfg.RepoRoot, 0o755); err != nil {
 74		slog.Error("startup failed", slog.Any("err", err))
 75		os.Exit(1)
 76	}
 77
 78	db, err := OpenDB(cfg.DataDir)
 79	if err != nil {
 80		slog.Error("startup failed", slog.Any("err", err))
 81		os.Exit(1)
 82	}
 83	defer db.Close()
 84
 85	store := NewStore(cfg.RepoRoot)
 86	defer store.Close()
 87
 88	// A missing http-backend is not fatal; the browse half still works.
 89	backend := gitHTTPBackend()
 90	if backend == "" {
 91		slog.Warn("git-http-backend not found; clone and push are unavailable")
 92	}
 93
 94	dist := distFS()
 95	assets, err := web.LoadAssets(dist)
 96	if err != nil {
 97		slog.Error("startup failed", slog.Any("err", err))
 98		os.Exit(1)
 99	}
100
101	templates, err := fs.Sub(templateFS, "templates")
102	if err != nil {
103		slog.Error("startup failed", slog.Any("err", err))
104		os.Exit(1)
105	}
106
107	renderer, err := web.NewRenderer(templates, templateFuncs, layoutTemplates, pageTemplates)
108	if err != nil {
109		slog.Error("startup failed", slog.Any("err", err))
110		os.Exit(1)
111	}
112
113	s := &site{
114		renderer: renderer,
115		store:    store,
116		db:       db,
117		cfg:      cfg,
118		backend:  backend,
119		script:   assets.Script("index.js"),
120		styles:   assets.Styles("index.js"),
121		auth:     web.NewAuthenticator(),
122	}
123
124	ctx, cancel := context.WithCancel(context.Background())
125	defer cancel()
126
127	// Seeded once; after that the mirror list is edited on the settings page.
128	if err := db.SeedMirrorSources(githubUser); err != nil {
129		slog.Error("seed mirror sources", slog.Any("err", err))
130	}
131	mirror := NewMirror(store, db)
132	s.mirror = mirror
133	if cfg.MirrorEnabled {
134		go mirror.Run(ctx, cfg.MirrorEvery)
135	} else {
136		slog.Info("mirror lane disabled")
137	}
138	// receive.autogc is false on every repository here, so a push never repacks
139	// while Cloudflare counts to 100. This lane is where that work went.
140	go RunGC(ctx, store, cfg.GCEvery)
141
142	mux := http.NewServeMux()
143
144	mux.HandleFunc("GET /{$}", s.index)
145
146	// Signing in happens on auth.bythewood.me. This stays so an old bookmark
147	// and every "sign in" link in the templates land somewhere useful.
148	mux.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) {
149		http.Redirect(w, r, web.LoginURL(r), http.StatusSeeOther)
150	})
151
152	mux.HandleFunc("GET /settings", s.requireLogin(s.settings))
153	// The read only view chat.bythewood.me's tools call.
154	mux.HandleFunc("GET /api/repos", s.auth.RequireAuthJSON(s.apiRepos))
155	mux.HandleFunc("GET /api/repos/{name}/tree/{rev}", s.auth.RequireAuthJSON(s.apiTree))
156	mux.HandleFunc("GET /api/repos/{name}/tree/{rev}/{path...}", s.auth.RequireAuthJSON(s.apiTree))
157	mux.HandleFunc("GET /api/repos/{name}/file/{rev}/{path...}", s.auth.RequireAuthJSON(s.apiFile))
158	mux.HandleFunc("POST /settings/tokens", s.requireLogin(s.createToken))
159	mux.HandleFunc("POST /settings/tokens/{id}/revoke", s.requireLogin(s.revokeToken))
160	mux.HandleFunc("POST /settings/mirrors", s.requireLogin(s.addMirrorSource))
161	mux.HandleFunc("POST /settings/mirrors/{id}/delete", s.requireLogin(s.deleteMirrorSource))
162	mux.HandleFunc("POST /settings/mirrors/sync", s.requireLogin(s.syncMirrors))
163
164	// ServeMux matches most specific first, so registration order does not matter.
165	mux.HandleFunc("GET /{name}", s.repo)
166	mux.HandleFunc("POST /{name}/edit", s.requireLogin(s.editRepo))
167
168	mux.HandleFunc("GET /{name}/tree/{rev}", s.tree)
169	mux.HandleFunc("GET /{name}/tree/{rev}/{path...}", s.tree)
170	mux.HandleFunc("GET /{name}/blob/{rev}/{path...}", s.blob)
171	mux.HandleFunc("GET /{name}/raw/{rev}/{path...}", s.raw)
172
173	mux.HandleFunc("GET /{name}/log", s.log)
174	mux.HandleFunc("GET /{name}/log/{rev}", s.log)
175	mux.HandleFunc("GET /{name}/log/{rev}/{path...}", s.log)
176
177	mux.HandleFunc("GET /{name}/commit/{sha}", s.commit)
178	mux.HandleFunc("GET /{name}/branches", s.refsPage("branches.html"))
179	mux.HandleFunc("GET /{name}/tags", s.refsPage("tags.html"))
180	// The format rides as a file extension so a browser and a shell both get a
181	// usable filename.
182	mux.HandleFunc("GET /{name}/archive/{rev}", s.archive)
183	mux.HandleFunc("GET /{name}/atom.xml", s.atom)
184
185	mux.HandleFunc("GET /favicon.ico", favicon)
186	mux.HandleFunc("GET /favicon.svg", favicon)
187	mux.HandleFunc("GET /robots.txt", robots)
188
189	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
190		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
191		// EdgeCache fills in the site policy whenever a handler sets no
192		// Cache-Control of its own, so saying nothing here means the edge
193		// answers a liveness check out of cache long after this process has
194		// stopped serving.
195		w.Header().Set("Cache-Control", "no-store")
196		_, _ = w.Write([]byte("ok\n"))
197	})
198
199	mux.HandleFunc("GET /", s.notFound)
200
201	wr := &wire{store: store, db: db, backend: backend, allowCreate: true}
202
203	// Neither the wire nor /static/ can be a mux pattern: a wildcard matches a
204	// whole segment so "{name}.git" is illegal, and "GET /static/" conflicts
205	// with "GET /{name}/tree/{rev}/{path...}" and panics at registration.
206	handler := web.Chain(staticRouter(dist, assets, wr.Router(mux)),
207		web.Recovered,
208		web.Logged,
209		web.SecurityHeaders(csp()),
210		// Browse pages branch on LoggedIn and the logged-in half names internal
211		// container topology, so an operator response must never be shared-cacheable.
212		privateWhenSignedIn,
213		// Short, because a repository page changes the moment something is pushed.
214		// The wire is exempt: git's responses carry no-cache and EdgeCache only
215		// fills in a policy where a handler chose none.
216		web.EdgeCache("public, max-age=60, "+
217			"stale-while-revalidate=600, stale-if-error=86400"),
218	)
219
220	slog.Info(fmt.Sprintf("repos.bythewood.me serving %s (staging=%t, mirror=%t)",
221		baseURL, Staging, cfg.MirrorEnabled))
222	if err := web.Serve(listenAddr, handler); err != nil {
223		slog.Error("startup failed", slog.Any("err", err))
224		os.Exit(1)
225	}
226}
227
228// privateWhenSignedIn stamps no-store on any response to a request carrying a
229// session cookie, so EdgeCache leaves it alone.
230//
231// It tests for the cookie rather than for a live session, which is both cheaper
232// and safer: whether the cookie is still valid is a question for auth, and a
233// response to somebody holding an expired one still must not be shared cached.
234func privateWhenSignedIn(next http.Handler) http.Handler {
235	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
236		if c, err := r.Cookie(web.SessionCookie); err == nil && c.Value != "" {
237			w.Header().Set("Cache-Control", "private, no-store")
238		}
239		next.ServeHTTP(w, r)
240	})
241}
242
243// staticRouter claims /static/ before the browse mux can see it.
244func staticRouter(dist fs.FS, assets *web.Assets, next http.Handler) http.Handler {
245	static := web.Static(dist, assets)
246	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
247		if strings.HasPrefix(r.URL.Path, "/static/") {
248			static.ServeHTTP(w, r)
249			return
250		}
251		next.ServeHTTP(w, r)
252	})
253}
254
255func robots(w http.ResponseWriter, r *http.Request) {
256	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
257	if Staging {
258		_, _ = w.Write([]byte("User-agent: *\nDisallow: /\n"))
259		return
260	}
261	// A crawler walking every blob at every revision is one git subprocess per
262	// request, and a repository page already costs about eleven. Every route
263	// below the repository name carries the name as its first segment, so these
264	// need the wildcard: a bare "/raw/" matches nothing this site serves.
265	//
266	// tree and blob stay open, since the browse UI is the point of the site.
267	// raw, archive, commit and log are the expensive ones nobody searches for.
268	_, _ = fmt.Fprint(w, "User-agent: *\n"+
269		"Disallow: /*/raw/\n"+
270		"Disallow: /*/archive/\n"+
271		"Disallow: /*/commit/\n"+
272		"Disallow: /*/log\n"+
273		"Disallow: /settings\n"+
274		"Disallow: /login\n"+
275		"Crawl-delay: 10\n")
276}
277
278func favicon(w http.ResponseWriter, r *http.Request) {
279	w.Header().Set("Content-Type", "image/svg+xml")
280	w.Header().Set("Cache-Control", "public, max-age=86400")
281	_, _ = w.Write([]byte(faviconSVG))
282}
283
284const faviconSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">` +
285	`<rect width="32" height="32" rx="7" fill="#17151a"/>` +
286	`<circle cx="16" cy="16" r="7" fill="#a99bf5"/></svg>`