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
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 "&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
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}