gallery v1 !2

merged merged by cmc on 2026-09-26 00:38 UTC · cmc/gallery:v1 into main

37 files changed, +2521 −0

Layout: unified · split

.dockerignore added +5
@@ -0,0 +1,5 @@
1.git
2photos
3cache
4gallery
5.superpowers
.gitignore added +4
@@ -0,0 +1,4 @@
1/photos/
2/cache/
3/gallery
4.superpowers/
Dockerfile added +13
@@ -0,0 +1,13 @@
1FROM golang:1-alpine AS build
2WORKDIR /src
3COPY go.mod go.sum ./
4RUN go mod download
5COPY . .
6RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /gallery ./cmd/gallery
7
8FROM alpine:3
9RUN apk add --no-cache imagemagick imagemagick-jpeg imagemagick-heic imagemagick-webp exiftool
10COPY --from=build /gallery /usr/local/bin/gallery
11ENV GALLERY_PHOTOS=/photos GALLERY_CACHE=/cache GALLERY_ADDR=:8080
12EXPOSE 8080
13ENTRYPOINT ["gallery"]
README.org +57
@@ -3,4 +3,61 @@
33Self-hosted photo gallery. Albums are folders; resized copies are generated on
44request with ImageMagick and cached on disk.
55
6* Requirements
7
8Go 1.27, ImageMagick 7 (=magick=), exiftool.
9
10* Photos
11
12#+begin_example
13photos/
14 about.org optional; rendered as the Info page
15 tokyo/
16 album.org optional
17 DSCF0412.jpg
18 ...
19#+end_example
20
21=album.org= keywords, all optional:
22
23| Keyword | Meaning | Default |
24|------------+-------------------------------------------+--------------------|
25| =#+title= | album title | folder name |
26| =#+date= | =YYYY=, =YYYY-MM= or =YYYY-MM-DD= | newest photo |
27| =#+cover= | file name of the cover photo | first photo |
28| =#+order= | integer; ordered albums come first | unset |
29| =#+sort= | =taken= or =name= | =taken= |
30
31Text below the keywords is shown as the album description.
32
33* Run
34
35#+begin_src sh
36go build -o gallery ./cmd/gallery
37./gallery -photos ./photos -cache ./cache -addr :8080
38#+end_src
39
40| Flag | Env | Default |
41|--------------+---------------------+-----------|
42| =-photos= | =GALLERY_PHOTOS= | required |
43| =-cache= | =GALLERY_CACHE= | required |
44| =-addr= | =GALLERY_ADDR= | =:8080= |
45| =-theme= | =GALLERY_THEME= | embedded |
46| =-templates= | =GALLERY_TEMPLATES= | embedded |
47| =-title= | =GALLERY_TITLE= | =gallery= |
48
49=-theme= replaces [[file:internal/web/assets/theme.css]]. =-templates= points
50at a directory with the same file names as [[file:internal/web/templates]].
51
652See [[file:docs/specs/2026-09-25-gallery-design.org][the design spec]].
53
54* Deploy
55
56#+begin_src sh
57git clone https://gitbay.org/cmc/gallery.git ~/docker/gallery
58cd ~/docker/gallery
59GALLERY_PHOTOS_DIR=/path/to/photos docker compose up -d --build
60#+end_src
61
62The container listens on =127.0.0.1:8003=; put a reverse proxy in front of it.
63Photos are mounted read-only; derivatives and =meta.json= live in =./cache=.
cmd/gallery/main.go added +106
@@ -0,0 +1,106 @@
1package main
2
3import (
4 "context"
5 "errors"
6 "flag"
7 "fmt"
8 "log"
9 "net/http"
10 "os"
11 "os/signal"
12 "path/filepath"
13 "runtime"
14 "syscall"
15 "time"
16
17 "gitbay.org/cmc/gallery/internal/format"
18 "gitbay.org/cmc/gallery/internal/library"
19 "gitbay.org/cmc/gallery/internal/render"
20 "gitbay.org/cmc/gallery/internal/web"
21)
22
23func env(key, def string) string {
24 if v := os.Getenv(key); v != "" {
25 return v
26 }
27 return def
28}
29
30func main() {
31 photos := flag.String("photos", env("GALLERY_PHOTOS", ""), "photo root (required)")
32 cache := flag.String("cache", env("GALLERY_CACHE", ""), "cache directory (required)")
33 addr := flag.String("addr", env("GALLERY_ADDR", ":8080"), "listen address")
34 theme := flag.String("theme", env("GALLERY_THEME", ""), "replacement theme.css")
35 templates := flag.String("templates", env("GALLERY_TEMPLATES", ""), "replacement template directory")
36 title := flag.String("title", env("GALLERY_TITLE", "gallery"), "site name")
37 flag.Parse()
38 if *photos == "" || *cache == "" {
39 fmt.Fprintln(os.Stderr, "gallery: -photos and -cache are required")
40 flag.Usage()
41 os.Exit(2)
42 }
43 if err := format.CheckTools(); err != nil {
44 log.Fatal(err)
45 }
46
47 reg := format.Default()
48 meta, err := library.OpenMetaCache(filepath.Join(*cache, "meta.json"))
49 if err != nil {
50 log.Fatal(err)
51 }
52 rend := render.New(*cache, runtime.NumCPU())
53 store := &library.Store{}
54 rescan := func() error {
55 lib, err := library.Scan(*photos, reg, meta)
56 if err != nil {
57 return err
58 }
59 store.Set(lib)
60 if lib.Incomplete {
61 log.Printf("scan incomplete: skipping prune")
62 } else if err := rend.Prune(lib); err != nil {
63 log.Printf("prune: %v", err)
64 }
65 log.Printf("library: %d albums", len(lib.Albums))
66 return nil
67 }
68 if err := rescan(); err != nil {
69 log.Fatal(err)
70 }
71
72 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
73 defer stop()
74 onChange := func() {
75 if err := rescan(); err != nil {
76 log.Printf("rescan: %v", err)
77 }
78 }
79 if err := library.Watch(ctx, *photos, 500*time.Millisecond, onChange); err != nil {
80 log.Fatal(err)
81 }
82
83 opt := web.Options{Title: *title, ThemePath: *theme}
84 if *templates != "" {
85 opt.Templates = os.DirFS(*templates)
86 }
87 h, err := web.New(store, rend, opt)
88 if err != nil {
89 log.Fatal(err)
90 }
91
92 srv := &http.Server{Addr: *addr, Handler: h, ReadHeaderTimeout: 10 * time.Second}
93 done := make(chan struct{})
94 go func() {
95 <-ctx.Done()
96 sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
97 defer cancel()
98 srv.Shutdown(sctx)
99 close(done)
100 }()
101 log.Printf("listening on %s", *addr)
102 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
103 log.Fatal(err)
104 }
105 <-done
106}
compose.yml added +12
@@ -0,0 +1,12 @@
1services:
2 gallery:
3 build: .
4 container_name: gallery
5 restart: unless-stopped
6 ports:
7 - "127.0.0.1:8003:8080"
8 volumes:
9 - ${GALLERY_PHOTOS_DIR:-./photos}:/photos:ro
10 - ./cache:/cache
11 environment:
12 GALLERY_TITLE: ${GALLERY_TITLE:-gallery}
go.mod added +14
@@ -0,0 +1,14 @@
1module gitbay.org/cmc/gallery
2
3go 1.27.1
4
5require (
6 github.com/fsnotify/fsnotify v1.10.1
7 github.com/niklasfasching/go-org v1.9.1
8 golang.org/x/sync v0.23.0
9)
10
11require (
12 golang.org/x/net v0.38.0 // indirect
13 golang.org/x/sys v0.31.0 // indirect
14)
go.sum added +12
@@ -0,0 +1,12 @@
1github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
2github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
3github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
4github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
5github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
6github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
7golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
8golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
9golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
10golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
11golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
12golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
internal/format/exif.go added +92
@@ -0,0 +1,92 @@
1package format
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "math"
8 "strconv"
9 "strings"
10 "time"
11)
12
13type exifRecord struct {
14 ImageWidth int
15 ImageHeight int
16 Orientation int
17 DateTimeOriginal string
18 CreateDate string
19 Make string
20 Model string
21 LensModel string
22 FNumber float64
23 ExposureTime float64
24 ISO int
25}
26
27// parseExif reads `exiftool -json -n` output for a single file.
28func parseExif(data []byte) (Meta, error) {
29 var recs []exifRecord
30 if err := json.Unmarshal(data, &recs); err != nil {
31 return Meta{}, fmt.Errorf("exiftool output: %w", err)
32 }
33 if len(recs) != 1 {
34 return Meta{}, fmt.Errorf("exiftool output: %d records, want 1", len(recs))
35 }
36 e := recs[0]
37 if e.ImageWidth == 0 || e.ImageHeight == 0 {
38 return Meta{}, errors.New("exiftool output: no image dimensions")
39 }
40 m := Meta{Width: e.ImageWidth, Height: e.ImageHeight, Lens: e.LensModel, ISO: e.ISO}
41 if e.Orientation >= 5 && e.Orientation <= 8 {
42 m.Width, m.Height = m.Height, m.Width
43 }
44 m.Taken = exifTime(e.DateTimeOriginal)
45 if m.Taken.IsZero() {
46 m.Taken = exifTime(e.CreateDate)
47 }
48 m.Camera = camera(e.Make, e.Model)
49 if e.FNumber > 0 {
50 m.Aperture = "f/" + strconv.FormatFloat(e.FNumber, 'f', -1, 64)
51 }
52 m.Shutter = shutter(e.ExposureTime)
53 return m, nil
54}
55
56// exifTime parses "YYYY:MM:DD HH:MM:SS", ignoring any trailing subseconds or zone.
57func exifTime(s string) time.Time {
58 if len(s) < 19 {
59 return time.Time{}
60 }
61 t, err := time.Parse("2006:01:02 15:04:05", s[:19])
62 if err != nil {
63 return time.Time{}
64 }
65 return t
66}
67
68func camera(maker, model string) string {
69 maker, model = strings.TrimSpace(maker), strings.TrimSpace(model)
70 if maker == "" {
71 return model
72 }
73 if model == "" {
74 return maker
75 }
76 brand := strings.ToLower(strings.Fields(maker)[0])
77 if strings.Contains(strings.ToLower(model), brand) {
78 return model
79 }
80 return maker + " " + model
81}
82
83func shutter(sec float64) string {
84 switch {
85 case sec <= 0:
86 return ""
87 case sec >= 1:
88 return strconv.FormatFloat(sec, 'f', -1, 64) + "s"
89 default:
90 return "1/" + strconv.Itoa(int(math.Round(1/sec)))
91 }
92}
internal/format/exif_test.go added +83
@@ -0,0 +1,83 @@
1package format
2
3import (
4 "testing"
5 "time"
6)
7
8func TestParseExifFull(t *testing.T) {
9 data := []byte(`[{
10 "SourceFile": "a.jpg",
11 "ImageWidth": 6240, "ImageHeight": 4160, "Orientation": 1,
12 "DateTimeOriginal": "2025:03:14 23:10:05",
13 "Make": "FUJIFILM", "Model": "X-T5", "LensModel": "XF23mmF2 R WR",
14 "FNumber": 2, "ExposureTime": 0.008, "ISO": 1600
15 }]`)
16 m, err := parseExif(data)
17 if err != nil {
18 t.Fatal(err)
19 }
20 want := Meta{
21 Width: 6240, Height: 4160,
22 Taken: time.Date(2025, 3, 14, 23, 10, 5, 0, time.UTC),
23 Camera: "FUJIFILM X-T5",
24 Lens: "XF23mmF2 R WR",
25 Aperture: "f/2",
26 Shutter: "1/125",
27 ISO: 1600,
28 }
29 if m != want {
30 t.Fatalf("got %+v\nwant %+v", m, want)
31 }
32}
33
34func TestParseExifRotatedSwapsDimensions(t *testing.T) {
35 for _, o := range []string{"5", "6", "7", "8"} {
36 m, err := parseExif([]byte(`[{"ImageWidth": 300, "ImageHeight": 200, "Orientation": ` + o + `}]`))
37 if err != nil {
38 t.Fatal(err)
39 }
40 if m.Width != 200 || m.Height != 300 {
41 t.Errorf("orientation %s: got %dx%d, want 200x300", o, m.Width, m.Height)
42 }
43 }
44}
45
46func TestParseExifFallbacks(t *testing.T) {
47 m, err := parseExif([]byte(`[{
48 "ImageWidth": 10, "ImageHeight": 10,
49 "CreateDate": "2024:01:02 03:04:05+01:00",
50 "Make": "Canon", "Model": "Canon EOS R6",
51 "FNumber": 5.6, "ExposureTime": 2
52 }]`))
53 if err != nil {
54 t.Fatal(err)
55 }
56 if want := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC); !m.Taken.Equal(want) {
57 t.Errorf("Taken = %v, want %v", m.Taken, want)
58 }
59 if m.Camera != "Canon EOS R6" {
60 t.Errorf("Camera = %q", m.Camera)
61 }
62 if m.Aperture != "f/5.6" || m.Shutter != "2s" {
63 t.Errorf("Aperture %q Shutter %q", m.Aperture, m.Shutter)
64 }
65}
66
67func TestParseExifMinimal(t *testing.T) {
68 m, err := parseExif([]byte(`[{"ImageWidth": 120, "ImageHeight": 80}]`))
69 if err != nil {
70 t.Fatal(err)
71 }
72 if (m != Meta{Width: 120, Height: 80}) {
73 t.Fatalf("got %+v", m)
74 }
75}
76
77func TestParseExifErrors(t *testing.T) {
78 for _, in := range []string{`not json`, `[]`, `[{}, {}]`, `[{"ImageWidth": 10}]`} {
79 if _, err := parseExif([]byte(in)); err == nil {
80 t.Errorf("%s: want error", in)
81 }
82 }
83}
internal/format/format.go added +61
@@ -0,0 +1,61 @@
1// Package format describes the file types the gallery can show.
2package format
3
4import (
5 "io"
6 "path/filepath"
7 "strings"
8 "time"
9)
10
11type Kind int
12
13const (
14 KindImage Kind = iota
15)
16
17// Meta holds display metadata. Width and Height are after EXIF orientation.
18type Meta struct {
19 Width, Height int
20 Taken time.Time
21 Camera, Lens string
22 Aperture string
23 Shutter string
24 ISO int
25}
26
27type Format interface {
28 Name() string
29 Match(path string) bool
30 Metadata(path string) (Meta, error)
31 Resize(src string, width int, dst io.Writer) error
32 Kind() Kind
33}
34
35// Registry resolves a path to the first registered Format that matches it.
36type Registry struct {
37 formats []Format
38}
39
40func (r *Registry) Register(f Format) {
41 r.formats = append(r.formats, f)
42}
43
44func (r *Registry) Lookup(path string) Format {
45 for _, f := range r.formats {
46 if f.Match(path) {
47 return f
48 }
49 }
50 return nil
51}
52
53func hasExt(path string, exts []string) bool {
54 ext := strings.ToLower(filepath.Ext(path))
55 for _, e := range exts {
56 if ext == e {
57 return true
58 }
59 }
60 return false
61}
internal/format/format_test.go added +44
@@ -0,0 +1,44 @@
1package format
2
3import (
4 "io"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10type fake struct{ name, ext string }
11
12func (f fake) Name() string { return f.name }
13func (f fake) Match(p string) bool { return strings.EqualFold(filepath.Ext(p), f.ext) }
14func (fake) Metadata(string) (Meta, error) { return Meta{}, nil }
15func (fake) Resize(string, int, io.Writer) error { return nil }
16func (fake) Kind() Kind { return KindImage }
17
18func TestRegistryFirstMatchWins(t *testing.T) {
19 var r Registry
20 r.Register(fake{"a", ".jpg"})
21 r.Register(fake{"b", ".jpg"})
22 r.Register(fake{"c", ".png"})
23
24 if got := r.Lookup("x/IMG.JPG"); got == nil || got.Name() != "a" {
25 t.Fatalf("jpg: got %v, want a", got)
26 }
27 if got := r.Lookup("x.png"); got == nil || got.Name() != "c" {
28 t.Fatalf("png: got %v, want c", got)
29 }
30 if got := r.Lookup("notes.txt"); got != nil {
31 t.Fatalf("txt: got %v, want nil", got)
32 }
33}
34
35func TestHasExt(t *testing.T) {
36 exts := []string{".jpg", ".jpeg"}
37 for p, want := range map[string]bool{
38 "a.jpg": true, "a.JPEG": true, "a.png": false, "jpg": false, "a.jpg.txt": false,
39 } {
40 if got := hasExt(p, exts); got != want {
41 t.Errorf("hasExt(%q) = %v, want %v", p, got, want)
42 }
43 }
44}
internal/format/magick.go added +68
@@ -0,0 +1,68 @@
1package format
2
3import (
4 "bytes"
5 "fmt"
6 "io"
7 "os/exec"
8 "strconv"
9 "strings"
10)
11
12// Magick reads metadata with exiftool and resizes with ImageMagick.
13type Magick struct {
14 name string
15 exts []string
16}
17
18func NewMagick(name string, exts ...string) *Magick {
19 return &Magick{name: name, exts: exts}
20}
21
22func (m *Magick) Name() string { return m.name }
23func (m *Magick) Match(path string) bool { return hasExt(path, m.exts) }
24func (m *Magick) Kind() Kind { return KindImage }
25
26func (m *Magick) Metadata(path string) (Meta, error) {
27 out, err := exec.Command("exiftool", "-json", "-n",
28 "-ImageWidth", "-ImageHeight", "-Orientation",
29 "-DateTimeOriginal", "-CreateDate",
30 "-Make", "-Model", "-LensModel",
31 "-FNumber", "-ExposureTime", "-ISO",
32 path).Output()
33 if err != nil {
34 return Meta{}, fmt.Errorf("exiftool %s: %w", path, err)
35 }
36 return parseExif(out)
37}
38
39func (m *Magick) Resize(src string, width int, dst io.Writer) error {
40 cmd := exec.Command("magick", src,
41 "-auto-orient", "-resize", strconv.Itoa(width)+"x",
42 "-strip", "-quality", "82", "jpg:-")
43 var stderr bytes.Buffer
44 cmd.Stdout = dst
45 cmd.Stderr = &stderr
46 if err := cmd.Run(); err != nil {
47 return fmt.Errorf("magick %s: %w: %s", src, err, strings.TrimSpace(stderr.String()))
48 }
49 return nil
50}
51
52// Default returns the formats supported in v1.
53func Default() *Registry {
54 r := &Registry{}
55 r.Register(NewMagick("jpeg", ".jpg", ".jpeg"))
56 r.Register(NewMagick("png", ".png"))
57 return r
58}
59
60// CheckTools reports the first required external tool missing from PATH.
61func CheckTools() error {
62 for _, tool := range []string{"magick", "exiftool"} {
63 if _, err := exec.LookPath(tool); err != nil {
64 return fmt.Errorf("%s not found on PATH", tool)
65 }
66 }
67 return nil
68}
internal/format/magick_test.go added +120
@@ -0,0 +1,120 @@
1package format
2
3import (
4 "bytes"
5 "fmt"
6 "image/jpeg"
7 "os/exec"
8 "path/filepath"
9 "testing"
10 "time"
11)
12
13func requireTools(t *testing.T) {
14 t.Helper()
15 if err := CheckTools(); err != nil {
16 t.Skip(err)
17 }
18}
19
20func makeImage(t *testing.T, name string, w, h int) string {
21 t.Helper()
22 p := filepath.Join(t.TempDir(), name)
23 run(t, "magick", "-size", fmt.Sprintf("%dx%d", w, h), "xc:gray", p)
24 return p
25}
26
27func run(t *testing.T, name string, args ...string) {
28 t.Helper()
29 if out, err := exec.Command(name, args...).CombinedOutput(); err != nil {
30 t.Fatalf("%s %v: %v\n%s", name, args, err, out)
31 }
32}
33
34func resizedSize(t *testing.T, f Format, src string, w int) (int, int) {
35 t.Helper()
36 var buf bytes.Buffer
37 if err := f.Resize(src, w, &buf); err != nil {
38 t.Fatal(err)
39 }
40 cfg, err := jpeg.DecodeConfig(&buf)
41 if err != nil {
42 t.Fatalf("output is not JPEG: %v", err)
43 }
44 return cfg.Width, cfg.Height
45}
46
47func TestMagickJPEG(t *testing.T) {
48 requireTools(t)
49 p := makeImage(t, "a.jpg", 300, 200)
50 run(t, "exiftool", "-overwrite_original", "-DateTimeOriginal=2025:03:14 23:10:05", "-Model=TestCam", p)
51
52 f := NewMagick("jpeg", ".jpg", ".jpeg")
53 m, err := f.Metadata(p)
54 if err != nil {
55 t.Fatal(err)
56 }
57 if m.Width != 300 || m.Height != 200 || m.Camera != "TestCam" {
58 t.Fatalf("got %+v", m)
59 }
60 if want := time.Date(2025, 3, 14, 23, 10, 5, 0, time.UTC); !m.Taken.Equal(want) {
61 t.Fatalf("Taken = %v", m.Taken)
62 }
63 if w, h := resizedSize(t, f, p, 150); w != 150 || h != 100 {
64 t.Fatalf("resized to %dx%d, want 150x100", w, h)
65 }
66}
67
68func TestMagickOrientation(t *testing.T) {
69 requireTools(t)
70 p := makeImage(t, "r.jpg", 300, 200)
71 run(t, "exiftool", "-overwrite_original", "-n", "-Orientation=6", p)
72
73 f := NewMagick("jpeg", ".jpg")
74 m, err := f.Metadata(p)
75 if err != nil {
76 t.Fatal(err)
77 }
78 if m.Width != 200 || m.Height != 300 {
79 t.Fatalf("metadata %dx%d, want 200x300", m.Width, m.Height)
80 }
81 if w, h := resizedSize(t, f, p, 100); w != 100 || h != 150 {
82 t.Fatalf("resized to %dx%d, want 100x150", w, h)
83 }
84}
85
86func TestMagickPNG(t *testing.T) {
87 requireTools(t)
88 p := makeImage(t, "a.png", 120, 80)
89 f := NewMagick("png", ".png")
90 m, err := f.Metadata(p)
91 if err != nil {
92 t.Fatal(err)
93 }
94 if m.Width != 120 || m.Height != 80 {
95 t.Fatalf("got %+v", m)
96 }
97 if w, h := resizedSize(t, f, p, 60); w != 60 || h != 40 {
98 t.Fatalf("resized to %dx%d, want 60x40", w, h)
99 }
100}
101
102func TestMagickResizeError(t *testing.T) {
103 requireTools(t)
104 var buf bytes.Buffer
105 if err := NewMagick("jpeg", ".jpg").Resize(filepath.Join(t.TempDir(), "missing.jpg"), 100, &buf); err == nil {
106 t.Fatal("want error")
107 }
108}
109
110func TestDefaultRegistry(t *testing.T) {
111 r := Default()
112 for path, want := range map[string]string{"a.JPG": "jpeg", "a.jpeg": "jpeg", "a.png": "png"} {
113 if f := r.Lookup(path); f == nil || f.Name() != want {
114 t.Errorf("%s: got %v, want %s", path, f, want)
115 }
116 }
117 if r.Lookup("a.heic") != nil {
118 t.Error("heic should not match in v1")
119 }
120}
internal/library/library.go added +117
@@ -0,0 +1,117 @@
1// Package library turns the photo folder tree into albums and items.
2package library
3
4import (
5 "html/template"
6 "sort"
7 "strconv"
8 "sync/atomic"
9 "time"
10
11 "gitbay.org/cmc/gallery/internal/format"
12)
13
14type Library struct {
15 Albums []*Album
16 About template.HTML
17 Incomplete bool // set when the scan found zero albums or an album directory failed to read
18 bySlug map[string]*Album
19}
20
21func (l *Library) Album(slug string) *Album {
22 return l.bySlug[slug]
23}
24
25type Album struct {
26 Slug string
27 Title string
28 Date string
29 Description template.HTML
30 Order int
31 HasOrder bool
32 Cover *Item
33 Items []*Item
34 byName map[string]int
35}
36
37func (a *Album) Item(name string) (int, *Item) {
38 i, ok := a.byName[name]
39 if !ok {
40 return -1, nil
41 }
42 return i, a.Items[i]
43}
44
45// Year is the album's #+date year, else the cover's taken year.
46func (a *Album) Year() string {
47 if len(a.Date) >= 4 {
48 return a.Date[:4]
49 }
50 if a.Cover != nil && !a.Cover.Taken.IsZero() {
51 return strconv.Itoa(a.Cover.Taken.Year())
52 }
53 return ""
54}
55
56func (a *Album) sortTime() time.Time {
57 for _, layout := range []string{"2006-01-02", "2006-01", "2006"} {
58 if t, err := time.Parse(layout, a.Date); err == nil {
59 return t
60 }
61 }
62 var newest time.Time
63 for _, it := range a.Items {
64 if it.Taken.After(newest) {
65 newest = it.Taken
66 }
67 }
68 return newest
69}
70
71type Item struct {
72 Album string
73 Name string
74 Path string
75 ModTime time.Time
76 Size int64
77 Taken time.Time // EXIF date if set, else ModTime.
78 Meta format.Meta
79 Format format.Format
80}
81
82// Store holds the current Library snapshot.
83type Store struct {
84 p atomic.Pointer[Library]
85}
86
87func (s *Store) Load() *Library { return s.p.Load() }
88func (s *Store) Set(l *Library) { s.p.Store(l) }
89
90// sortAlbums puts albums with #+order first (ascending), then the rest newest first.
91func sortAlbums(as []*Album) {
92 sort.SliceStable(as, func(i, j int) bool {
93 a, b := as[i], as[j]
94 if a.HasOrder != b.HasOrder {
95 return a.HasOrder
96 }
97 if a.HasOrder && a.Order != b.Order {
98 return a.Order < b.Order
99 }
100 if !a.HasOrder {
101 if ta, tb := a.sortTime(), b.sortTime(); !ta.Equal(tb) {
102 return ta.After(tb)
103 }
104 }
105 return a.Slug < b.Slug
106 })
107}
108
109func sortItems(items []*Item, by string) {
110 sort.SliceStable(items, func(i, j int) bool {
111 a, b := items[i], items[j]
112 if by == "taken" && !a.Taken.Equal(b.Taken) {
113 return a.Taken.Before(b.Taken)
114 }
115 return a.Name < b.Name
116 })
117}
internal/library/metacache.go added +72
@@ -0,0 +1,72 @@
1package library
2
3import (
4 "encoding/json"
5 "errors"
6 "io/fs"
7 "log"
8 "os"
9 "path/filepath"
10
11 "gitbay.org/cmc/gallery/internal/format"
12)
13
14type metaEntry struct {
15 ModTime int64 `json:"mtime"`
16 Size int64 `json:"size"`
17 Meta format.Meta `json:"meta"`
18}
19
20// MetaCache persists extracted metadata keyed by album-relative path.
21type MetaCache struct {
22 path string
23 entries map[string]metaEntry
24}
25
26func OpenMetaCache(path string) (*MetaCache, error) {
27 c := &MetaCache{path: path, entries: map[string]metaEntry{}}
28 data, err := os.ReadFile(path)
29 if errors.Is(err, fs.ErrNotExist) {
30 return c, nil
31 }
32 if err != nil {
33 return nil, err
34 }
35 if err := json.Unmarshal(data, &c.entries); err != nil {
36 log.Printf("meta cache %s unreadable, starting empty: %v", path, err)
37 c.entries = map[string]metaEntry{}
38 }
39 return c, nil
40}
41
42func (c *MetaCache) get(rel string, fi fs.FileInfo) (format.Meta, bool) {
43 e, ok := c.entries[rel]
44 if !ok || e.ModTime != fi.ModTime().UnixNano() || e.Size != fi.Size() {
45 return format.Meta{}, false
46 }
47 return e.Meta, true
48}
49
50func (c *MetaCache) put(rel string, fi fs.FileInfo, m format.Meta) {
51 c.entries[rel] = metaEntry{ModTime: fi.ModTime().UnixNano(), Size: fi.Size(), Meta: m}
52}
53
54func (c *MetaCache) save(keep map[string]bool) error {
55 for k := range c.entries {
56 if !keep[k] {
57 delete(c.entries, k)
58 }
59 }
60 data, err := json.Marshal(c.entries)
61 if err != nil {
62 return err
63 }
64 if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
65 return err
66 }
67 tmp := c.path + ".tmp"
68 if err := os.WriteFile(tmp, data, 0o644); err != nil {
69 return err
70 }
71 return os.Rename(tmp, c.path)
72}
internal/library/metacache_test.go added +85
@@ -0,0 +1,85 @@
1package library
2
3import (
4 "io/fs"
5 "os"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "gitbay.org/cmc/gallery/internal/format"
11)
12
13func statFile(t *testing.T, body string) (string, fs.FileInfo) {
14 t.Helper()
15 p := filepath.Join(t.TempDir(), "a.jpg")
16 if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
17 t.Fatal(err)
18 }
19 fi, err := os.Stat(p)
20 if err != nil {
21 t.Fatal(err)
22 }
23 return p, fi
24}
25
26func TestMetaCacheRoundTrip(t *testing.T) {
27 path := filepath.Join(t.TempDir(), "sub", "meta.json")
28 c, err := OpenMetaCache(path)
29 if err != nil {
30 t.Fatal(err)
31 }
32 _, fi := statFile(t, "x")
33 m := format.Meta{Width: 3, Height: 2, Camera: "X-T5", Taken: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC)}
34 c.put("tokyo/a.jpg", fi, m)
35 c.put("gone/b.jpg", fi, m)
36 if err := c.save(map[string]bool{"tokyo/a.jpg": true}); err != nil {
37 t.Fatal(err)
38 }
39
40 c2, err := OpenMetaCache(path)
41 if err != nil {
42 t.Fatal(err)
43 }
44 got, ok := c2.get("tokyo/a.jpg", fi)
45 if !ok || !got.Taken.Equal(m.Taken) || got.Camera != "X-T5" || got.Width != 3 {
46 t.Fatalf("get = %+v, %v", got, ok)
47 }
48 if _, ok := c2.get("gone/b.jpg", fi); ok {
49 t.Fatal("entry outside keep survived save")
50 }
51}
52
53func TestMetaCacheMissOnChange(t *testing.T) {
54 c, _ := OpenMetaCache(filepath.Join(t.TempDir(), "meta.json"))
55 p, fi := statFile(t, "x")
56 c.put("a.jpg", fi, format.Meta{Width: 1, Height: 1})
57
58 if err := os.WriteFile(p, []byte("longer"), 0o644); err != nil {
59 t.Fatal(err)
60 }
61 fi2, _ := os.Stat(p)
62 if _, ok := c.get("a.jpg", fi2); ok {
63 t.Fatal("size change should miss")
64 }
65
66 later := fi.ModTime().Add(time.Hour)
67 os.WriteFile(p, []byte("x"), 0o644)
68 os.Chtimes(p, later, later)
69 fi3, _ := os.Stat(p)
70 if _, ok := c.get("a.jpg", fi3); ok {
71 t.Fatal("mtime change should miss")
72 }
73}
74
75func TestMetaCacheCorruptStartsEmpty(t *testing.T) {
76 path := filepath.Join(t.TempDir(), "meta.json")
77 os.WriteFile(path, []byte("{nope"), 0o644)
78 c, err := OpenMetaCache(path)
79 if err != nil {
80 t.Fatal(err)
81 }
82 if len(c.entries) != 0 {
83 t.Fatalf("entries = %v", c.entries)
84 }
85}
internal/library/org.go added +34
@@ -0,0 +1,34 @@
1package library
2
3import (
4 "html/template"
5 "strings"
6
7 "github.com/niklasfasching/go-org/org"
8)
9
10// parseOrg splits "#+KEY: value" lines out as lower-cased keywords and
11// renders the rest of the document to HTML.
12func parseOrg(src []byte, path string) (map[string]string, template.HTML, error) {
13 kw := map[string]string{}
14 var body []string
15 for _, line := range strings.Split(string(src), "\n") {
16 t := strings.TrimSpace(line)
17 if rest, ok := strings.CutPrefix(t, "#+"); ok {
18 if k, v, ok := strings.Cut(rest, ":"); ok && k != "" && !strings.ContainsAny(k, " \t") {
19 kw[strings.ToLower(k)] = strings.TrimSpace(v)
20 continue
21 }
22 }
23 body = append(body, line)
24 }
25 text := strings.TrimSpace(strings.Join(body, "\n"))
26 if text == "" {
27 return kw, "", nil
28 }
29 out, err := org.New().Parse(strings.NewReader(text), path).Write(org.NewHTMLWriter())
30 if err != nil {
31 return nil, "", err
32 }
33 return kw, template.HTML(out), nil
34}
internal/library/org_test.go added +32
@@ -0,0 +1,32 @@
1package library
2
3import (
4 "strings"
5 "testing"
6)
7
8func TestParseOrg(t *testing.T) {
9 src := "#+title: Tokyo: nights\n#+COVER: a.jpg\n\n#+begin_quote\nA /quiet/ week.\n#+end_quote\n"
10 kw, html, err := parseOrg([]byte(src), "album.org")
11 if err != nil {
12 t.Fatal(err)
13 }
14 if len(kw) != 2 || kw["title"] != "Tokyo: nights" || kw["cover"] != "a.jpg" {
15 t.Fatalf("keywords = %v", kw)
16 }
17 for _, want := range []string{"<blockquote>", "<em>quiet</em>"} {
18 if !strings.Contains(string(html), want) {
19 t.Errorf("html missing %q:\n%s", want, html)
20 }
21 }
22}
23
24func TestParseOrgKeywordsOnly(t *testing.T) {
25 kw, html, err := parseOrg([]byte("#+title: x\n\n"), "album.org")
26 if err != nil {
27 t.Fatal(err)
28 }
29 if kw["title"] != "x" || html != "" {
30 t.Fatalf("kw=%v html=%q", kw, html)
31 }
32}
internal/library/scan.go added +150
@@ -0,0 +1,150 @@
1package library
2
3import (
4 "log"
5 "os"
6 "path/filepath"
7 "strconv"
8 "strings"
9
10 "gitbay.org/cmc/gallery/internal/format"
11)
12
13// Slugs that would collide with fixed routes.
14var reserved = map[string]bool{"img": true, "info": true, "_": true}
15
16// Scan reads every album under root. Unreadable albums and files are logged and skipped.
17func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error) {
18 entries, err := os.ReadDir(root)
19 if err != nil {
20 return nil, err
21 }
22 lib := &Library{bySlug: map[string]*Album{}}
23 keep := map[string]bool{}
24 for _, e := range entries {
25 name := e.Name()
26 if !e.IsDir() || strings.HasPrefix(name, ".") {
27 continue
28 }
29 if reserved[name] {
30 log.Printf("skipping album %q: reserved name", name)
31 continue
32 }
33 a, err := scanAlbum(root, name, reg, cache, keep)
34 if err != nil {
35 log.Printf("album %s: %v", name, err)
36 lib.Incomplete = true
37 continue
38 }
39 if len(a.Items) == 0 {
40 continue
41 }
42 lib.Albums = append(lib.Albums, a)
43 lib.bySlug[name] = a
44 }
45 sortAlbums(lib.Albums)
46 if len(lib.Albums) == 0 {
47 lib.Incomplete = true
48 }
49
50 if src, err := os.ReadFile(filepath.Join(root, "about.org")); err == nil {
51 if _, html, err := parseOrg(src, "about.org"); err != nil {
52 log.Printf("about.org: %v", err)
53 } else {
54 lib.About = html
55 }
56 }
57 if lib.Incomplete {
58 log.Printf("scan incomplete: keeping existing meta cache")
59 } else if err := cache.save(keep); err != nil {
60 log.Printf("meta cache: %v", err)
61 }
62 return lib, nil
63}
64
65func scanAlbum(root, slug string, reg *format.Registry, cache *MetaCache, keep map[string]bool) (*Album, error) {
66 dir := filepath.Join(root, slug)
67 entries, err := os.ReadDir(dir)
68 if err != nil {
69 return nil, err
70 }
71 a := &Album{Slug: slug, Title: slug, byName: map[string]int{}}
72 sortBy, cover := "taken", ""
73
74 if src, err := os.ReadFile(filepath.Join(dir, "album.org")); err == nil {
75 kw, desc, err := parseOrg(src, filepath.Join(slug, "album.org"))
76 if err != nil {
77 log.Printf("%s/album.org: %v", slug, err)
78 } else {
79 if v := kw["title"]; v != "" {
80 a.Title = v
81 }
82 a.Date, a.Description, cover = kw["date"], desc, kw["cover"]
83 if v := kw["order"]; v != "" {
84 if n, err := strconv.Atoi(v); err == nil {
85 a.Order, a.HasOrder = n, true
86 } else {
87 log.Printf("%s/album.org: bad order %q", slug, v)
88 }
89 }
90 switch v := kw["sort"]; v {
91 case "", "taken":
92 case "name":
93 sortBy = v
94 default:
95 log.Printf("%s/album.org: bad sort %q", slug, v)
96 }
97 }
98 }
99
100 for _, e := range entries {
101 name := e.Name()
102 if e.IsDir() || strings.HasPrefix(name, ".") {
103 continue
104 }
105 path := filepath.Join(dir, name)
106 f := reg.Lookup(path)
107 if f == nil {
108 continue
109 }
110 fi, err := e.Info()
111 if err != nil {
112 log.Printf("%s/%s: %v", slug, name, err)
113 continue
114 }
115 rel := slug + "/" + name
116 m, ok := cache.get(rel, fi)
117 if !ok {
118 if m, err = f.Metadata(path); err != nil {
119 log.Printf("%s: %v", rel, err)
120 continue
121 }
122 cache.put(rel, fi, m)
123 }
124 keep[rel] = true
125 taken := m.Taken
126 if taken.IsZero() {
127 taken = fi.ModTime()
128 }
129 a.Items = append(a.Items, &Item{
130 Album: slug, Name: name, Path: path,
131 ModTime: fi.ModTime(), Size: fi.Size(),
132 Taken: taken,
133 Meta: m, Format: f,
134 })
135 }
136
137 sortItems(a.Items, sortBy)
138 for i, it := range a.Items {
139 a.byName[it.Name] = i
140 }
141 if i, ok := a.byName[cover]; ok {
142 a.Cover = a.Items[i]
143 } else if len(a.Items) > 0 {
144 if cover != "" {
145 log.Printf("%s/album.org: cover %q not found", slug, cover)
146 }
147 a.Cover = a.Items[0]
148 }
149 return a, nil
150}
internal/library/scan_test.go added +264
@@ -0,0 +1,264 @@
1package library
2
3import (
4 "errors"
5 "io"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "gitbay.org/cmc/gallery/internal/format"
13)
14
15type fakeFormat struct {
16 meta map[string]format.Meta // by base name
17 fail map[string]bool
18 calls int
19}
20
21func (f *fakeFormat) Name() string { return "fake" }
22func (f *fakeFormat) Match(p string) bool { return strings.EqualFold(filepath.Ext(p), ".jpg") }
23func (f *fakeFormat) Kind() format.Kind { return format.KindImage }
24func (f *fakeFormat) Resize(string, int, io.Writer) error { return nil }
25func (f *fakeFormat) Metadata(p string) (format.Meta, error) {
26 f.calls++
27 name := filepath.Base(p)
28 if f.fail[name] {
29 return format.Meta{}, errors.New("bad file")
30 }
31 if m, ok := f.meta[name]; ok {
32 return m, nil
33 }
34 return format.Meta{Width: 300, Height: 200}, nil
35}
36
37func day(n int) time.Time { return time.Date(2025, 3, n, 0, 0, 0, 0, time.UTC) }
38
39func writeTree(t *testing.T, files map[string]string) string {
40 t.Helper()
41 root := t.TempDir()
42 for rel, body := range files {
43 p := filepath.Join(root, rel)
44 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
45 t.Fatal(err)
46 }
47 if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
48 t.Fatal(err)
49 }
50 }
51 return root
52}
53
54func scanWith(t *testing.T, root string, f *fakeFormat, cache *MetaCache) *Library {
55 t.Helper()
56 if cache == nil {
57 var err error
58 if cache, err = OpenMetaCache(filepath.Join(t.TempDir(), "meta.json")); err != nil {
59 t.Fatal(err)
60 }
61 }
62 reg := &format.Registry{}
63 reg.Register(f)
64 lib, err := Scan(root, reg, cache)
65 if err != nil {
66 t.Fatal(err)
67 }
68 return lib
69}
70
71func names(items []*Item) string {
72 var s []string
73 for _, it := range items {
74 s = append(s, it.Name)
75 }
76 return strings.Join(s, ",")
77}
78
79func slugs(as []*Album) string {
80 var s []string
81 for _, a := range as {
82 s = append(s, a.Slug)
83 }
84 return strings.Join(s, ",")
85}
86
87func TestScanSkipsAndSorts(t *testing.T) {
88 root := writeTree(t, map[string]string{
89 "tokyo/b.jpg": "x",
90 "tokyo/a.jpg": "x",
91 "tokyo/.hidden.jpg": "x",
92 "tokyo/notes.txt": "x",
93 "tokyo/sub/c.jpg": "x",
94 ".git/x.jpg": "x",
95 "img/x.jpg": "x",
96 "info/x.jpg": "x",
97 "_/x.jpg": "x",
98 "empty/notes.txt": "x",
99 "loose.jpg": "x",
100 })
101 f := &fakeFormat{meta: map[string]format.Meta{
102 "a.jpg": {Width: 300, Height: 200, Taken: day(2)},
103 "b.jpg": {Width: 300, Height: 200, Taken: day(1)},
104 }}
105 lib := scanWith(t, root, f, nil)
106
107 if got := slugs(lib.Albums); got != "tokyo" {
108 t.Fatalf("albums = %s", got)
109 }
110 a := lib.Album("tokyo")
111 if got := names(a.Items); got != "b.jpg,a.jpg" {
112 t.Fatalf("items = %s", got)
113 }
114 if a.Title != "tokyo" || a.Cover != a.Items[0] || a.Year() != "2025" {
115 t.Fatalf("title %q cover %v year %q", a.Title, a.Cover, a.Year())
116 }
117 if i, it := a.Item("a.jpg"); i != 1 || it.Path != filepath.Join(root, "tokyo", "a.jpg") {
118 t.Fatalf("Item(a.jpg) = %d, %+v", i, it)
119 }
120 if i, it := a.Item("nope.jpg"); i != -1 || it != nil {
121 t.Fatal("Item(nope.jpg) should be absent")
122 }
123 if lib.Album("nope") != nil {
124 t.Fatal("Album(nope) should be nil")
125 }
126}
127
128func TestScanAlbumOrg(t *testing.T) {
129 root := writeTree(t, map[string]string{
130 "tokyo/album.org": "#+title: Tokyo\n#+date: 2024-11\n#+cover: b.jpg\n#+sort: name\n\nA /quiet/ week.\n",
131 "tokyo/b.jpg": "x",
132 "tokyo/a.jpg": "x",
133 })
134 f := &fakeFormat{meta: map[string]format.Meta{
135 "a.jpg": {Width: 1, Height: 1, Taken: day(2)},
136 "b.jpg": {Width: 1, Height: 1, Taken: day(1)},
137 }}
138 a := scanWith(t, root, f, nil).Album("tokyo")
139 if a.Title != "Tokyo" || a.Date != "2024-11" || a.Year() != "2024" {
140 t.Fatalf("title %q date %q year %q", a.Title, a.Date, a.Year())
141 }
142 if got := names(a.Items); got != "a.jpg,b.jpg" {
143 t.Fatalf("sort name: items = %s", got)
144 }
145 if a.Cover.Name != "b.jpg" {
146 t.Fatalf("cover = %s", a.Cover.Name)
147 }
148 if !strings.Contains(string(a.Description), "<em>quiet</em>") {
149 t.Fatalf("description = %s", a.Description)
150 }
151}
152
153func TestScanAlbumOrdering(t *testing.T) {
154 root := writeTree(t, map[string]string{
155 "a/album.org": "#+order: 2\n",
156 "a/x.jpg": "x",
157 "b/album.org": "#+order: 1\n",
158 "b/x.jpg": "x",
159 "c/album.org": "#+date: 2024\n",
160 "c/x.jpg": "x",
161 "d/album.org": "#+date: 2025-06\n",
162 "d/x.jpg": "x",
163 "e/x.jpg": "x", // no album.org; newest item taken is day(1) of 2025-03
164 })
165 f := &fakeFormat{meta: map[string]format.Meta{"x.jpg": {Width: 1, Height: 1, Taken: day(1)}}}
166 if got := slugs(scanWith(t, root, f, nil).Albums); got != "b,a,d,e,c" {
167 t.Fatalf("albums = %s", got)
168 }
169}
170
171func TestScanTakenFallsBackToModTime(t *testing.T) {
172 root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"})
173 mt := time.Date(2023, 5, 6, 7, 8, 9, 0, time.UTC)
174 os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), mt, mt)
175 a := scanWith(t, root, &fakeFormat{}, nil).Album("tokyo")
176 if !a.Items[0].Taken.Equal(mt) {
177 t.Fatalf("Taken = %v, want %v", a.Items[0].Taken, mt)
178 }
179 if !a.Items[0].Meta.Taken.IsZero() {
180 t.Fatalf("Meta.Taken = %v, want zero", a.Items[0].Meta.Taken)
181 }
182}
183
184func TestScanSkipsFailedMetadata(t *testing.T) {
185 root := writeTree(t, map[string]string{"tokyo/a.jpg": "x", "tokyo/bad.jpg": "x"})
186 a := scanWith(t, root, &fakeFormat{fail: map[string]bool{"bad.jpg": true}}, nil).Album("tokyo")
187 if got := names(a.Items); got != "a.jpg" {
188 t.Fatalf("items = %s", got)
189 }
190}
191
192func TestScanUsesMetaCache(t *testing.T) {
193 root := writeTree(t, map[string]string{"tokyo/a.jpg": "x", "tokyo/b.jpg": "x"})
194 cache, _ := OpenMetaCache(filepath.Join(t.TempDir(), "meta.json"))
195 f := &fakeFormat{}
196 scanWith(t, root, f, cache)
197 scanWith(t, root, f, cache)
198 if f.calls != 2 {
199 t.Fatalf("calls after two scans = %d, want 2", f.calls)
200 }
201 later := time.Now().Add(time.Hour)
202 os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), later, later)
203 scanWith(t, root, f, cache)
204 if f.calls != 3 {
205 t.Fatalf("calls after touch = %d, want 3", f.calls)
206 }
207}
208
209func TestScanAbout(t *testing.T) {
210 root := writeTree(t, map[string]string{"about.org": "Hello /there/.\n", "tokyo/a.jpg": "x"})
211 lib := scanWith(t, root, &fakeFormat{}, nil)
212 if !strings.Contains(string(lib.About), "<em>there</em>") {
213 t.Fatalf("About = %s", lib.About)
214 }
215 root2 := writeTree(t, map[string]string{"tokyo/a.jpg": "x"})
216 if lib := scanWith(t, root2, &fakeFormat{}, nil); lib.About != "" {
217 t.Fatalf("About without file = %q", lib.About)
218 }
219}
220
221func TestScanIncomplete(t *testing.T) {
222 root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"})
223 cachePath := filepath.Join(t.TempDir(), "meta.json")
224 cache, err := OpenMetaCache(cachePath)
225 if err != nil {
226 t.Fatal(err)
227 }
228 f := &fakeFormat{}
229
230 lib := scanWith(t, root, f, cache)
231 if lib.Incomplete {
232 t.Fatal("normal scan should not be Incomplete")
233 }
234 if f.calls != 1 {
235 t.Fatalf("calls = %d, want 1", f.calls)
236 }
237
238 empty := t.TempDir()
239 lib2 := scanWith(t, empty, f, cache)
240 if !lib2.Incomplete {
241 t.Fatal("empty root scan should be Incomplete")
242 }
243
244 // The cache entry from the first scan must have survived the incomplete scan.
245 lib3 := scanWith(t, root, f, cache)
246 if lib3.Incomplete {
247 t.Fatal("rescan of the normal root should not be Incomplete")
248 }
249 if f.calls != 1 {
250 t.Fatalf("calls after rescan = %d, want 1 (cache entry should have survived the incomplete scan)", f.calls)
251 }
252}
253
254func TestStore(t *testing.T) {
255 var s Store
256 if s.Load() != nil {
257 t.Fatal("empty store should load nil")
258 }
259 lib := &Library{}
260 s.Set(lib)
261 if s.Load() != lib {
262 t.Fatal("Load after Set")
263 }
264}
internal/library/watch.go added +73
@@ -0,0 +1,73 @@
1package library
2
3import (
4 "context"
5 "log"
6 "os"
7 "path/filepath"
8 "strings"
9 "time"
10
11 "github.com/fsnotify/fsnotify"
12)
13
14// Watch calls onChange after filesystem activity under root has been quiet for debounce.
15func Watch(ctx context.Context, root string, debounce time.Duration, onChange func()) error {
16 w, err := fsnotify.NewWatcher()
17 if err != nil {
18 return err
19 }
20 if err := addTree(w, root); err != nil {
21 w.Close()
22 return err
23 }
24 go func() {
25 defer w.Close()
26 timer := time.NewTimer(debounce)
27 timer.Stop()
28 for {
29 select {
30 case <-ctx.Done():
31 return
32 case ev, ok := <-w.Events:
33 if !ok {
34 return
35 }
36 if ev.Has(fsnotify.Create) {
37 if fi, err := os.Stat(ev.Name); err == nil && fi.IsDir() {
38 if err := w.Add(ev.Name); err != nil {
39 log.Printf("watch %s: %v", ev.Name, err)
40 }
41 }
42 }
43 timer.Reset(debounce)
44 case err, ok := <-w.Errors:
45 if !ok {
46 return
47 }
48 log.Printf("watch: %v", err)
49 case <-timer.C:
50 onChange()
51 }
52 }
53 }()
54 return nil
55}
56
57func addTree(w *fsnotify.Watcher, root string) error {
58 if err := w.Add(root); err != nil {
59 return err
60 }
61 entries, err := os.ReadDir(root)
62 if err != nil {
63 return err
64 }
65 for _, e := range entries {
66 if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
67 if err := w.Add(filepath.Join(root, e.Name())); err != nil {
68 return err
69 }
70 }
71 }
72 return nil
73}
internal/library/watch_test.go added +45
@@ -0,0 +1,45 @@
1package library
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8 "time"
9)
10
11func expectCall(t *testing.T, ch <-chan struct{}, what string) {
12 t.Helper()
13 select {
14 case <-ch:
15 case <-time.After(3 * time.Second):
16 t.Fatalf("no onChange after %s", what)
17 }
18}
19
20func TestWatchDebouncesAndFollowsNewDirs(t *testing.T) {
21 root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"})
22 ctx, cancel := context.WithCancel(context.Background())
23 defer cancel()
24
25 calls := make(chan struct{}, 10)
26 if err := Watch(ctx, root, 100*time.Millisecond, func() { calls <- struct{}{} }); err != nil {
27 t.Fatal(err)
28 }
29
30 for _, n := range []string{"b.jpg", "c.jpg", "d.jpg"} {
31 os.WriteFile(filepath.Join(root, "tokyo", n), []byte("x"), 0o644)
32 }
33 expectCall(t, calls, "writes in existing album")
34 select {
35 case <-calls:
36 t.Fatal("burst of writes produced more than one call")
37 case <-time.After(300 * time.Millisecond):
38 }
39
40 os.Mkdir(filepath.Join(root, "iceland"), 0o755)
41 expectCall(t, calls, "new album dir")
42 time.Sleep(150 * time.Millisecond) // let the watcher add the new dir
43 os.WriteFile(filepath.Join(root, "iceland", "a.jpg"), []byte("x"), 0o644)
44 expectCall(t, calls, "write in new album")
45}
internal/render/render.go added +111
@@ -0,0 +1,111 @@
1// Package render produces and caches resized copies of library items.
2package render
3
4import (
5 "fmt"
6 "log"
7 "os"
8 "path/filepath"
9 "slices"
10 "strconv"
11
12 "golang.org/x/sync/singleflight"
13
14 "gitbay.org/cmc/gallery/internal/library"
15)
16
17var Widths = []int{480, 960, 1600, 2400}
18
19func Allowed(w int) bool { return slices.Contains(Widths, w) }
20
21type Renderer struct {
22 root string
23 sem chan struct{}
24 group singleflight.Group
25}
26
27func New(cacheDir string, concurrency int) *Renderer {
28 return &Renderer{
29 root: filepath.Join(cacheDir, "img"),
30 sem: make(chan struct{}, concurrency),
31 }
32}
33
34func (r *Renderer) dir(it *library.Item) string {
35 return filepath.Join(r.root, it.Album, it.Name, fmt.Sprintf("%d-%d", it.ModTime.UnixNano(), it.Size))
36}
37
38func (r *Renderer) Path(it *library.Item, w int) string {
39 return filepath.Join(r.dir(it), strconv.Itoa(w)+".jpg")
40}
41
42// Get returns the cached derivative for it at width w, generating it if needed.
43func (r *Renderer) Get(it *library.Item, w int) (string, error) {
44 if !Allowed(w) {
45 return "", fmt.Errorf("width %d not allowed", w)
46 }
47 p := r.Path(it, w)
48 if _, err := os.Stat(p); err == nil {
49 return p, nil
50 }
51 _, err, _ := r.group.Do(p, func() (any, error) {
52 if _, err := os.Stat(p); err == nil {
53 return nil, nil
54 }
55 r.sem <- struct{}{}
56 defer func() { <-r.sem }()
57 return nil, r.generate(it, w, p)
58 })
59 if err != nil {
60 return "", err
61 }
62 return p, nil
63}
64
65func (r *Renderer) generate(it *library.Item, w int, dst string) error {
66 if it.Meta.Width > 0 && w > it.Meta.Width {
67 w = it.Meta.Width
68 }
69 if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
70 return err
71 }
72 tmp, err := os.CreateTemp(filepath.Dir(dst), ".tmp-*")
73 if err != nil {
74 return err
75 }
76 defer os.Remove(tmp.Name())
77 if err := it.Format.Resize(it.Path, w, tmp); err != nil {
78 tmp.Close()
79 return err
80 }
81 if err := tmp.Close(); err != nil {
82 return err
83 }
84 return os.Rename(tmp.Name(), dst)
85}
86
87// Prune removes derivative directories for items no longer in lib or whose source changed.
88func (r *Renderer) Prune(lib *library.Library) error {
89 keep := map[string]bool{}
90 for _, a := range lib.Albums {
91 for _, it := range a.Items {
92 keep[r.dir(it)] = true
93 }
94 }
95 versions, err := filepath.Glob(filepath.Join(r.root, "*", "*", "*"))
96 if err != nil {
97 return err
98 }
99 var firstErr error
100 for _, v := range versions {
101 if !keep[v] {
102 if err := os.RemoveAll(v); err != nil {
103 log.Printf("prune %s: %v", v, err)
104 if firstErr == nil {
105 firstErr = err
106 }
107 }
108 }
109 }
110 return firstErr
111}
internal/render/render_test.go added +166
@@ -0,0 +1,166 @@
1package render
2
3import (
4 "errors"
5 "io"
6 "os"
7 "path/filepath"
8 "sync"
9 "sync/atomic"
10 "testing"
11 "time"
12
13 "gitbay.org/cmc/gallery/internal/format"
14 "gitbay.org/cmc/gallery/internal/library"
15)
16
17type fakeFormat struct {
18 calls atomic.Int32
19 width atomic.Int32
20 fail bool
21}
22
23func (f *fakeFormat) Name() string { return "fake" }
24func (f *fakeFormat) Match(string) bool { return true }
25func (f *fakeFormat) Kind() format.Kind { return format.KindImage }
26func (f *fakeFormat) Metadata(string) (format.Meta, error) { return format.Meta{}, nil }
27func (f *fakeFormat) Resize(_ string, w int, dst io.Writer) error {
28 f.calls.Add(1)
29 f.width.Store(int32(w))
30 time.Sleep(20 * time.Millisecond)
31 io.WriteString(dst, "partial")
32 if f.fail {
33 return errors.New("magick failed")
34 }
35 io.WriteString(dst, "-done")
36 return nil
37}
38
39func item(f format.Format) *library.Item {
40 return &library.Item{
41 Album: "tokyo", Name: "a.jpg", Path: "/src/a.jpg",
42 ModTime: time.Unix(100, 0), Size: 42,
43 Meta: format.Meta{Width: 3000, Height: 2000},
44 Format: f,
45 }
46}
47
48func TestAllowed(t *testing.T) {
49 for _, w := range Widths {
50 if !Allowed(w) {
51 t.Errorf("%d should be allowed", w)
52 }
53 }
54 for _, w := range []int{0, 1, 961, 4000} {
55 if Allowed(w) {
56 t.Errorf("%d should not be allowed", w)
57 }
58 }
59}
60
61func TestGetGeneratesOnce(t *testing.T) {
62 f := &fakeFormat{}
63 r := New(t.TempDir(), 4)
64 it := item(f)
65
66 var wg sync.WaitGroup
67 for range 10 {
68 wg.Add(1)
69 go func() {
70 defer wg.Done()
71 if _, err := r.Get(it, 960); err != nil {
72 t.Error(err)
73 }
74 }()
75 }
76 wg.Wait()
77 p, err := r.Get(it, 960)
78 if err != nil {
79 t.Fatal(err)
80 }
81 if n := f.calls.Load(); n != 1 {
82 t.Fatalf("Resize calls = %d, want 1", n)
83 }
84 if b, _ := os.ReadFile(p); string(b) != "partial-done" {
85 t.Fatalf("content = %q", b)
86 }
87 if p != r.Path(it, 960) {
88 t.Fatalf("Get path %s != Path %s", p, r.Path(it, 960))
89 }
90}
91
92func TestGetRejectsWidth(t *testing.T) {
93 if _, err := New(t.TempDir(), 1).Get(item(&fakeFormat{}), 961); err == nil {
94 t.Fatal("want error")
95 }
96}
97
98func TestGetClampsToSource(t *testing.T) {
99 f := &fakeFormat{}
100 it := item(f)
101 it.Meta.Width = 700
102 if _, err := New(t.TempDir(), 1).Get(it, 960); err != nil {
103 t.Fatal(err)
104 }
105 if w := f.width.Load(); w != 700 {
106 t.Fatalf("resized to %d, want 700", w)
107 }
108}
109
110func TestGetFailureLeavesNothing(t *testing.T) {
111 f := &fakeFormat{fail: true}
112 r := New(t.TempDir(), 1)
113 it := item(f)
114 if _, err := r.Get(it, 480); err == nil {
115 t.Fatal("want error")
116 }
117 entries, _ := os.ReadDir(filepath.Dir(r.Path(it, 480)))
118 if len(entries) != 0 {
119 t.Fatalf("left behind: %v", entries)
120 }
121 r.Get(it, 480)
122 if n := f.calls.Load(); n != 2 {
123 t.Fatalf("failure was cached: calls = %d", n)
124 }
125}
126
127func TestPathChangesWithSource(t *testing.T) {
128 r := New("/cache", 1)
129 a := item(nil)
130 b := item(nil)
131 b.ModTime = time.Unix(200, 0)
132 if r.Path(a, 480) == r.Path(b, 480) {
133 t.Fatal("path must change with mtime")
134 }
135 want := filepath.Join("/cache", "img", "tokyo", "a.jpg", "100000000000-42", "480.jpg")
136 if got := r.Path(a, 480); got != want {
137 t.Fatalf("Path = %s, want %s", got, want)
138 }
139}
140
141func TestPrune(t *testing.T) {
142 f := &fakeFormat{}
143 r := New(t.TempDir(), 1)
144 cur := item(f)
145 old := item(f)
146 old.ModTime = time.Unix(50, 0)
147 gone := item(f)
148 gone.Name = "gone.jpg"
149 for _, it := range []*library.Item{cur, old, gone} {
150 if _, err := r.Get(it, 480); err != nil {
151 t.Fatal(err)
152 }
153 }
154 lib := &library.Library{Albums: []*library.Album{{Slug: "tokyo", Items: []*library.Item{cur}}}}
155 if err := r.Prune(lib); err != nil {
156 t.Fatal(err)
157 }
158 if _, err := os.Stat(r.Path(cur, 480)); err != nil {
159 t.Fatalf("current derivative removed: %v", err)
160 }
161 for _, it := range []*library.Item{old, gone} {
162 if _, err := os.Stat(filepath.Dir(r.Path(it, 480))); !os.IsNotExist(err) {
163 t.Fatalf("stale dir kept: %s", filepath.Dir(r.Path(it, 480)))
164 }
165 }
166}
internal/web/assets/gallery.js added +47
@@ -0,0 +1,47 @@
1(() => {
2 // Justified rows: fill each row to the container width at roughly --row-height.
3 const layout = (el) => {
4 const cs = getComputedStyle(el);
5 const gap = parseFloat(cs.columnGap) || 0;
6 const target = parseFloat(cs.getPropertyValue('--row-height')) || 320;
7 const width = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
8 let row = [], sum = 0;
9 const flush = (last) => {
10 let h = (width - gap * (row.length - 1)) / sum;
11 if (last && h > target) h = target;
12 for (const t of row) {
13 t.style.width = Math.floor(h * t.dataset.ar) + 'px';
14 t.style.height = Math.floor(h) + 'px';
15 }
16 row = [];
17 sum = 0;
18 };
19 for (const t of el.children) {
20 row.push(t);
21 sum += +t.dataset.ar;
22 if ((width - gap * (row.length - 1)) / sum <= target) flush(false);
23 }
24 if (row.length) flush(true);
25 };
26 const layoutAll = () => document.querySelectorAll('.rows').forEach(layout);
27 let timer;
28 addEventListener('resize', () => { clearTimeout(timer); timer = setTimeout(layoutAll, 100); });
29 layoutAll();
30
31 const follow = (sel) => { const a = document.querySelector(sel); if (a) location.href = a.href; };
32 addEventListener('keydown', (e) => {
33 if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
34 if (e.key === 'ArrowLeft') follow('a[rel=prev]');
35 else if (e.key === 'ArrowRight') follow('a[rel=next]');
36 else if (e.key === 'Escape') follow('#up');
37 });
38
39 let x0 = null;
40 addEventListener('touchstart', (e) => { x0 = e.touches[0].clientX; }, { passive: true });
41 addEventListener('touchend', (e) => {
42 if (x0 === null) return;
43 const dx = e.changedTouches[0].clientX - x0;
44 x0 = null;
45 if (Math.abs(dx) > 50) follow(dx > 0 ? 'a[rel=prev]' : 'a[rel=next]');
46 }, { passive: true });
47})();
internal/web/assets/theme.css added +47
@@ -0,0 +1,47 @@
1:root {
2 --bg: #0a0a0a;
3 --fg: #dddddd;
4 --muted: #777777;
5 --accent: #ffffff;
6 --gap: 4px;
7 --row-height: 320px;
8 --font: "Helvetica Neue", Arial, sans-serif;
9 --tracking: 0.25em;
10}
11html { box-sizing: border-box; } *, *::before, *::after { box-sizing: inherit; }
12html, body { margin: 0; background: var(--bg); color: var(--fg); font: 13px/1.5 var(--font); }
13a { color: inherit; text-decoration: none; }
14
15nav.site { display: flex; flex-wrap: wrap; gap: 8px 22px; padding: 18px 20px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); }
16nav.site .brand { color: var(--accent); font-weight: 600; margin-right: auto; }
17nav.site a:hover, nav.site a.on { color: var(--accent); }
18
19.title { display: flex; justify-content: space-between; align-items: baseline; padding: 16px 20px 12px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); }
20.title h1 { margin: 0; font: inherit; color: var(--fg); }
21.desc { padding: 0 20px 16px; max-width: 60ch; color: var(--muted); }
22
23.rows { display: flex; flex-wrap: wrap; gap: var(--gap); padding: 0 var(--gap) var(--gap); }
24.tile { display: block; position: relative; height: var(--row-height); width: calc(var(--row-height) * var(--ar)); max-width: 100%; }
25.tile img { display: block; width: 100%; height: 100%; object-fit: contain; }
26.captioned { row-gap: 36px; padding-top: 4px; padding-bottom: 40px; }
27.captioned .label { position: absolute; top: 100%; left: 0; padding-top: 8px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); white-space: nowrap; }
28
29body.photo { display: flex; flex-direction: column; min-height: 100vh; }
30body.photo main { flex: 1; display: flex; flex-direction: column; }
31.stage { flex: 1; margin: 0; display: flex; align-items: center; justify-content: center; position: relative; padding: 0 48px; }
32.stage img { max-width: 100%; max-height: calc(100vh - 120px); width: auto; height: auto; }
33.stage .prev, .stage .next { position: absolute; top: 0; bottom: 0; width: 48px; display: flex; align-items: center; justify-content: center; font-size: 24px; color: var(--muted); }
34.stage .prev { left: 0; }
35.stage .next { right: 0; }
36.stage .prev:hover, .stage .next:hover { color: var(--accent); }
37.meta { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 16px; padding: 16px 20px 20px; font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted); }
38.meta span:first-child { color: var(--fg); }
39
40.info { padding: 0 20px 40px; max-width: 60ch; }
41.error { padding: 40px 20px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); }
42
43@media (max-width: 600px) {
44 :root { --row-height: 200px; }
45 .stage { padding: 0; }
46 .stage .prev, .stage .next { display: none; }
47}
internal/web/funcs.go added +75
@@ -0,0 +1,75 @@
1package web
2
3import (
4 "fmt"
5 "html/template"
6 "net/url"
7 "strconv"
8 "strings"
9
10 "gitbay.org/cmc/gallery/internal/library"
11 "gitbay.org/cmc/gallery/internal/render"
12)
13
14var funcs = template.FuncMap{
15 "albumURL": albumURL,
16 "photoURL": photoURL,
17 "imgURL": imgURL,
18 "srcset": srcset,
19 "ar": aspect,
20 "exif": exifLine,
21 "add": func(a, b int) int { return a + b },
22}
23
24func albumURL(a *library.Album) string {
25 return "/" + url.PathEscape(a.Slug) + "/"
26}
27
28func photoURL(it *library.Item) string {
29 return "/" + url.PathEscape(it.Album) + "/" + url.PathEscape(it.Name) + "/"
30}
31
32func imgURL(it *library.Item, w int) string {
33 return fmt.Sprintf("/img/%s/%s/%d.jpg?v=%d", url.PathEscape(it.Album), url.PathEscape(it.Name), w, it.ModTime.UnixNano())
34}
35
36// srcset lists allowed widths up to the first one that reaches the source width.
37func srcset(it *library.Item) string {
38 var parts []string
39 for _, w := range render.Widths {
40 actual := w
41 reached := it.Meta.Width > 0 && w >= it.Meta.Width
42 if reached {
43 actual = it.Meta.Width
44 }
45 parts = append(parts, fmt.Sprintf("%s %dw", imgURL(it, w), actual))
46 if reached {
47 break
48 }
49 }
50 return strings.Join(parts, ", ")
51}
52
53func aspect(it *library.Item) string {
54 if it.Meta.Height == 0 {
55 return "1"
56 }
57 return strconv.FormatFloat(float64(it.Meta.Width)/float64(it.Meta.Height), 'f', 4, 64)
58}
59
60func exifLine(it *library.Item) string {
61 m := it.Meta
62 var parts []string
63 for _, s := range []string{m.Camera, m.Lens, m.Aperture, m.Shutter} {
64 if s != "" {
65 parts = append(parts, s)
66 }
67 }
68 if m.ISO > 0 {
69 parts = append(parts, "ISO "+strconv.Itoa(m.ISO))
70 }
71 if !m.Taken.IsZero() {
72 parts = append(parts, m.Taken.Format("2006-01-02"))
73 }
74 return strings.Join(parts, " · ")
75}
internal/web/funcs_test.go added +35
@@ -0,0 +1,35 @@
1package web
2
3import (
4 "strings"
5 "testing"
6 "time"
7
8 "gitbay.org/cmc/gallery/internal/format"
9 "gitbay.org/cmc/gallery/internal/library"
10)
11
12func TestExifLineDate(t *testing.T) {
13 it := &library.Item{Meta: format.Meta{Camera: "X-T5", Taken: time.Date(2025, 3, 14, 23, 10, 5, 0, time.UTC)}}
14 if got, want := exifLine(it), "X-T5 · 2025-03-14"; got != want {
15 t.Fatalf("exifLine = %q, want %q", got, want)
16 }
17}
18
19func TestExifLineNoDate(t *testing.T) {
20 it := &library.Item{Meta: format.Meta{Camera: "X-T5"}}
21 if got, want := exifLine(it), "X-T5"; got != want {
22 t.Fatalf("exifLine = %q, want %q", got, want)
23 }
24}
25
26func TestSrcsetStopsAtExactSourceWidth(t *testing.T) {
27 it := &library.Item{Album: "tokyo", Name: "a.jpg", Meta: format.Meta{Width: 960}}
28 got := srcset(it)
29 if n := strings.Count(got, "960w"); n != 1 {
30 t.Fatalf("srcset = %q, want exactly one 960w entry, got %d", got, n)
31 }
32 if n := strings.Count(got, ", "); n != 1 {
33 t.Fatalf("srcset = %q, want exactly two entries", got)
34 }
35}
internal/web/server.go added +199
@@ -0,0 +1,199 @@
1// Package web serves gallery pages and images.
2package web
3
4import (
5 "bytes"
6 "embed"
7 "fmt"
8 "html/template"
9 "io/fs"
10 "log"
11 "net/http"
12 "os"
13 "strconv"
14 "strings"
15
16 "gitbay.org/cmc/gallery/internal/library"
17 "gitbay.org/cmc/gallery/internal/render"
18)
19
20//go:embed templates/*.html
21var embeddedTemplates embed.FS
22
23//go:embed assets/theme.css
24var embeddedTheme []byte
25
26//go:embed assets/gallery.js
27var embeddedScript []byte
28
29type Options struct {
30 Title string
31 ThemePath string // replaces the embedded theme.css
32 Templates fs.FS // replaces the embedded templates; same file names
33}
34
35var pageNames = []string{"home", "album", "photo", "info", "error"}
36
37type server struct {
38 store *library.Store
39 rend *render.Renderer
40 title string
41 theme []byte
42 pages map[string]*template.Template
43}
44
45type page struct {
46 Title string
47 Lib *library.Library
48 Current string
49 Album *library.Album
50 Item *library.Item
51 Index int
52 Prev *library.Item
53 Next *library.Item
54 Code int
55 Message string
56}
57
58func New(store *library.Store, rend *render.Renderer, opt Options) (http.Handler, error) {
59 s := &server{store: store, rend: rend, title: opt.Title, theme: embeddedTheme, pages: map[string]*template.Template{}}
60 if opt.ThemePath != "" {
61 b, err := os.ReadFile(opt.ThemePath)
62 if err != nil {
63 return nil, err
64 }
65 s.theme = b
66 }
67 tfs := opt.Templates
68 if tfs == nil {
69 sub, err := fs.Sub(embeddedTemplates, "templates")
70 if err != nil {
71 return nil, err
72 }
73 tfs = sub
74 }
75 for _, name := range pageNames {
76 t, err := template.New("base.html").Funcs(funcs).ParseFS(tfs, "base.html", name+".html")
77 if err != nil {
78 return nil, fmt.Errorf("template %s: %w", name, err)
79 }
80 s.pages[name] = t
81 }
82
83 mux := http.NewServeMux()
84 mux.HandleFunc("GET /{$}", s.home)
85 mux.HandleFunc("GET /info/{$}", s.info)
86 mux.HandleFunc("GET /_/theme.css", s.themeCSS)
87 mux.HandleFunc("GET /_/gallery.js", s.script)
88 mux.HandleFunc("GET /img/{album}/{file}/{width}", s.image)
89 mux.HandleFunc("GET /{album}/{$}", s.album)
90 mux.HandleFunc("GET /{album}/{file}/{$}", s.photo)
91 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { s.error(w, http.StatusNotFound) })
92 return mux, nil
93}
94
95func (s *server) render(w http.ResponseWriter, name string, status int, p page) {
96 p.Title = s.title
97 if p.Lib == nil {
98 p.Lib = s.store.Load()
99 }
100 var buf bytes.Buffer
101 if err := s.pages[name].Execute(&buf, p); err != nil {
102 log.Printf("render %s: %v", name, err)
103 http.Error(w, "internal error", http.StatusInternalServerError)
104 return
105 }
106 w.Header().Set("Content-Type", "text/html; charset=utf-8")
107 w.WriteHeader(status)
108 buf.WriteTo(w)
109}
110
111func (s *server) error(w http.ResponseWriter, code int) {
112 s.render(w, "error", code, page{Code: code, Message: http.StatusText(code)})
113}
114
115func (s *server) home(w http.ResponseWriter, r *http.Request) {
116 s.render(w, "home", http.StatusOK, page{})
117}
118
119func (s *server) info(w http.ResponseWriter, r *http.Request) {
120 lib := s.store.Load()
121 if lib.About == "" {
122 s.error(w, http.StatusNotFound)
123 return
124 }
125 s.render(w, "info", http.StatusOK, page{Lib: lib, Current: "info"})
126}
127
128func (s *server) album(w http.ResponseWriter, r *http.Request) {
129 lib := s.store.Load()
130 a := lib.Album(r.PathValue("album"))
131 if a == nil {
132 s.error(w, http.StatusNotFound)
133 return
134 }
135 s.render(w, "album", http.StatusOK, page{Lib: lib, Current: a.Slug, Album: a})
136}
137
138func (s *server) photo(w http.ResponseWriter, r *http.Request) {
139 lib := s.store.Load()
140 a := lib.Album(r.PathValue("album"))
141 if a == nil {
142 s.error(w, http.StatusNotFound)
143 return
144 }
145 i, it := a.Item(r.PathValue("file"))
146 if it == nil {
147 s.error(w, http.StatusNotFound)
148 return
149 }
150 p := page{Lib: lib, Current: a.Slug, Album: a, Item: it, Index: i}
151 if i > 0 {
152 p.Prev = a.Items[i-1]
153 }
154 if i < len(a.Items)-1 {
155 p.Next = a.Items[i+1]
156 }
157 s.render(w, "photo", http.StatusOK, p)
158}
159
160func (s *server) image(w http.ResponseWriter, r *http.Request) {
161 lib := s.store.Load()
162 a := lib.Album(r.PathValue("album"))
163 if a == nil {
164 http.NotFound(w, r)
165 return
166 }
167 _, it := a.Item(r.PathValue("file"))
168 if it == nil {
169 http.NotFound(w, r)
170 return
171 }
172 ws, ok := strings.CutSuffix(r.PathValue("width"), ".jpg")
173 n, err := strconv.Atoi(ws)
174 if !ok || err != nil || !render.Allowed(n) {
175 http.Error(w, "bad width", http.StatusBadRequest)
176 return
177 }
178 p, err := s.rend.Get(it, n)
179 if err != nil {
180 log.Printf("render %s/%s@%d: %v", it.Album, it.Name, n, err)
181 http.Error(w, "render failed", http.StatusInternalServerError)
182 return
183 }
184 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
185 w.Header().Set("Content-Type", "image/jpeg")
186 http.ServeFile(w, r, p)
187}
188
189func (s *server) themeCSS(w http.ResponseWriter, r *http.Request) {
190 w.Header().Set("Content-Type", "text/css; charset=utf-8")
191 w.Header().Set("Cache-Control", "no-cache")
192 w.Write(s.theme)
193}
194
195func (s *server) script(w http.ResponseWriter, r *http.Request) {
196 w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
197 w.Header().Set("Cache-Control", "no-cache")
198 w.Write(embeddedScript)
199}
internal/web/server_test.go added +211
@@ -0,0 +1,211 @@
1package web
2
3import (
4 "errors"
5 "io"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "gitbay.org/cmc/gallery/internal/format"
15 "gitbay.org/cmc/gallery/internal/library"
16 "gitbay.org/cmc/gallery/internal/render"
17)
18
19type fakeFormat struct{ fail bool }
20
21func (fakeFormat) Name() string { return "fake" }
22func (fakeFormat) Match(p string) bool { return strings.HasSuffix(p, ".jpg") }
23func (fakeFormat) Kind() format.Kind { return format.KindImage }
24func (fakeFormat) Metadata(string) (format.Meta, error) {
25 return format.Meta{
26 Width: 3000, Height: 2000,
27 Taken: time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
28 Camera: "X-T5", Aperture: "f/2", Shutter: "1/125", ISO: 1600,
29 }, nil
30}
31func (f fakeFormat) Resize(_ string, _ int, dst io.Writer) error {
32 if f.fail {
33 return errors.New("magick failed")
34 }
35 _, err := io.WriteString(dst, "JPEGDATA")
36 return err
37}
38
39type setup struct {
40 fail bool
41 about bool
42 opt Options
43}
44
45func newServer(t *testing.T, s setup) http.Handler {
46 t.Helper()
47 root := t.TempDir()
48 files := map[string]string{
49 "tokyo/a.jpg": "x",
50 "tokyo/b.jpg": "x",
51 "tokyo/album.org": "#+title: Tokyo\n#+date: 2025-03\n",
52 }
53 if s.about {
54 files["about.org"] = "Hello /there/.\n"
55 }
56 for rel, body := range files {
57 p := filepath.Join(root, rel)
58 os.MkdirAll(filepath.Dir(p), 0o755)
59 os.WriteFile(p, []byte(body), 0o644)
60 }
61 reg := &format.Registry{}
62 reg.Register(fakeFormat{fail: s.fail})
63 cache, err := library.OpenMetaCache(filepath.Join(t.TempDir(), "meta.json"))
64 if err != nil {
65 t.Fatal(err)
66 }
67 lib, err := library.Scan(root, reg, cache)
68 if err != nil {
69 t.Fatal(err)
70 }
71 store := &library.Store{}
72 store.Set(lib)
73 if s.opt.Title == "" {
74 s.opt.Title = "gallery"
75 }
76 h, err := New(store, render.New(t.TempDir(), 2), s.opt)
77 if err != nil {
78 t.Fatal(err)
79 }
80 return h
81}
82
83func get(h http.Handler, path string) *httptest.ResponseRecorder {
84 rec := httptest.NewRecorder()
85 h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
86 return rec
87}
88
89func TestPages(t *testing.T) {
90 h := newServer(t, setup{about: true})
91 for _, tc := range []struct {
92 path string
93 code int
94 want []string
95 reject []string
96 }{
97 {"/", 200, []string{`href="/tokyo/"`, `data-ar="1.5000"`, "Tokyo", "2025", `href="/info/"`}, nil},
98 {"/tokyo/", 200, []string{`href="/tokyo/a.jpg/"`, `href="/tokyo/b.jpg/"`, `/img/tokyo/a.jpg/960.jpg?v=`, `480w`, `class="on"`}, nil},
99 {"/tokyo/a.jpg/", 200, []string{"a.jpg — 1 / 2", "X-T5 · f/2 · 1/125 · ISO 1600 · 2025-03-14", `rel="next" href="/tokyo/b.jpg/"`, `id="up" href="/tokyo/"`}, []string{`rel="prev"`}},
100 {"/tokyo/b.jpg/", 200, []string{`rel="prev" href="/tokyo/a.jpg/"`, "2 / 2"}, []string{`rel="next"`}},
101 {"/info/", 200, []string{"<em>there</em>"}, nil},
102 {"/nope/", 404, []string{"404"}, nil},
103 {"/tokyo/nope.jpg/", 404, []string{"404"}, nil},
104 {"/tokyo/a.jpg/extra/", 404, nil, nil},
105 } {
106 rec := get(h, tc.path)
107 if rec.Code != tc.code {
108 t.Errorf("%s: code %d, want %d", tc.path, rec.Code, tc.code)
109 continue
110 }
111 body := rec.Body.String()
112 for _, w := range tc.want {
113 if !strings.Contains(body, w) {
114 t.Errorf("%s: missing %q", tc.path, w)
115 }
116 }
117 for _, r := range tc.reject {
118 if strings.Contains(body, r) {
119 t.Errorf("%s: unexpected %q", tc.path, r)
120 }
121 }
122 }
123}
124
125func TestInfoHiddenWithoutAbout(t *testing.T) {
126 h := newServer(t, setup{})
127 if strings.Contains(get(h, "/").Body.String(), `href="/info/"`) {
128 t.Error("nav links Info without about.org")
129 }
130 if code := get(h, "/info/").Code; code != 404 {
131 t.Errorf("/info/ = %d, want 404", code)
132 }
133}
134
135func TestImages(t *testing.T) {
136 h := newServer(t, setup{})
137 rec := get(h, "/img/tokyo/a.jpg/960.jpg?v=1")
138 if rec.Code != 200 || rec.Body.String() != "JPEGDATA" {
139 t.Fatalf("code %d body %q", rec.Code, rec.Body.String())
140 }
141 if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
142 t.Errorf("Cache-Control = %q", cc)
143 }
144 if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" {
145 t.Errorf("Content-Type = %q", ct)
146 }
147 for path, code := range map[string]int{
148 "/img/tokyo/a.jpg/961.jpg": 400,
149 "/img/tokyo/a.jpg/960.png": 400,
150 "/img/tokyo/a.jpg/big.jpg": 400,
151 "/img/nope/a.jpg/960.jpg": 404,
152 "/img/tokyo/nope.jpg/960.jpg": 404,
153 } {
154 if got := get(h, path).Code; got != code {
155 t.Errorf("%s: %d, want %d", path, got, code)
156 }
157 }
158 // The mux may 404 or redirect these; either way no file is served.
159 for _, path := range []string{"/img/%2e%2e/a.jpg/960.jpg", "/img/tokyo/..%2fa.jpg/960.jpg"} {
160 if rec := get(h, path); rec.Code == 200 {
161 t.Errorf("%s: served %q", path, rec.Body.String())
162 }
163 }
164}
165
166func TestImageRenderFailure(t *testing.T) {
167 h := newServer(t, setup{fail: true})
168 if code := get(h, "/img/tokyo/a.jpg/480.jpg").Code; code != 500 {
169 t.Fatalf("code %d, want 500", code)
170 }
171}
172
173func TestTheme(t *testing.T) {
174 rec := get(newServer(t, setup{}), "/_/theme.css")
175 if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") || !strings.Contains(rec.Body.String(), "--bg") {
176 t.Fatalf("code %d type %q", rec.Code, rec.Header().Get("Content-Type"))
177 }
178
179 custom := filepath.Join(t.TempDir(), "theme.css")
180 os.WriteFile(custom, []byte(":root{--bg:red}"), 0o644)
181 rec = get(newServer(t, setup{opt: Options{ThemePath: custom}}), "/_/theme.css")
182 if rec.Body.String() != ":root{--bg:red}" {
183 t.Fatalf("custom theme not served: %q", rec.Body.String())
184 }
185}
186
187func TestCustomTemplates(t *testing.T) {
188 dir := t.TempDir()
189 for _, name := range []string{"base", "home", "album", "photo", "info", "error"} {
190 body := `{{define "content"}}custom-` + name + `{{end}}`
191 if name == "base" {
192 body = `{{template "content" .}}`
193 }
194 os.WriteFile(filepath.Join(dir, name+".html"), []byte(body), 0o644)
195 }
196 h := newServer(t, setup{opt: Options{Templates: os.DirFS(dir)}})
197 if body := get(h, "/").Body.String(); body != "custom-home" {
198 t.Fatalf("body = %q", body)
199 }
200}
201
202func TestScript(t *testing.T) {
203 h := newServer(t, setup{})
204 rec := get(h, "/_/gallery.js")
205 if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/javascript") {
206 t.Fatalf("code %d type %q", rec.Code, rec.Header().Get("Content-Type"))
207 }
208 if !strings.Contains(get(h, "/tokyo/").Body.String(), `<script src="/_/gallery.js" defer></script>`) {
209 t.Fatal("album page missing script tag")
210 }
211}
internal/web/templates/album.html added +15
@@ -0,0 +1,15 @@
1{{define "title"}}{{.Album.Title}} · {{.Title}}{{end}}
2{{define "content"}}
3<header class="title">
4 <h1>{{.Album.Title}}</h1>
5 <span>{{with .Album.Year}}{{.}} · {{end}}{{len .Album.Items}}</span>
6</header>
7{{with .Album.Description}}<div class="desc">{{.}}</div>{{end}}
8<div class="rows">
9{{- range .Album.Items}}
10 <a class="tile" href="{{photoURL .}}" data-ar="{{ar .}}" style="--ar:{{ar .}}">
11 <img src="{{imgURL . 960}}" srcset="{{srcset .}}" sizes="(max-width: 600px) 100vw, 33vw" width="{{.Meta.Width}}" height="{{.Meta.Height}}" loading="lazy" alt="">
12 </a>
13{{- end}}
14</div>
15{{end}}
internal/web/templates/base.html added +22
@@ -0,0 +1,22 @@
1<!doctype html>
2<html lang="en">
3<head>
4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1">
6<title>{{block "title" .}}{{.Title}}{{end}}</title>
7<link rel="stylesheet" href="/_/theme.css">
8<script src="/_/gallery.js" defer></script>
9</head>
10<body class="{{block "bodyclass" .}}{{end}}">
11<nav class="site">
12 <a class="brand" href="/">{{.Title}}</a>
13 {{- range .Lib.Albums}}
14 <a href="{{albumURL .}}"{{if eq .Slug $.Current}} class="on"{{end}}>{{.Title}}</a>
15 {{- end}}
16 {{- if .Lib.About}}
17 <a href="/info/"{{if eq .Current "info"}} class="on"{{end}}>Info</a>
18 {{- end}}
19</nav>
20<main>{{template "content" .}}</main>
21</body>
22</html>
internal/web/templates/error.html added +2
@@ -0,0 +1,2 @@
1{{define "title"}}{{.Code}} · {{.Title}}{{end}}
2{{define "content"}}<p class="error">{{.Code}} — {{.Message}}</p>{{end}}
internal/web/templates/home.html added +10
@@ -0,0 +1,10 @@
1{{define "content"}}
2<div class="rows captioned">
3{{- range $a := .Lib.Albums}}{{$c := $a.Cover}}
4 <a class="tile" href="{{albumURL $a}}" data-ar="{{ar $c}}" style="--ar:{{ar $c}}">
5 <img src="{{imgURL $c 960}}" srcset="{{srcset $c}}" sizes="(max-width: 600px) 100vw, 50vw" width="{{$c.Meta.Width}}" height="{{$c.Meta.Height}}" loading="lazy" alt="{{$a.Title}}">
6 <span class="label">{{$a.Title}}{{with $a.Year}} · {{.}}{{end}}</span>
7 </a>
8{{- end}}
9</div>
10{{end}}
internal/web/templates/info.html added +2
@@ -0,0 +1,2 @@
1{{define "title"}}Info · {{.Title}}{{end}}
2{{define "content"}}<article class="info">{{.Lib.About}}</article>{{end}}
internal/web/templates/photo.html added +16
@@ -0,0 +1,16 @@
1{{define "title"}}{{.Item.Name}} · {{.Album.Title}} · {{.Title}}{{end}}
2{{define "bodyclass"}}photo{{end}}
3{{define "content"}}
4<figure class="stage">
5 {{with .Prev}}<a class="prev" rel="prev" href="{{photoURL .}}" aria-label="Previous">‹</a>{{end}}
6 <img src="{{imgURL .Item 1600}}" srcset="{{srcset .Item}}" sizes="100vw" width="{{.Item.Meta.Width}}" height="{{.Item.Meta.Height}}" alt="">
7 {{with .Next}}<a class="next" rel="next" href="{{photoURL .}}" aria-label="Next">›</a>{{end}}
8</figure>
9<footer class="meta">
10 <span>{{.Item.Name}} — {{add .Index 1}} / {{len .Album.Items}}</span>
11 <span>{{exif .Item}}</span>
12</footer>
13<a id="up" href="{{albumURL .Album}}" hidden></a>
14{{with .Prev}}<link rel="preload" as="image" href="{{imgURL . 1600}}">{{end}}
15{{with .Next}}<link rel="preload" as="image" href="{{imgURL . 1600}}">{{end}}
16{{end}}