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 "regexp"
8 "strings"
9)
10
11// The blog ran on Wagtail until 2026, and its rendition URLs are still being
12// requested by feed readers and search indexes years later. A rendition is
13// /media/images/<stem>.<hash>.<filterspec>.<format>.webp, and the same picture
14// now lives at /content/images/<stem>.webp under a tidier name, so the old URL
15// can be resolved rather than 404'd.
16
17// renditionToken matches the first component Wagtail appends: an eight
18// character content hash, or a filter spec when the rendition was not hashed.
19var renditionToken = regexp.MustCompile(`^([0-9a-f]{8}|(?:max|min|fill|width|height|scale|original|format)-.*)$`)
20
21// wagtailAliases covers what no rule can derive. Wagtail truncated the stem to
22// twenty characters, which prefix matching handles, but it truncated a
23// misspelled original here and no amount of matching recovers the missing "l".
24var wagtailAliases = map[string]string{
25 "postgrseql-row-cou": "postgresql-row-count-output.webp",
26}
27
28// mediaIndex maps a normalized stem to the file that serves it now.
29type mediaIndex struct {
30 byStem map[string]string
31 stems []string
32}
33
34func newMediaIndex(images fs.FS) *mediaIndex {
35 idx := &mediaIndex{byStem: make(map[string]string)}
36 entries, err := fs.ReadDir(images, ".")
37 if err != nil {
38 return idx
39 }
40 for _, e := range entries {
41 if e.IsDir() {
42 continue
43 }
44 name := e.Name()
45 stem := normalizeStem(strings.TrimSuffix(name, path.Ext(name)))
46 idx.byStem[stem] = name
47 idx.stems = append(idx.stems, stem)
48 }
49 return idx
50}
51
52// normalizeStem folds the two things Wagtail and the current tree disagree
53// about, case and the word separator.
54func normalizeStem(s string) string {
55 return strings.ToLower(strings.ReplaceAll(s, "_", "-"))
56}
57
58// originalStem strips the rendition suffix, keeping dotted names like
59// caddyserver.com intact by stopping at the first component that is a Wagtail
60// token rather than at the first dot.
61func originalStem(filename string) string {
62 parts := strings.Split(filename, ".")
63 for i, p := range parts {
64 if i > 0 && renditionToken.MatchString(strings.ToLower(p)) {
65 return strings.Join(parts[:i], ".")
66 }
67 }
68 if len(parts) > 1 {
69 return strings.Join(parts[:len(parts)-1], ".")
70 }
71 return filename
72}
73
74// resolve finds the current file for an old rendition name. A truncated stem
75// resolves only when exactly one file starts with it, so an ambiguous prefix
76// 404s rather than sending a reader to the wrong picture.
77func (m *mediaIndex) resolve(filename string) (string, bool) {
78 stem := normalizeStem(originalStem(filename))
79 if stem == "" {
80 return "", false
81 }
82 if name, ok := m.byStem[stem]; ok {
83 return name, true
84 }
85 for prefix, name := range wagtailAliases {
86 if strings.HasPrefix(stem, prefix) {
87 return name, true
88 }
89 }
90 var match string
91 for _, s := range m.stems {
92 if strings.HasPrefix(s, stem) {
93 if match != "" {
94 return "", false
95 }
96 match = m.byStem[s]
97 }
98 }
99 return match, match != ""
100}
101
102// target maps a Wagtail media URL onto the path that serves it now. The avatar
103// had a UUID in its name and only ever had one subject, so the whole directory
104// resolves to it. og_images were hashed with nothing recoverable in the name,
105// so those have no answer and 404.
106func (m *mediaIndex) target(urlPath string) (string, bool) {
107 dir, file := path.Split(strings.TrimPrefix(urlPath, "/media/"))
108
109 switch strings.Trim(dir, "/") {
110 case "avatar_images":
111 return "/content/images/avatar.webp", true
112 case "images":
113 name, ok := m.resolve(file)
114 if !ok {
115 return "", false
116 }
117 return "/content/images/" + name, true
118 }
119 return "", false
120}
121
122func (s *site) media(w http.ResponseWriter, r *http.Request) {
123 target, ok := s.mediaIdx.target(r.URL.Path)
124 if !ok {
125 s.notFound(w, r)
126 return
127 }
128 w.Header().Set("Cache-Control", "public, max-age=86400")
129 http.Redirect(w, r, target, http.StatusMovedPermanently)
130}