orchard
mirrorEvery 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
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>`