fix(gallery): serve thumbnails / photos / hero via storage abstraction (#432)

Three gallery serving routes bypassed the getStorage() abstraction and
used fs.* directly against local paths. Worked in local-fs mode, 500'd
in S3 mode because the files only exist in the bucket. Reported by
@w1ll-i-code with a precise root-cause pointer at gallery.js:1138.

The admin photo serving route (adminPhotos.js) had already been
converted to use storage.stat + storage.get; the gallery side hadn't.
This PR brings the gallery routes in line.

Changes:

- Add getRange(relPath, start, end) to the StorageBackend interface +
  LocalFsStorage (fs.createReadStream with start/end) + S3StorageBackend
  (downloadStream with Range header). Needed for video range requests
  on S3 — previously the photo route did fs.createReadStream(filePath,
  {start, end}) which is local-only.

- /:slug/thumbnail/:photoId — read mtime via storage.stat, stream bytes
  via storage.get. Watermark application path materializes the source
  via withLocalCopy (no-op in local mode, downloads to a tmp file then
  cleans up in S3 mode) so applyWatermark's sharp + fs.readFile still
  works.

- /:slug/photo/:photoId — branches on source_origin: external/reference
  photos still use the local fs path (NAS mounts are local), managed
  photos use the storage abstraction. Video range requests pass through
  to storage.getRange. Pre-generated watermarks served via storage too.
  On-the-fly watermark generation uses withLocalCopy for managed photos.

- /:slug/hero/:photoId — hero images are always managed-storage keys
  (imageProcessor.generateHeroImage writes via the storage abstraction),
  so this just switches to storage.stat + storage.get. Watermark via
  withLocalCopy.

Verified end-to-end against minio in dev:
  POST /api/admin/photos/N/upload         → photo + thumbnail land in S3
  GET /api/gallery/<slug>/thumbnail/<id>  → 200, JPEG 300x300 ✓
  GET /api/gallery/<slug>/photo/<id>      → 200, JPEG 1200x800 ✓
  GET /api/gallery/<slug>/hero/<id>       → 200, JPEG 1920x1080 ✓
  ETag round-trip (If-None-Match)         → 304 ✓
  Backend logs                            → no errors

LocalFs regression: 13/13 smoke tests pass.

Closes #432.
This commit is contained in:
Paul Nothaft
2026-05-09 20:56:20 +02:00
parent ed37caf3d8
commit 83d79f4d39
4 changed files with 173 additions and 101 deletions
@@ -76,6 +76,11 @@ class LocalFsStorage {
return fs.createReadStream(abs);
}
async getRange(relPath, start, end) {
const abs = this._resolve(relPath);
return fs.createReadStream(abs, { start, end });
}
async getToFile(relPath, localPath) {
const abs = this._resolve(relPath);
await fsp.mkdir(path.dirname(localPath), { recursive: true });
@@ -80,6 +80,10 @@ class S3StorageBackend {
return this.adapter.downloadStream(this._key(relPath));
}
async getRange(relPath, start, end) {
return this.adapter.downloadStream(this._key(relPath), { range: `bytes=${start}-${end}` });
}
async getToFile(relPath, localPath) {
await fsp.mkdir(path.dirname(localPath), { recursive: true });
await this.adapter.download(this._key(relPath), localPath);
@@ -29,6 +29,7 @@
* @property {(relPath: string, body: NodeJS.ReadableStream | Buffer, options?: PutOptions) => Promise<void>} put
* @property {(relPath: string, localPath: string, options?: PutOptions) => Promise<void>} putFromFile
* @property {(relPath: string) => Promise<NodeJS.ReadableStream>} get - Returns a readable stream of the object body.
* @property {(relPath: string, start: number, end: number) => Promise<NodeJS.ReadableStream>} getRange - Returns a readable stream of the object body for the inclusive byte range [start, end]. Used by video range-request handlers.
* @property {(relPath: string, localPath: string) => Promise<void>} getToFile - Streams the object to a local path (creates parent dirs).
* @property {(relPath: string) => Promise<boolean>} exists
* @property {(relPath: string) => Promise<StatResult|null>} stat - Null if missing.