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

22.6 KB · 854 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"fmt"
  6	"html/template"
  7	"log/slog"
  8	"net/http"
  9	"net/url"
 10	"sort"
 11	"strconv"
 12	"strings"
 13	"time"
 14
 15	"repos.bythewood.me/web"
 16)
 17
 18// site holds everything a handler needs.
 19type site struct {
 20	renderer *web.Renderer
 21	store    *Store
 22	db       *DB
 23	cfg      Config
 24	backend  string
 25	script   string
 26	styles   []string
 27	// mirror is here only so the settings page can run a sync on demand.
 28	mirror *Mirror
 29	auth   *web.Authenticator
 30}
 31
 32// page is the data every template gets, so base.html can render the chrome.
 33type page struct {
 34	Title       string
 35	Description string
 36	Script      string
 37	Styles      []string
 38	LoggedIn    bool
 39	Staging     bool
 40	// Analytics gates the collector snippet, so staging never files traffic under
 41	// the real property.
 42	Analytics   bool
 43	AnalyticsID string
 44	SiteName    string
 45	SiteTagline string
 46	Crumb       string
 47	Year        int
 48	SourceURL   string
 49	Canonical   string
 50	AuthorName  string
 51	// RepoName and RepoRev drive the header's context line. Filled by
 52	// pageForRepo, empty on the index and on settings.
 53	RepoName string
 54	RepoRev  string
 55	Data     any
 56}
 57
 58func (s *site) page(r *http.Request, title, description string, data any) page {
 59	return page{
 60		Title:       title,
 61		Description: description,
 62		Script:      s.script,
 63		Styles:      s.styles,
 64		LoggedIn:    s.auth.Authenticated(r),
 65		Staging:     Staging,
 66		Analytics:   !Staging,
 67		AnalyticsID: analyticsID,
 68		SiteName:    siteName,
 69		SiteTagline: siteTagline,
 70		Year:        time.Now().UTC().Year(),
 71		SourceURL:   sourceURL,
 72		Canonical:   baseURL + r.URL.Path,
 73		AuthorName:  authorName,
 74		Data:        data,
 75	}
 76}
 77
 78// pageForRepo is page plus the repository header context.
 79func (s *site) pageForRepo(r *http.Request, rc *repoContext, title, description string, data any) page {
 80	p := s.page(r, title, description, data)
 81	p.RepoName = rc.Repo.Name
 82	if !rc.Empty {
 83		p.RepoRev = rc.Rev
 84	}
 85	return p
 86}
 87
 88func (s *site) notFound(w http.ResponseWriter, r *http.Request) {
 89	p := s.page(r, "404", "That page does not exist.", nil)
 90	p.Crumb = "404"
 91	s.renderer.Render(w, http.StatusNotFound, "notfound.html", p)
 92}
 93
 94// requireLogin gates the routes that change something.
 95func (s *site) requireLogin(next http.HandlerFunc) http.HandlerFunc {
 96	return func(w http.ResponseWriter, r *http.Request) {
 97		if !s.auth.Authenticated(r) {
 98			http.Redirect(w, r, web.LoginURL(r), http.StatusSeeOther)
 99			return
100		}
101		next(w, r)
102	}
103}
104
105// RepoCard is one row on the index.
106type RepoCard struct {
107	RepoMeta
108	Size     int64
109	LastPush time.Time
110	Branches int
111	Tags     int
112	Empty    bool
113	// PushPercent is how much of one push through the tunnel a from-scratch push
114	// of this repository would use.
115	PushPercent int
116}
117
118func (s *site) index(w http.ResponseWriter, r *http.Request) {
119	ctx := r.Context()
120
121	repos, err := s.store.Discover()
122	if err != nil {
123		slog.Error("discover repos", slog.Any("err", err))
124		http.Error(w, "internal server error", http.StatusInternalServerError)
125		return
126	}
127	meta, err := s.db.AllRepos()
128	if err != nil {
129		slog.Error("read repo metadata", slog.Any("err", err))
130		meta = map[string]RepoMeta{}
131	}
132
133	cards := make([]RepoCard, 0, len(repos))
134	for _, repo := range repos {
135		m := meta[repo.Name]
136		m.Name = repo.Name
137		if m.Hidden {
138			continue
139		}
140		o := s.store.Overview(ctx, repo)
141		cards = append(cards, RepoCard{
142			RepoMeta:    m,
143			Size:        o.Size,
144			LastPush:    o.LastPush,
145			Branches:    o.Branches,
146			Tags:        o.Tags,
147			Empty:       o.Empty,
148			PushPercent: percentOf(o.Size, cloudflareBodyLimit),
149		})
150	}
151
152	sort.SliceStable(cards, func(i, j int) bool {
153		return cards[i].LastPush.After(cards[j].LastPush)
154	})
155
156	var totalSize int64
157	var mirrors int
158	var newest time.Time
159	for _, c := range cards {
160		totalSize += c.Size
161		if c.Mirror {
162			mirrors++
163		}
164		if c.LastPush.After(newest) {
165			newest = c.LastPush
166		}
167	}
168
169	s.renderer.Render(w, http.StatusOK, "index.html", s.page(r, "", siteTagline, map[string]any{
170		"Repos":     cards,
171		"HasTokens": s.db.HasTokens(),
172		"CloneURL":  strings.TrimSuffix(baseURL, "/"),
173		"Bridge":    containerName,
174		"TotalSize": totalSize,
175		"Mirrors":   mirrors,
176		"LastPush":  newest,
177	}))
178}
179
180// repoContext is the shared header every repository page renders.
181type repoContext struct {
182	Repo     Repo
183	Meta     RepoMeta
184	Rev      string
185	IsBranch bool
186	Head     string
187	Branches []Ref
188	Tags     []Ref
189	Empty    bool
190	Size     int64
191
192	CloneURL  string
193	BridgeURL string
194	// BridgeContainer is the container name alone, which is what the push help
195	// builds its commands from.
196	BridgeContainer string
197	// OverLimit says a from-scratch push of this repository is larger than one
198	// request through Cloudflare, so seeding it needs the bridge or slices.
199	OverLimit   bool
200	PushPercent int
201}
202
203// resolveRepo loads the shared header, or writes a 404 and reports false.
204func (s *site) resolveRepo(w http.ResponseWriter, r *http.Request) (*repoContext, bool) {
205	ctx := r.Context()
206
207	name := r.PathValue("name")
208	repo, ok := s.store.Open(name)
209	if !ok {
210		s.notFound(w, r)
211		return nil, false
212	}
213
214	meta, _ := s.db.Repo(name)
215	meta.Name = name
216
217	rc := &repoContext{
218		Repo:            repo,
219		Meta:            meta,
220		Head:            s.store.Head(ctx, repo),
221		Empty:           s.store.IsEmpty(ctx, repo),
222		Size:            s.store.Size(ctx, repo),
223		CloneURL:        cloneURL(baseURL, name),
224		BridgeURL:       bridgeCloneURL(containerName, name),
225		BridgeContainer: containerName,
226	}
227	rc.OverLimit = rc.Size > cloudflareBodyLimit
228	rc.PushPercent = percentOf(rc.Size, cloudflareBodyLimit)
229
230	if !rc.Empty {
231		rc.Branches, _ = s.store.Branches(ctx, repo)
232		rc.Tags, _ = s.store.Tags(ctx, repo)
233	}
234
235	// Either way the revision is resolved before use, so anything that does not
236	// name a commit is a 404 rather than an argument to a later git command.
237	rev := r.PathValue("rev")
238	if rev == "" {
239		rev = rc.Head
240	}
241	rc.Rev = rev
242
243	for _, b := range rc.Branches {
244		if b.Name == rev {
245			rc.IsBranch = true
246			break
247		}
248	}
249	return rc, true
250}
251
252func (s *site) repo(w http.ResponseWriter, r *http.Request) {
253	rc, ok := s.resolveRepo(w, r)
254	if !ok {
255		return
256	}
257	ctx := r.Context()
258
259	data := map[string]any{"Ctx": rc}
260
261	if !rc.Empty {
262		if _, err := s.store.Resolve(ctx, rc.Repo, rc.Rev); err != nil {
263			s.notFound(w, r)
264			return
265		}
266		commits, _ := s.store.Log(ctx, rc.Repo, rc.Rev, 0, 10)
267		data["Commits"] = commits
268		data["CommitCount"] = s.store.CountCommits(ctx, rc.Repo, rc.Rev)
269
270		entries, err := s.store.Tree(ctx, rc.Repo, rc.Rev, "")
271		if err == nil {
272			data["Entries"] = entries
273			if name, html, ok := s.readme(ctx, rc.Repo, rc.Rev, entries); ok {
274				data["Readme"] = html
275				data["ReadmeName"] = name
276			}
277		}
278	}
279
280	title := rc.Repo.Name
281	desc := rc.Meta.Description
282	if desc == "" {
283		desc = "git repository " + rc.Repo.Name
284	}
285	s.renderer.Render(w, http.StatusOK, "repo.html", s.pageForRepo(r, rc, title, desc, data))
286}
287
288// readme finds and renders the README in a directory listing.
289func (s *site) readme(ctx context.Context, repo Repo, rev string, entries []TreeEntry) (string, template.HTML, bool) {
290	present := make(map[string]bool, len(entries))
291	for _, e := range entries {
292		if !e.IsDir() {
293			present[e.Name] = true
294		}
295	}
296
297	for _, want := range readmeNames {
298		if !present[want] {
299			continue
300		}
301		src, _, err := s.store.Blob(ctx, repo, rev, want)
302		if err != nil {
303			continue
304		}
305		if IsMarkdown(want) {
306			html, err := RenderMarkdown(src)
307			if err != nil {
308				continue
309			}
310			return want, html, true
311		}
312		return want, template.HTML("<pre>" +
313			template.HTMLEscapeString(string(src)) + "</pre>"), true
314	}
315	return "", "", false
316}
317
318func (s *site) tree(w http.ResponseWriter, r *http.Request) {
319	rc, ok := s.resolveRepo(w, r)
320	if !ok {
321		return
322	}
323	ctx := r.Context()
324
325	if _, err := s.store.Resolve(ctx, rc.Repo, rc.Rev); err != nil {
326		s.notFound(w, r)
327		return
328	}
329
330	path := strings.Trim(r.PathValue("path"), "/")
331	entries, err := s.store.Tree(ctx, rc.Repo, rc.Rev, path)
332	if err != nil {
333		s.notFound(w, r)
334		return
335	}
336
337	data := map[string]any{"Ctx": rc, "Entries": entries, "Path": path}
338	if name, html, ok := s.readme(ctx, rc.Repo, rc.Rev, entries); ok {
339		data["Readme"] = html
340		data["ReadmeName"] = name
341	}
342
343	title := rc.Repo.Name + "/" + path
344	s.renderer.Render(w, http.StatusOK, "tree.html", s.pageForRepo(r, rc, title, "", data))
345}
346
347func (s *site) blob(w http.ResponseWriter, r *http.Request) {
348	rc, ok := s.resolveRepo(w, r)
349	if !ok {
350		return
351	}
352	ctx := r.Context()
353
354	if _, err := s.store.Resolve(ctx, rc.Repo, rc.Rev); err != nil {
355		s.notFound(w, r)
356		return
357	}
358
359	path := strings.Trim(r.PathValue("path"), "/")
360	if path == "" {
361		s.notFound(w, r)
362		return
363	}
364
365	src, size, err := s.store.Blob(ctx, rc.Repo, rc.Rev, path)
366	data := map[string]any{
367		"Ctx":  rc,
368		"Path": path,
369		"Size": size,
370	}
371
372	switch {
373	case err == errTooLarge:
374		// Not an error page: the file exists and is still downloadable.
375		data["TooLarge"] = true
376	case err != nil:
377		s.notFound(w, r)
378		return
379	case IsBinary(src):
380		data["Binary"] = true
381	default:
382		if html, ok := Highlight(path, src); ok {
383			data["Highlighted"] = html
384		} else {
385			data["Plain"] = string(src)
386		}
387		data["Lines"] = strings.Count(string(src), "\n") + 1
388		data["Language"] = languageOf(path)
389	}
390
391	s.renderer.Render(w, http.StatusOK, "blob.html",
392		s.pageForRepo(r, rc, rc.Repo.Name+"/"+path, "", data))
393}
394
395// raw streams a file's bytes as text/plain with nosniff. Serving a .html or .svg
396// blob with its real content type would be stored XSS on the origin that also
397// holds the session cookie.
398func (s *site) raw(w http.ResponseWriter, r *http.Request) {
399	rc, ok := s.resolveRepo(w, r)
400	if !ok {
401		return
402	}
403	ctx := r.Context()
404
405	sha, err := s.store.Resolve(ctx, rc.Repo, rc.Rev)
406	if err != nil {
407		s.notFound(w, r)
408		return
409	}
410
411	path := strings.Trim(r.PathValue("path"), "/")
412	if path == "" {
413		s.notFound(w, r)
414		return
415	}
416
417	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
418	w.Header().Set("X-Content-Type-Options", "nosniff")
419	// A blob at a resolved SHA cannot change; at a branch name it can.
420	if sha == rc.Rev {
421		w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
422	} else {
423		w.Header().Set("Cache-Control", "public, max-age=300")
424	}
425
426	if err := s.store.StreamBlob(ctx, rc.Repo, rc.Rev, path, w); err != nil {
427		// The header is already written, so there is nothing to say to the client.
428		slog.Info("raw blob failed",
429			slog.String("repo", rc.Repo.Name), slog.String("path", path))
430	}
431}
432
433func (s *site) log(w http.ResponseWriter, r *http.Request) {
434	rc, ok := s.resolveRepo(w, r)
435	if !ok {
436		return
437	}
438	ctx := r.Context()
439
440	if rc.Empty {
441		s.renderer.Render(w, http.StatusOK, "log.html",
442			s.pageForRepo(r, rc, rc.Repo.Name+" log", "", map[string]any{"Ctx": rc}))
443		return
444	}
445	if _, err := s.store.Resolve(ctx, rc.Repo, rc.Rev); err != nil {
446		s.notFound(w, r)
447		return
448	}
449
450	const perPage = 50
451	page := 1
452	if v := r.URL.Query().Get("page"); v != "" {
453		if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 {
454			page = n
455		}
456	}
457
458	path := strings.Trim(r.PathValue("path"), "/")
459
460	var commits []Commit
461	var err error
462	if path != "" {
463		commits, err = s.store.LogFile(ctx, rc.Repo, rc.Rev, path, (page-1)*perPage, perPage+1)
464	} else {
465		commits, err = s.store.Log(ctx, rc.Repo, rc.Rev, (page-1)*perPage, perPage+1)
466	}
467	if err != nil {
468		s.notFound(w, r)
469		return
470	}
471
472	// One extra row rather than counting the whole history, which is a graph walk.
473	hasNext := len(commits) > perPage
474	if hasNext {
475		commits = commits[:perPage]
476	}
477
478	s.renderer.Render(w, http.StatusOK, "log.html",
479		s.pageForRepo(r, rc, rc.Repo.Name+" log", "", map[string]any{
480			"Ctx":     rc,
481			"Commits": commits,
482			"Path":    path,
483			"Page":    page,
484			"HasNext": hasNext,
485			"HasPrev": page > 1,
486		}))
487}
488
489func (s *site) commit(w http.ResponseWriter, r *http.Request) {
490	rc, ok := s.resolveRepo(w, r)
491	if !ok {
492		return
493	}
494	ctx := r.Context()
495
496	sha, err := s.store.Resolve(ctx, rc.Repo, r.PathValue("sha"))
497	if err != nil {
498		s.notFound(w, r)
499		return
500	}
501
502	commit, err := s.store.CommitOne(ctx, rc.Repo, sha)
503	if err != nil {
504		s.notFound(w, r)
505		return
506	}
507	diff, err := s.store.Diff(ctx, rc.Repo, sha)
508	if err != nil {
509		slog.Error("diff failed", slog.String("repo", rc.Repo.Name),
510			slog.String("sha", sha), slog.Any("err", err))
511	}
512
513	s.renderer.Render(w, http.StatusOK, "commit.html",
514		s.pageForRepo(r, rc, commit.Subject, "", map[string]any{
515			"Ctx":    rc,
516			"Commit": commit,
517			"Diff":   diff,
518		}))
519}
520
521func (s *site) refsPage(templateName string) http.HandlerFunc {
522	return func(w http.ResponseWriter, r *http.Request) {
523		rc, ok := s.resolveRepo(w, r)
524		if !ok {
525			return
526		}
527		s.renderer.Render(w, http.StatusOK, templateName,
528			s.pageForRepo(r, rc, rc.Repo.Name, "", map[string]any{"Ctx": rc}))
529	}
530}
531
532// archive streams a tarball or zip of a revision.
533func (s *site) archive(w http.ResponseWriter, r *http.Request) {
534	rc, ok := s.resolveRepo(w, r)
535	if !ok {
536		return
537	}
538	ctx := r.Context()
539
540	// The format rides as the file extension on the last URL segment.
541	rev, format := rc.Rev, ""
542	switch {
543	case strings.HasSuffix(rev, ".tar.gz"):
544		rev, format = strings.TrimSuffix(rev, ".tar.gz"), "tar.gz"
545	case strings.HasSuffix(rev, ".zip"):
546		rev, format = strings.TrimSuffix(rev, ".zip"), "zip"
547	default:
548		s.notFound(w, r)
549		return
550	}
551
552	if _, err := s.store.Resolve(ctx, rc.Repo, rev); err != nil {
553		s.notFound(w, r)
554		return
555	}
556
557	prefix := archiveName(rc.Repo.Name, rev)
558	filename := prefix + "." + format
559
560	if format == "zip" {
561		w.Header().Set("Content-Type", "application/zip")
562	} else {
563		w.Header().Set("Content-Type", "application/gzip")
564	}
565	w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
566	w.Header().Set("X-Content-Type-Options", "nosniff")
567
568	if err := s.store.Archive(ctx, rc.Repo, rev, format, prefix, w); err != nil {
569		slog.Error("archive failed",
570			slog.String("repo", rc.Repo.Name), slog.Any("err", err))
571	}
572}
573
574// newTokenCookie carries a freshly minted token from the POST to the page that
575// shows it, exactly once.
576const newTokenCookie = "new_token"
577
578func (s *site) settings(w http.ResponseWriter, r *http.Request) {
579	// This page can render a live push credential, so it must never reach a
580	// shared cache. EdgeCache leaves a handler's own choice alone.
581	w.Header().Set("Cache-Control", "no-store, private")
582
583	// Read once and clear, so a refresh does not show the token again.
584	fresh := ""
585	if c, err := r.Cookie(newTokenCookie); err == nil {
586		fresh = c.Value
587		http.SetCookie(w, &http.Cookie{
588			Name: newTokenCookie, Value: "", Path: "/settings",
589			Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: -1,
590		})
591	}
592
593	notice := ""
594	if c, err := r.Cookie(mirrorNoticeCookie); err == nil {
595		notice, _ = url.QueryUnescape(c.Value)
596		http.SetCookie(w, &http.Cookie{
597			Name: mirrorNoticeCookie, Value: "", Path: "/settings",
598			Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: -1,
599		})
600	}
601
602	tokens, err := s.db.Tokens()
603	if err != nil {
604		slog.Error("list tokens", slog.Any("err", err))
605	}
606	sources, err := s.mirrorSources()
607	if err != nil {
608		slog.Error("list mirror sources", slog.Any("err", err))
609	}
610	s.renderer.Render(w, http.StatusOK, "settings.html",
611		s.page(r, "Settings", "", map[string]any{
612			"Tokens":        tokens,
613			"CloneURL":      strings.TrimSuffix(baseURL, "/"),
614			"Bridge":        containerName,
615			"Sources":       sources,
616			"MirrorEnabled": s.mirrorReady(),
617			"MirrorEvery":   shortDuration(s.cfg.MirrorEvery),
618			"Notice":        notice,
619			"NewToken":      fresh,
620		}))
621}
622
623func (s *site) createToken(w http.ResponseWriter, r *http.Request) {
624	token, err := s.db.CreateToken(r.FormValue("label"))
625	if err != nil {
626		slog.Error("create token", slog.Any("err", err))
627		http.Error(w, "could not create token", http.StatusInternalServerError)
628		return
629	}
630	// A one-shot cookie rather than a query string: a bearer credential in a URL
631	// lands in the edge access log, a Referer header and browser history.
632	http.SetCookie(w, &http.Cookie{
633		Name:     newTokenCookie,
634		Value:    token,
635		Path:     "/settings",
636		Secure:   true,
637		HttpOnly: true,
638		SameSite: http.SameSiteStrictMode,
639		MaxAge:   60,
640	})
641	http.Redirect(w, r, "/settings", http.StatusSeeOther)
642}
643
644func (s *site) revokeToken(w http.ResponseWriter, r *http.Request) {
645	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
646	if err != nil {
647		s.notFound(w, r)
648		return
649	}
650	if err := s.db.RevokeToken(id); err != nil {
651		slog.Error("revoke token", slog.Any("err", err))
652	}
653	http.Redirect(w, r, "/settings", http.StatusSeeOther)
654}
655
656// editRepo saves the description and topics, which git has nowhere to put.
657func (s *site) editRepo(w http.ResponseWriter, r *http.Request) {
658	name := r.PathValue("name")
659	if _, ok := s.store.Open(name); !ok {
660		s.notFound(w, r)
661		return
662	}
663
664	// A mirror's description is overwritten on the next sync, so it is not editable.
665	if meta, err := s.db.Repo(name); err == nil && meta.Mirror {
666		http.Error(w, "this repository is a mirror; its description comes from upstream",
667			http.StatusForbidden)
668		return
669	}
670
671	var topics []string
672	for _, t := range strings.Split(r.FormValue("topics"), ",") {
673		if t = strings.TrimSpace(t); t != "" {
674			topics = append(topics, t)
675		}
676	}
677	if err := s.db.SetDescription(name,
678		strings.TrimSpace(r.FormValue("description")), topics,
679		strings.TrimSpace(r.FormValue("homepage"))); err != nil {
680		slog.Error("save description", slog.Any("err", err))
681	}
682	http.Redirect(w, r, "/"+name, http.StatusSeeOther)
683}
684
685// atom is the per-repository commit feed.
686func (s *site) atom(w http.ResponseWriter, r *http.Request) {
687	rc, ok := s.resolveRepo(w, r)
688	if !ok {
689		return
690	}
691	ctx := r.Context()
692
693	if rc.Empty {
694		s.notFound(w, r)
695		return
696	}
697	commits, err := s.store.Log(ctx, rc.Repo, rc.Head, 0, 20)
698	if err != nil {
699		s.notFound(w, r)
700		return
701	}
702
703	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
704	w.Header().Set("Cache-Control", "public, max-age=600")
705
706	repoURL := baseURL + "/" + rc.Repo.Name
707	updated := time.Now().UTC()
708	if len(commits) > 0 {
709		updated = commits[0].Commit
710	}
711
712	fmt.Fprintf(w, `<?xml version="1.0" encoding="utf-8"?>`+"\n")
713	fmt.Fprintf(w, `<feed xmlns="http://www.w3.org/2005/Atom">`+"\n")
714	fmt.Fprintf(w, "  <title>%s</title>\n", xmlEscape(rc.Repo.Name))
715	fmt.Fprintf(w, "  <id>%s</id>\n", xmlEscape(repoURL))
716	fmt.Fprintf(w, `  <link href="%s"/>`+"\n", xmlEscape(repoURL))
717	fmt.Fprintf(w, `  <link rel="self" href="%s/atom.xml"/>`+"\n", xmlEscape(repoURL))
718	fmt.Fprintf(w, "  <updated>%s</updated>\n", updated.Format(time.RFC3339))
719
720	for _, c := range commits {
721		url := repoURL + "/commit/" + c.SHA
722		fmt.Fprintf(w, "  <entry>\n")
723		fmt.Fprintf(w, "    <title>%s</title>\n", xmlEscape(c.Subject))
724		fmt.Fprintf(w, "    <id>%s</id>\n", xmlEscape(url))
725		fmt.Fprintf(w, `    <link href="%s"/>`+"\n", xmlEscape(url))
726		fmt.Fprintf(w, "    <updated>%s</updated>\n", c.Commit.Format(time.RFC3339))
727		fmt.Fprintf(w, "    <author><name>%s</name></author>\n", xmlEscape(c.Author))
728		fmt.Fprintf(w, "    <content type=\"text\">%s</content>\n", xmlEscape(c.Body))
729		fmt.Fprintf(w, "  </entry>\n")
730	}
731	fmt.Fprintf(w, "</feed>\n")
732}
733
734func xmlEscape(s string) string {
735	r := strings.NewReplacer(
736		"&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
737	return r.Replace(s)
738}
739
740// mirrorNoticeCookie carries the outcome of a settings POST back to the page that
741// renders it, keeping the message out of the URL and the access log.
742const mirrorNoticeCookie = "mirror_notice"
743
744func (s *site) setMirrorNotice(w http.ResponseWriter, msg string) {
745	http.SetCookie(w, &http.Cookie{
746		Name: mirrorNoticeCookie, Value: url.QueryEscape(msg), Path: "/settings",
747		Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: 60,
748	})
749}
750
751// SourceView is one configured source with what it has actually brought in.
752type SourceView struct {
753	MirrorSource
754	Repos    int
755	LastSync time.Time
756	Failing  int
757}
758
759// mirrorSources joins the configured sources against the repository rows.
760func (s *site) mirrorSources() ([]SourceView, error) {
761	sources, err := s.db.MirrorSources()
762	if err != nil {
763		return nil, err
764	}
765	repos, err := s.db.AllRepos()
766	if err != nil {
767		return nil, err
768	}
769
770	out := make([]SourceView, 0, len(sources))
771	for _, src := range sources {
772		v := SourceView{MirrorSource: src}
773		for _, meta := range repos {
774			if !meta.Mirror || !coveredBySource(meta.Upstream, []MirrorSource{src}) {
775				continue
776			}
777			v.Repos++
778			if meta.LastSyncErr != "" {
779				v.Failing++
780			}
781			if meta.LastSync.After(v.LastSync) {
782				v.LastSync = meta.LastSync
783			}
784		}
785		out = append(out, v)
786	}
787	return out, nil
788}
789
790// addMirrorSource accepts either "owner" or "owner/repo" from one field.
791func (s *site) addMirrorSource(w http.ResponseWriter, r *http.Request) {
792	src, err := ParseMirrorSource(r.FormValue("source"))
793	if err != nil {
794		s.setMirrorNotice(w, err.Error())
795		http.Redirect(w, r, "/settings", http.StatusSeeOther)
796		return
797	}
798	if err := s.db.AddMirrorSource(src); err != nil {
799		slog.Error("add mirror source", slog.Any("err", err))
800		s.setMirrorNotice(w, "could not save that source")
801		http.Redirect(w, r, "/settings", http.StatusSeeOther)
802		return
803	}
804
805	// Sync now rather than at the next tick, so a typo'd name is reported here.
806	msg := "Added " + src.Label() + ". Syncing now."
807	if !s.mirrorReady() {
808		msg = "Added " + src.Label() + ". The mirror lane is disabled, so nothing will sync."
809	} else if !s.mirror.TrySync(context.Background()) {
810		msg = "Added " + src.Label() + ". A sync is already running; it will be picked up next time."
811	}
812	s.setMirrorNotice(w, msg)
813	http.Redirect(w, r, "/settings", http.StatusSeeOther)
814}
815
816// deleteMirrorSource stops watching a source; nothing on disk is touched.
817func (s *site) deleteMirrorSource(w http.ResponseWriter, r *http.Request) {
818	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
819	if err != nil {
820		http.Error(w, "bad source id", http.StatusBadRequest)
821		return
822	}
823	if err := s.db.DeleteMirrorSource(id); err != nil {
824		slog.Error("delete mirror source", slog.Any("err", err))
825		s.setMirrorNotice(w, "could not remove that source")
826	} else {
827		s.setMirrorNotice(w, "Source removed. Everything it mirrored is still on disk.")
828	}
829	http.Redirect(w, r, "/settings", http.StatusSeeOther)
830}
831
832// syncMirrors runs the lane now instead of waiting for the ticker.
833func (s *site) syncMirrors(w http.ResponseWriter, r *http.Request) {
834	switch {
835	case !s.mirrorReady():
836		s.setMirrorNotice(w, "The mirror lane is disabled (REPOS_MIRROR=0).")
837	case s.mirror.TrySync(context.Background()):
838		s.setMirrorNotice(w, "Sync started. Reload in a moment to see it land.")
839	default:
840		s.setMirrorNotice(w, "A sync is already running.")
841	}
842	http.Redirect(w, r, "/settings", http.StatusSeeOther)
843}
844
845func (s *site) mirrorReady() bool { return s.mirror != nil && s.cfg.MirrorEnabled }
846
847// shortDuration trims the zero tail off a Duration, so it reads "6h" not "6h0m0s".
848func shortDuration(d time.Duration) string {
849	out := d.String()
850	out = strings.TrimSuffix(out, "0s")
851	out = strings.TrimSuffix(out, "0m")
852	return out
853}