package main import ( "bytes" "crypto/sha256" "encoding/hex" "fmt" "io" "log" "net/http" "net/url" "os" "path" "path/filepath" "strconv" "strings" "sync" "time" ) // Thumbnailer produces a thumbnail of a source file. Implementations register // themselves in init() by appending to thumbnailers. The first registered // handler whose CanHandle returns true is used. type Thumbnailer interface { CanHandle(ext string) bool Generate(src io.Reader, dst io.Writer, width int) error } // PathThumbnailer is an optional capability for thumbnailers whose source must // be a seekable file on disk rather than a stream (e.g. ffmpeg for video). When // a Thumbnailer also implements this, handleThumb passes the file path directly // and skips reading the file into memory and content-hashing it. type PathThumbnailer interface { GenerateFromPath(srcPath string, dst io.Writer, width int) error } var thumbnailers []Thumbnailer // thumbCacheDir is set from the -cache flag at startup. var thumbCacheDir string const thumbURLPrefix = "/_thumb" // Cache is content-addressed: the cache path is derived from the SHA-256 of // the source file. Renames and moves reuse the same cache entry; overwriting // a file with new content produces a new digest and regenerates. var ( thumbLocks = map[string]*sync.Mutex{} thumbLocksMu sync.Mutex digestCache = map[string]digestEntry{} digestCacheMu sync.Mutex ) // digestEntry remembers the digest of a source file so repeated requests do // not re-hash the whole file. The (mtime, size) pair invalidates the cache // when the file is overwritten in place. type digestEntry struct { mtime time.Time size int64 hex string } func findThumbnailer(name string) Thumbnailer { ext := strings.ToLower(filepath.Ext(name)) if ext == "" { return nil } for _, t := range thumbnailers { if t.CanHandle(ext) { return t } } return nil } // hasThumbnail reports whether a file name has a registered thumbnailer. func hasThumbnail(name string) bool { return findThumbnailer(name) != nil } // thumbURL builds a thumbnail URL for a wiki file. filePath must be URL-style // (slash-separated, leading slash), as already used on page links. func thumbURL(filePath string, width int) string { return thumbURLPrefix + filePath + "?w=" + strconv.Itoa(width) } func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) { raw := strings.TrimPrefix(r.URL.Path, thumbURLPrefix) if raw == "" || raw == "/" { http.NotFound(w, r) return } decoded, err := url.PathUnescape(raw) if err != nil { http.Error(w, "bad path", http.StatusBadRequest) return } cleanPath := path.Clean(decoded) srcFS := filepath.Join(h.root, filepath.FromSlash(cleanPath)) rel, err := filepath.Rel(h.root, srcFS) if err != nil || strings.HasPrefix(rel, "..") { http.Error(w, "Forbidden", http.StatusForbidden) return } srcInfo, err := os.Stat(srcFS) if err != nil || srcInfo.IsDir() { http.NotFound(w, r) return } t := findThumbnailer(srcFS) if t == nil { http.NotFound(w, r) return } width := 300 if s := r.URL.Query().Get("w"); s != "" { if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 2000 { width = n } } // Path thumbnailers (video/ffmpeg) take the source path directly and key // the cache on path+mtime+size, so the file is never read into memory. // Content thumbnailers (images) stay content-addressed so renames reuse // the cache entry. Either way, generation runs through the closure below. var ( digest string write func(dst io.Writer) error ) if pt, ok := t.(PathThumbnailer); ok { digest = pathDigest(srcFS, srcInfo) write = func(dst io.Writer) error { return pt.GenerateFromPath(srcFS, dst, width) } } else { d, data, err := sourceDigest(srcFS, srcInfo) if err != nil { log.Printf("thumb digest %s: %v", rel, err) http.Error(w, "thumbnail failed", http.StatusInternalServerError) return } digest = d write = func(dst io.Writer) error { var src io.Reader if data != nil { src = bytes.NewReader(data) } else { f, err := os.Open(srcFS) if err != nil { return err } defer f.Close() src = f } return t.Generate(src, dst, width) } } cacheFS := filepath.Join(thumbCacheDir, digest[:2], fmt.Sprintf("%s.%d.jpg", digest, width)) if _, err := os.Stat(cacheFS); err == nil { serveThumb(w, r, cacheFS) return } lock := thumbLock(cacheFS) lock.Lock() defer lock.Unlock() if _, err := os.Stat(cacheFS); err == nil { serveThumb(w, r, cacheFS) return } if err := generateThumb(cacheFS, write); err != nil { log.Printf("thumb %s: %v", rel, err) http.Error(w, "thumbnail failed", http.StatusInternalServerError) return } serveThumb(w, r, cacheFS) } // pathDigest keys the cache for path-based thumbnailers on the source path plus // its mtime and size, so the (potentially large) file is never read into memory // just to hash it. Overwriting the file changes mtime/size and busts the entry. func pathDigest(srcFS string, info os.FileInfo) string { sum := sha256.Sum256(fmt.Appendf(nil, "%s\x00%d\x00%d", srcFS, info.ModTime().UnixNano(), info.Size())) return hex.EncodeToString(sum[:]) } // sourceDigest returns the SHA-256 hex digest of a source file's content. // On a cache hit (path + mtime + size unchanged) the returned data is nil, // so the caller knows to open the file itself. On a miss the file is read // once and the contents are returned for the caller to reuse. func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) { digestCacheMu.Lock() d, ok := digestCache[srcFS] digestCacheMu.Unlock() if ok && d.mtime.Equal(info.ModTime()) && d.size == info.Size() { return d.hex, nil, nil } data, err := os.ReadFile(srcFS) if err != nil { return "", nil, err } sum := sha256.Sum256(data) h := hex.EncodeToString(sum[:]) digestCacheMu.Lock() digestCache[srcFS] = digestEntry{mtime: info.ModTime(), size: info.Size(), hex: h} digestCacheMu.Unlock() return h, data, nil } func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) { w.Header().Set("Cache-Control", "public, max-age=86400") http.ServeFile(w, r, cacheFS) } // generateThumb writes a thumbnail to cacheFS atomically: write builds the // image into a temp file in the same dir, which is renamed into place only on // success so readers never see a partial file. func generateThumb(cacheFS string, write func(dst io.Writer) error) error { if err := os.MkdirAll(filepath.Dir(cacheFS), 0755); err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(cacheFS), ".thumb-*") if err != nil { return err } tmpName := tmp.Name() if err := write(tmp); err != nil { tmp.Close() os.Remove(tmpName) return err } if err := tmp.Close(); err != nil { os.Remove(tmpName) return err } return os.Rename(tmpName, cacheFS) } func thumbLock(key string) *sync.Mutex { thumbLocksMu.Lock() defer thumbLocksMu.Unlock() m, ok := thumbLocks[key] if !ok { m = &sync.Mutex{} thumbLocks[key] = m } return m }