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

3.8 KB · 131 lines · Go Raw History
  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}