Add thumbnailing for photo grids
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
digest, data, err := sourceDigest(srcFS, srcInfo)
|
||||
if err != nil {
|
||||
log.Printf("thumb digest %s: %v", rel, err)
|
||||
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var src io.Reader
|
||||
if data != nil {
|
||||
src = bytes.NewReader(data)
|
||||
} else {
|
||||
f, err := os.Open(srcFS)
|
||||
if err != nil {
|
||||
log.Printf("thumb open %s: %v", rel, err)
|
||||
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
src = f
|
||||
}
|
||||
|
||||
if err := generateThumb(t, src, cacheFS, width); err != nil {
|
||||
log.Printf("thumb %s: %v", rel, err)
|
||||
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
serveThumb(w, r, cacheFS)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
func generateThumb(t Thumbnailer, src io.Reader, cacheFS string, width int) 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 := t.Generate(src, tmp, width); 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
|
||||
}
|
||||
Reference in New Issue
Block a user