diff --git a/.gitignore b/.gitignore index 936e05f..702bbb5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .claude/ wiki/ +cache/ # Binaries datascape diff --git a/assets/diary/diary-day.html b/assets/diary/diary-day.html index 05c4b84..7c069c6 100644 --- a/assets/diary/diary-day.html +++ b/assets/diary/diary-day.html @@ -1,7 +1,7 @@ {{if .Photos}}
{{range .Photos}} - {{.Name}} + {{.Name}} {{end}}
{{end}} diff --git a/assets/diary/diary-month.html b/assets/diary/diary-month.html index 7ef6988..74f5475 100644 --- a/assets/diary/diary-month.html +++ b/assets/diary/diary-month.html @@ -7,7 +7,7 @@ {{if .Photos}}
{{range .Photos}} - {{.Name}} + {{.Name}} {{end}}
{{end}} diff --git a/assets/diary/diary-year.html b/assets/diary/diary-year.html index 263677f..f7b609b 100644 --- a/assets/diary/diary-year.html +++ b/assets/diary/diary-year.html @@ -2,6 +2,12 @@ {{range .Months}}

{{.Name}} - {{if .PhotoCount}}({{.PhotoCount}} photos){{end}}

+{{if .Photos}} +
+ {{range .Photos}} + {{.Name}} + {{end}} +
+{{end}} {{end}} diff --git a/diary.go b/diary.go index d307f1a..85f348a 100644 --- a/diary.go +++ b/diary.go @@ -283,16 +283,17 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int) // diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix. type diaryPhoto struct { - Date time.Time - Name string - URL string + Date time.Time + Name string + URL string + ThumbURL string } type diaryMonthSummary struct { - ID string - Name string - URL string - PhotoCount int + ID string + Name string + URL string + Photos []diaryPhoto } type diaryDaySection struct { @@ -376,10 +377,16 @@ func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto { if err != nil { continue } + photoURL := path.Join(yearURLPath, url.PathEscape(name)) + thumb := photoURL + if hasThumbnail(name) { + thumb = thumbURL(photoURL, 300) + } photos = append(photos, diaryPhoto{ - Date: t, - Name: name, - URL: path.Join(yearURLPath, url.PathEscape(name)), + Date: t, + Name: name, + URL: photoURL, + ThumbURL: thumb, }) } return photos @@ -408,18 +415,18 @@ func renderDiaryYear(fsPath, urlPath string) template.HTML { if err != nil || monthNum < 1 || monthNum > 12 { continue } - count := 0 + var monthPhotos []diaryPhoto for _, p := range photos { if p.Date.Year() == year && int(p.Date.Month()) == monthNum { - count++ + monthPhotos = append(monthPhotos, p) } } monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC) months = append(months, diaryMonthSummary{ - ID: monthDate.Format("2006-01"), - Name: monthDate.Format("January 2006"), - URL: path.Join(urlPath, e.Name()) + "/", - PhotoCount: count, + ID: monthDate.Format("2006-01"), + Name: fmt.Sprintf("%s %d", germanMonths[monthDate.Month()], year), + URL: path.Join(urlPath, e.Name()) + "/", + Photos: monthPhotos, }) } diff --git a/main.go b/main.go index 1d408bf..d2b4268 100644 --- a/main.go +++ b/main.go @@ -41,6 +41,7 @@ var pageTypeHandlers []pageTypeHandler func main() { addr := flag.String("addr", ":8080", "listen address") wikiDir := flag.String("dir", "./wiki", "wiki root directory") + cacheDir := flag.String("cache", "./cache", "thumbnail cache directory") user := flag.String("user", "", "basic auth username (empty = no auth)") pass := flag.String("pass", "", "basic auth password") flag.Parse() @@ -53,6 +54,14 @@ func main() { log.Fatal(err) } + thumbCacheDir, err = filepath.Abs(*cacheDir) + if err != nil { + log.Fatal(err) + } + if err := os.MkdirAll(thumbCacheDir, 0755); err != nil { + log.Fatal(err) + } + initMarkdown(root) authKey, err := loadOrCreateAuthKey(root) @@ -86,6 +95,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if strings.HasPrefix(r.URL.Path, thumbURLPrefix+"/") { + h.handleThumb(w, r) + return + } + urlPath := path.Clean("/" + r.URL.Path) fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath)) diff --git a/thumb.go b/thumb.go new file mode 100644 index 0000000..2a06e58 --- /dev/null +++ b/thumb.go @@ -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 +} diff --git a/thumb_image.go b/thumb_image.go new file mode 100644 index 0000000..aae2221 --- /dev/null +++ b/thumb_image.go @@ -0,0 +1,81 @@ +package main + +import ( + "image" + "image/color" + _ "image/gif" + "image/jpeg" + _ "image/png" + "io" +) + +func init() { + thumbnailers = append(thumbnailers, &imageThumbnailer{}) +} + +type imageThumbnailer struct{} + +func (it *imageThumbnailer) CanHandle(ext string) bool { + switch ext { + case ".jpg", ".jpeg", ".png", ".gif": + return true + } + return false +} + +func (it *imageThumbnailer) Generate(src io.Reader, dst io.Writer, width int) error { + img, _, err := image.Decode(src) + if err != nil { + return err + } + return jpeg.Encode(dst, resizeBox(img, width), &jpeg.Options{Quality: 80}) +} + +// resizeBox downsamples src to the requested width using a box filter. +// Aspect ratio is preserved. Upscaling is a no-op (returns src unchanged). +// Each source pixel is visited exactly once; alpha is discarded. +func resizeBox(src image.Image, width int) image.Image { + b := src.Bounds() + srcW, srcH := b.Dx(), b.Dy() + if srcW <= width { + return src + } + dstW := width + dstH := srcH * width / srcW + if dstH < 1 { + dstH = 1 + } + dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) + + for y := 0; y < dstH; y++ { + sy0 := y * srcH / dstH + sy1 := (y + 1) * srcH / dstH + if sy1 == sy0 { + sy1 = sy0 + 1 + } + for x := 0; x < dstW; x++ { + sx0 := x * srcW / dstW + sx1 := (x + 1) * srcW / dstW + if sx1 == sx0 { + sx1 = sx0 + 1 + } + var r, g, bl, n uint64 + for sy := sy0; sy < sy1; sy++ { + for sx := sx0; sx < sx1; sx++ { + sr, sg, sb, _ := src.At(b.Min.X+sx, b.Min.Y+sy).RGBA() + r += uint64(sr >> 8) + g += uint64(sg >> 8) + bl += uint64(sb >> 8) + n++ + } + } + dst.SetRGBA(x, y, color.RGBA{ + R: uint8(r / n), + G: uint8(g / n), + B: uint8(bl / n), + A: 255, + }) + } + } + return dst +}