gallery

package module
v0.0.0-...-451bd12 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 20, 2026 License: MIT Imports: 25 Imported by: 0

README

A Caddy v2 HTTP handler module that renders a directory as a light-themed image gallery. Replaces Caddy's default file_server browse with a thumbnailed grid, click-to-expand lightbox, sortable + paginated layout, and a separate "Other files" strip above the image grid for non-image content.

preview

Features

  • Drop-in replacement for file_server browse in a handle_path block.
  • Recursive — every subdirectory under the matched route is rendered as a gallery.
  • WebP thumbnails generated on the fly, cached on disk, invalidated by source mtime.
  • Vanilla JS lightbox for click-to-expand, no external JS dependencies.
  • Light theme with white card on grey background, blue accent links, Caddy-browse aesthetic.
  • Native loading="lazy" on every thumbnail.
  • Video support — videos show a play-button overlay and link to the raw file.
  • "Other files" section for non-image/non-video content in a directory.

Install

Build a custom Caddy binary with this module baked in:

xcaddy build \
    --with github.com/caddyserver/caddy@v2.11.4 \
    --with github.com/synapticloop/caddy_image_gallery@latest \

Or use the included build script (pins Caddy to v2.11.4 and the local module path):

./build.sh

The build script also restarts Caddy via systemd (you may need to be root or use sudo).

Caddyfile usage

handle_path /images/* {
    root * /var/www/html/images
    image_gallery         # default: mtime desc, 320px WebP thumbs
    file_server           # serves direct file requests (e.g. /images/foo.jpg)
}

# Or with explicit sort:
handle_path /images/crosswords/* {
    root * /var/www/html/images/crosswords
    image_gallery { sort name }   # alphabetical for curated content
}

The image_gallery directive MUST come before file_server in the handle block — that way it gets a chance to handle the request (gallery HTML, thumbnail requests), and only falls through to file_server for direct file access (e.g. /images/foo.jpg).

Auth

The gallery slots behind any standard Caddy auth layer (basic_auth, forward_auth, JWT, etc.) — it's just a regular HTTP handler. It does not implement its own auth.

Caddyfile directive options

Option Default Description
sort mtime mtime (newest first by modification time) or name (alphabetical)

Example:

image_gallery { sort name }

How thumbs work

Thumb URLs look like /_thumbs/<basename>.webp (e.g. for source photo.jpg, the thumb is at /_thumbs/photo.webp). On first request, the module:

  1. Hashes the source's absolute path (sha256, first 16 bytes).
  2. Checks the cache at /var/cache/caddy-gallery/<hash>.webp.
  3. If the cached file's mtime is older than the source, regenerates:
    • Decode source (jpg, png, gif, webp via stdlib + golang.org/x/image)
    • Resize to 320px wide, preserve aspect ratio
    • Encode as lossless WebP (VP8L) using github.com/HugoSmits86/nativewebp
    • Write to cache, return the bytes
  4. Subsequent requests serve the cached file directly.

Cache invalidation is purely mtime-based — no cron job, no inotify watcher.

Cache directory is /var/cache/caddy-gallery by default. Override with the GALLERY_THUMB_CACHE_DIR env var (useful for testing).

Caching & performance

  • Scan cache — each directory is scanned at most once per minute (mtime-keyed). For 100+ image directories like /images/generated/, this drops per-request work from milliseconds to microseconds.
  • Thumb cache — WebP thumbs are written to disk and served from disk; subsequent requests are a single os.ReadFile. The thumb URL is content-addressed (sha256 of the source path), so the URL itself is cacheable.
  • HTTP Cache-Control: public, max-age=86400 on thumb responses (24h, since thumbs are immutable per source mtime).
  • HTTP Cache-Control: no-cache on gallery HTML (so newly-added images show up on the next refresh).

Dependencies

Build

# Clone
git clone https://github.com/synapticloop/caddy_image_gallery
cd caddy_image_gallery

# Build (requires xcaddy and Go 1.21+)
go mod download
./build.sh

Test

go test ./... -v
go test ./... -race       # race detector

24 tests, all standard library + stdlib-friendly patterns. No test fixtures in the repo — the test for thumbnail generation uses a programmatically-generated 640x480 JPEG.

Architecture

caddy_image_gallery/
├── gallery.go          # Module registration, Caddyfile parser, ServeHTTP
├── scanner.go          # Directory walker + file classification (image/video/other)
├── scancache.go        # mtime-keyed in-memory cache of directory scans
├── render.go           # HTML template + inlined dark CSS + inlined lightbox JS
├── thumbnails.go       # WebP thumb generation (decode → resize → encode), mtime cache
├── *_test.go           # Go tests (24 total)
├── build.sh            # xcaddy build + systemd restart
└── README.md           # this file

Caddyfile example (full)

{
    admin off
}

your.caddy.host:443 {
    tls /etc/caddy/caddy.crt /etc/caddy/caddy.key

    route {
        basic_auth {
            youruser $2a$14$bcrypt_hash_here
        }

        handle_path /images/* {
            root * /var/www/html/images
            image_gallery
            file_server
        }
    }
}

Documentation

The full documentation is also available as a single PDF: caddy-image-gallery-book.pdf (19 pages, with cover page, table of contents, and the Google Fonts typography - Libre Baskerville for body text, JetBrains Mono for code).

Detailed operator documentation lives in docs/:

License

MIT. See LICENSE (add one if you haven't — the module code is yours to license).

Documentation

Overview

Package gallery implements an image-gallery HTTP handler for Caddy v2. It renders a directory as a dark-themed grid of thumbnails with a vanilla JS lightbox for click-to-expand.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func GenerateOrLoadThumb

func GenerateOrLoadThumb(src, cacheDir string, cfg ThumbConfig) ([]byte, error)

GenerateOrLoadThumb returns the thumbnail bytes for src, generating and caching on first call and serving from cache on subsequent calls. The cfg parameter (Width, Height, Format) controls the output size and output format. The source is fit-within-bounds (aspect ratio preserved, longest edge becomes the configured value). Sources already within the box are encoded without resizing.

The cache file is at <cacheDir>/<sha256(absolute source path)>.<ext> keyed by (source path, cfg.Format) so changing the format invalidates the old cache automatically. The cache is also regenerated when the source mtime is newer than the cache mtime.

func GenerateOrLoadVideoThumb

func GenerateOrLoadVideoThumb(src, cacheDir, ffmpegPath string, cfg ThumbConfig) ([]byte, error)

GenerateOrLoadVideoThumb extracts the first frame from a video using ffmpeg, saves it as a WebP thumbnail, and returns the thumbnail bytes. Mirrors GenerateOrLoadThumb's caching behavior: if a cache file already exists and is not older than the source video, the cached bytes are returned without invoking ffmpeg.

The ffmpegPath argument must be the absolute path to a working ffmpeg binary (use Gallery.ffmpegPath, set in Provision via exec.LookPath). If ffmpegPath is empty, the function returns an error — callers should check gallery.VideoThumbsEnabled() first to know whether to call this function.

ffmpeg invocation:

ffmpeg -y -i input.mp4 -vframes 1 -vf "scale=W:H:force_original_aspect_ratio=decrease" output.webp

-y: overwrite output without prompting -i: input -vframes 1: extract exactly one frame -vf scale=W:H:force_original_aspect_ratio=decrease: fit-within-bounds output: webp (or other format if cfg.Format is set; we default to webp since that's what the rest of the thumb pipeline uses)

Note on seeking: we use -vframes 1 which extracts the FIRST frame. For most videos this is fine. Some videos have an all- black opening frame (the "fade-in" frame); if that becomes a problem we can add -ss 0.5 to seek forward half a second.

func RenderPage

func RenderPage(title, pathPrefix, thumbPrefix, relPath, tmplName string, noThumbs, noVideoThumbs bool, pageSize int, files []FileInfo, query url.Values) (string, error)

RenderPage renders the gallery page for a directory. The caller provides the raw directory listing (output of Scanner.Scan); RenderPage does the split / sort / paginate / format work.

`title` is the page heading (e.g. "Generated Images"). It is derived by the caller — typically the basename of the current directory.

`pathPrefix` and `thumbPrefix` are the URL prefixes for relative links. The defaults used in the live config are "./" and "./_thumbs/" — both relative so they work for any subdir the gallery is mounted at.

`relPath` is the path within the gallery (the request's post-handle_path-stripped path, no leading slash). Empty for the gallery root. When non-empty, an ".." entry is prepended to the directories list so the user can navigate up.

`query` is the request's URL query values; sort and page are read from it. RenderPage renders the gallery. `tmplName` is the configured template name (relative to the templates dir). Pass "" to use the default ("gallery.tmpl"). The name is validated inside loadTemplate. RenderPage renders the gallery. `tmplName` is the configured template name (relative to the templates dir). Pass "" to use the default ("gallery.tmpl"). `noThumbs` is the configured no_thumbs flag — when true, image tiles use the original file as the <img src> instead of `/_thumbs/<name>.webp` (no thumb generation). `pageSize` is the configured page_size — the number of image entries per page. Pass 0 for the default of 50.

func ThumbPath

func ThumbPath(src, cacheDir string) string

ThumbPath returns the on-disk path where the thumbnail for src should be cached. The filename is the first 16 bytes of the SHA256 of src's absolute path, hex-encoded (32 hex chars) + ".webp". Using a content-hash means cache entries are stable across renames of the parent directory (as long as the absolute source path stays the same) and collisions are effectively impossible.

Types

type FileInfo

type FileInfo struct {
	Name    string   `json:"name"`
	ModTime int64    `json:"mtime"`
	Size    int64    `json:"size"`
	Kind    FileKind `json:"kind"`
}

FileInfo describes a single entry in a gallery directory. This is the type that flows into the template renderer.

ModTime is unix nanoseconds. int64 keeps the type JSON-friendly (no time.Time marshalling quirks) and nanosecond resolution preserves sub-second ordering of files written close together.

type FileKind

type FileKind string

FileKind categorises an entry in a gallery directory.

const (
	// KindDir is a subdirectory. The Name is the directory basename
	// (not the full path); the scanner joins it to Root at render
	// time.
	KindDir FileKind = "dir"
	// KindImage is an image file (jpg, png, gif, webp, svg, avif).
	KindImage FileKind = "image"
	// KindVideo is a video file (mp4, webm).
	KindVideo FileKind = "video"
	// KindOther is a non-image, non-video file (html, txt, etc.).
	// These are shown in the "Other files" strip above the image
	// grid.
	KindOther FileKind = "other"
)

func Classify

func Classify(name string) FileKind

Classify returns the FileKind for a filename based on its extension. Directories are not classified by name; the scanner uses the entry's IsDir() to set KindDir directly.

type FileView

type FileView struct {
	Name     string // basename ("photo.jpg" or "subdir")
	Href     string // relative link
	ThumbURL string // for images, the relative thumb URL; empty for non-images
	IsDir    bool
	IsUp     bool // true for the synthetic "../" up-link entry (rendered with ↑ icon, no trailing /)
	IsImage  bool
	IsVideo  bool
	IsOther  bool

	// ParentDir is set ONLY on the up-link FileView. It is the
	// basename of the parent directory (one level up from the
	// current page). Rendered as part of the up chip's display
	// text — "Up (../{ParentDir})" — so the user sees which
	// directory they'll land in. Empty at the gallery root or
	// in a top-level subdir (parent is the gallery root, no name
	// to show). Per user request 2026-06-17.
	ParentDir string

	// Internal fields used by buildFileView to format Size/Date
	// without round-tripping through fmt in the template.
	Size string
	Date string
	Type string
}

FileView is the template-friendly representation of a single entry. All display strings are pre-formatted (no template computation needed). Href and ThumbURL are relative to the current page.

type Gallery struct {
	// Root is the on-disk directory to render. Set automatically by
	// Caddy's `root` directive (via Provision), or can be set in JSON
	// config.
	Root string `json:"root,omitempty"`

	// Sort is the field used to order the gallery. Valid values:
	//   "mtime" (default) — newest first
	//   "name"           — alphabetical
	Sort string `json:"sort,omitempty"`

	// Template is the name of the template file to use, relative to
	// the templates dir ($GALLERY_TEMPLATES_DIR, default
	// /etc/caddy/gallery-templates). If empty, defaults to
	// "gallery.tmpl". The path is validated at Provision to
	// reject absolute paths and any traversal above the templates
	// dir (no `..` allowed). The template dir is the chroot; the
	// operator can only reference files inside it.
	Template string `json:"template,omitempty"`

	// NoThumbs disables the on-the-fly WebP thumbnail generation.
	// When true, the gallery uses the original image as the tile
	// <img src> instead of `/_thumbs/<name>.webp`. Requests to the
	// thumb URL fall through to the next handler (typically
	// file_server, which 404s since no _thumbs/ dir exists).
	// Tradeoffs: no thumb cache, no CPU cost, but the browser
	// downloads the full image per tile (bigger page payload, slower
	// load on dirs of large photos). Useful for small galleries
	// where the operator doesn't want to maintain a thumb cache.
	NoThumbs bool `json:"no_thumbs,omitempty"`

	// NoVideoThumbs disables the on-demand WebP thumbnail generation
	// for VIDEO files (extracted from the first frame via ffmpeg).
	// When true, videos still display in the gallery (with the
	// placeholder gradient + play button on each tile) but no
	// per-frame thumbnail is produced or served. When false (the
	// default), video thumbnails ARE generated IF ffmpeg is
	// available on the host — if ffmpeg is missing, video thumbs
	// fall back to the placeholder regardless of this setting
	// (there's no way to generate a frame without a tool that can
	// decode the video).
	// Caddyfile: `no_video_thumbs` (no arg → true) or
	//            `no_video_thumbs false` (re-enable).
	NoVideoThumbs bool `json:"no_video_thumbs,omitempty"`

	// PageSize is the number of image entries per page. Default
	// is 50 (set in Provision if zero). The user can override
	// per-route via the Caddyfile: `image_gallery { page_size 100 }`.
	// Validation: must be > 0. A zero or negative value is rejected
	// by UnmarshalCaddyfile (the Caddyfile parser).
	PageSize int `json:"page_size,omitempty"`

	// ThumbWidth is the maximum width in pixels of generated
	// thumbnails. The source image is fit-within-bounds (aspect
	// ratio preserved, longest edge becomes the configured value).
	// Default: 320. Caddyfile: `thumb_width 480`.
	// Validation: must be > 0; zero/negative rejected.
	ThumbWidth int `json:"thumb_width,omitempty"`

	// ThumbHeight is the maximum height in pixels of generated
	// thumbnails. Fit-within-bounds behavior — see ThumbWidth.
	// Default: 320. Caddyfile: `thumb_height 480`.
	// Validation: must be > 0.
	ThumbHeight int `json:"thumb_height,omitempty"`

	// ThumbFormat is the output format for generated thumbnails.
	// One of: "jpeg" (or "jpg"), "png", or "webp" (the current
	// default, lossless). Default: "webp". Caddyfile:
	// `thumb_format jpeg`. Validation: must be one of the three.
	ThumbFormat string `json:"thumb_format,omitempty"`

	// CacheScanMinutes is the in-memory scan cache TTL in
	// minutes. Default: 1. Caddyfile: `cache_scan 5`.
	// Validation: must be > 0.
	CacheScanMinutes int `json:"cache_scan,omitempty"`

	// ThumbTTLMinutes is the HTTP Cache-Control max-age in
	// minutes for thumb responses. Thumbs are immutable per
	// source mtime, so a long TTL is safe. Default: 1440
	// (= 24 hours, matches the previous 86400-second value).
	// Caddyfile: `thumb_ttl 60`. Validation: must be > 0.
	ThumbTTLMinutes int `json:"thumb_ttl,omitempty"`

	// Cache holds the in-memory scan cache. Initialised in Provision
	// if nil. Excluded from JSON config (runtime state only).
	Cache *ScanCache `json:"-"`
	// contains filtered or unexported fields
}

Gallery is a Caddy HTTP handler that renders a directory as a dark-themed image/video gallery. See the README for behaviour.

func (Gallery) CaddyModule

func (Gallery) CaddyModule() caddy.ModuleInfo

func (*Gallery) Cleanup

func (*Gallery) Cleanup()

func (*Gallery) Provision

func (g *Gallery) Provision(caddy.Context) error

Provision sets up the module. Creates a default scan cache if one isn't already set.

func (*Gallery) ServeHTTP

func (g *Gallery) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error

ServeHTTP renders the gallery for the directory at the current request path, or falls through to the next handler (typically file_server) if the request is for a file.

Path semantics after handle_path /images/* strips the prefix:

r.URL.Path = ""                    → render gallery for root
r.URL.Path = "subdir"              → render gallery for subdir
r.URL.Path = "subdir/"             → render gallery for subdir
r.URL.Path = "photo.jpg"           → fall through to file_server
r.URL.Path = "subdir/photo.jpg"    → fall through to file_server
r.URL.Path = "_thumbs/photo.webp"  → serve as thumbnail
r.URL.Path = "subdir/_thumbs/x.webp" → serve as thumbnail in subdir

On transient errors (scan failures, template parse), it falls through to the next handler rather than returning a 500.

func (*Gallery) UnmarshalCaddyfile

func (g *Gallery) UnmarshalCaddyfile(d *caddyfile.Dispenser) error

func (*Gallery) Validate

func (*Gallery) Validate() error

type PageData

type PageData struct {
	Title       string
	PathPrefix  string // e.g. "./" — prefix for relative links
	ThumbPrefix string // e.g. "./_thumbs/" — prefix for thumb URLs

	// Three sections. OtherFiles is shown in full regardless of
	// pagination/sort (per the user's spec — it always appears
	// at the top, horizontal). Images is the paginated/sorted
	// set. The directories section is split into two
	// elements: Up (the synthetic ../ entry, rendered on its
	// own line, always first) and Subdirs (the actual subdirs,
	// rendered in a tight row with no gap between them, per
	// the user's 2026-06-17 spec).
	Up         *FileView  // the up entry, or nil at the gallery root
	Subdirs    []FileView // the actual subdirs (no up entry)
	OtherFiles []FileView
	Images     []FileView

	// Pagination
	Page     int // 1-based
	PageSize int
	// TotalImages is the total media count (images + videos)
	// — used for the pagination math and the visibility check
	// on the images grid section.
	TotalImages int
	// ImageCount is the count of image files only — used for
	// the "N images" label in the header meta line (so the
	// label is accurate; videos are no longer miscounted as
	// images). Per user request 2026-06-17: separate video
	// indicator in the header.
	ImageCount int
	// TotalVideos is the count of video files only — shown in
	// the header meta line as "N videos" (after the images
	// count, only if > 0).
	TotalVideos int
	// TotalAllFilesSize is the pre-formatted (via humanSize) total
	// size of ALL files in the directory: images + videos + other
	// files. Excludes subdirectories (which don't have a Size
	// field). Shown in the header meta line as a separate segment
	// wrapped in `//` separators, per user request 2026-06-18:
	//   "the X.X KB is the total for all files in the directory"
	// e.g. "34 images ·8 videos ·2 other files // (8.3 MB) //
	//        ·26 directories ·50 per page"
	// The `//` separators visually distinguish the size from the
	// other meta items (which use `·`). Operator sees at a glance
	// how much disk the whole directory's media + sidecar files
	// take.
	TotalAllFilesSize string
	TotalPages        int
	HasPrev           bool
	HasNext           bool
	// PageNumbers is the list of page numbers (and 0 for
	// ellipsis) to show in the Google-style bottom pagination.
	// Computed by pageNumbers(current, total). Empty when
	// total <= 1 (no pagination needed).
	PageNumbers []int

	// Sort
	Sort SortSpec
}

PageData is the top-level template data for a gallery page. All values are pre-formatted for direct template use — no template-level computations needed.

type ScanCache

type ScanCache struct {
	// contains filtered or unexported fields
}

ScanCache is a small in-memory cache of recent directory scans. Cache entries are keyed by absolute directory path; an entry is valid as long as the directory's mtime has not changed AND the entry's TTL has not expired.

The cache eliminates the per-request os.ReadDir cost in directories that don't change often (the common case for a server with photos on disk). For 100+ image directories like /images/generated, this drops per-request work from milliseconds to microseconds.

This is intentionally simple: no eviction policy beyond TTL — old entries are dropped when re-accessed after the TTL. For a server with <1000 galleries in active rotation, memory is bounded by (number of dirs) * (average file count) * (size of FileInfo).

func NewScanCache

func NewScanCache(ttl time.Duration) *ScanCache

NewScanCache returns a cache with the given TTL. A TTL of 1 minute is a good default; it limits staleness if files are added/removed while also avoiding constant rescans for active directories.

func (*ScanCache) Get

func (c *ScanCache) Get(dir, sortMode string) ([]FileInfo, error)

Get returns the cached []FileInfo for dir, or runs a fresh scan if the cache is empty/expired/stale. The sortMode is part of the cache key — sorting by name vs mtime gives different results.

type Scanner

type Scanner struct {
	Root string
	Sort string // "mtime" (default) or "name"
}

Scanner reads a directory and produces a sorted []FileInfo. Both directories and files are included; the Kind field tells them apart.

func NewScanner

func NewScanner(root string) *Scanner

NewScanner returns a Scanner for the given root directory with default sort order (mtime desc — newest first).

func (*Scanner) Scan

func (s *Scanner) Scan() ([]FileInfo, error)

Scan walks the directory and returns a sorted slice of FileInfo. Both files and subdirectories are included (Kind = KindDir for directories). Symlinks are followed: a symlink to a directory is classified as KindDir, and the FileInfo's Size and ModTime come from the symlink's target (not the symlink itself, which would report the length of the target path string and the link's own mtime). Broken symlinks (target missing or inaccessible) are silently skipped.

Sort order:

  • "mtime" (default): newest first by modification time
  • "name": alphabetical by name (case-insensitive)

Returns an error only if the directory cannot be read.

type SortSpec

type SortSpec struct {
	Field string
	Order string
}

SortSpec describes the current sort state. Field is one of "name", "type", "date", "size", or "mtime" (the default). Order is "asc" or "desc".

type ThumbConfig

type ThumbConfig struct {
	// Width and Height are the max-dim bounding box (in pixels)
	// for the generated thumb. The source image is fit-within-
	// bounds: aspect ratio is preserved and the longest edge
	// becomes the configured value.
	Width  int
	Height int
	// Format is the output format: "jpeg" (or "jpg"), "png", or
	// "webp" (the default, lossless). Encoded with stdlib
	// image/jpeg (quality 75), stdlib image/png, or
	// github.com/HugoSmits86/nativewebp respectively.
	Format string
}

ThumbConfig holds the runtime configuration for thumb generation. Set at Provision time from the Gallery's Caddyfile-configured values (or defaults).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL