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}}
-

+

{{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}}
-

+

{{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}}
+

+ {{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
+}