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 "io/fs"
5 "net/http"
6 "path"
7 "sort"
8 "strconv"
9 "strings"
10)
11
12// The site ran on Next.js until 2026 and its image optimiser is still being
13// asked for pictures that are still here, under a different extension and with
14// the width baked into the name instead of the query string. The old URL was
15// /_next/image?url=/static/images/art/acrylic-pours/000.webp&w=960, and that
16// picture is now /static/images/art/acrylic-pours/000-960.avif.
17
18// nextImageIndex holds the widths available for each image base path.
19type nextImageIndex struct {
20 widths map[string][]int
21 plain map[string]string
22}
23
24func newNextImageIndex(dist fs.FS) *nextImageIndex {
25 idx := &nextImageIndex{widths: map[string][]int{}, plain: map[string]string{}}
26
27 _ = fs.WalkDir(dist, "images", func(p string, d fs.DirEntry, err error) error {
28 if err != nil || d.IsDir() {
29 return nil
30 }
31 ext := path.Ext(p)
32 stem := strings.TrimSuffix(p, ext)
33 if base, w, ok := splitWidth(stem); ok {
34 idx.widths[base] = append(idx.widths[base], w)
35 return nil
36 }
37 idx.plain[stem] = p
38 return nil
39 })
40 for k := range idx.widths {
41 sort.Ints(idx.widths[k])
42 }
43 return idx
44}
45
46// splitWidth pulls the trailing -<width> off a built name. A stem that ends in
47// a number for its own reasons keeps it, since the part before the dash has to
48// survive as a name.
49func splitWidth(stem string) (base string, width int, ok bool) {
50 i := strings.LastIndex(stem, "-")
51 if i <= 0 {
52 return "", 0, false
53 }
54 w, err := strconv.Atoi(stem[i+1:])
55 if err != nil || w <= 0 {
56 return "", 0, false
57 }
58 return stem[:i], w, true
59}
60
61// resolve maps an old optimiser request onto a file that exists now. It takes
62// the smallest width at least as large as the one asked for, so the picture is
63// never upscaled, and the largest available when the request wants more than
64// anything here has.
65func (n *nextImageIndex) resolve(rawURL string, want int) (string, bool) {
66 rest, ok := strings.CutPrefix(path.Clean(rawURL), "/static/")
67 if !ok {
68 return "", false
69 }
70 stem := strings.TrimSuffix(rest, path.Ext(rest))
71
72 if widths := n.widths[stem]; len(widths) > 0 {
73 pick := widths[len(widths)-1]
74 for _, w := range widths {
75 if w >= want {
76 pick = w
77 break
78 }
79 }
80 return "/static/" + stem + "-" + strconv.Itoa(pick) + ".avif", true
81 }
82 if p, ok := n.plain[stem]; ok {
83 return "/static/" + p, true
84 }
85 return "", false
86}
87
88func (s *site) nextImage(w http.ResponseWriter, r *http.Request) {
89 want, _ := strconv.Atoi(r.URL.Query().Get("w"))
90 target, ok := s.nextImages.resolve(r.URL.Query().Get("url"), want)
91 if !ok {
92 s.notFound(w, r)
93 return
94 }
95 w.Header().Set("Cache-Control", "public, max-age=86400")
96 http.Redirect(w, r, target, http.StatusMovedPermanently)
97}