Compare commits

..

6 Commits

Author SHA1 Message Date
Paul Nothaft e9fadd2ef4 chore(stable): release 3.46.3 (#1143)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-23 20:23:29 +02:00
Paul Nothaft d977e3e296 fix(gallery): give masonry tiles their real shape back (#1130, #1131)
Two independent causes of the same symptom — an aspect-ratio layout that does
not lay anything out.

gallery-premium discarded the tile height MasonryPhotoAlbum computed from
photos.width/height and set height:auto on both card and image, so the rendered
shape came from whatever rendition was served. With thumbnail_fit seeded 'cover'
every rendition is square, so the masonry drew identical squares.

The bundled CSS templates pinned images to a fixed pixel height, which beats the
.h-full utility six of the seven layouts rely on. Elegant Dark is seeded
is_default, so that was the out-of-the-box result for any layout other than
grid/timeline.

Migrations 052/053 corrected for fresh installs; 175 repairs the rows already
seeded. The repair is whitespace-tolerant because sanitizeCSS strips newlines
from any template ever saved through the editor, matches the height property
with a lookbehind so line-height/max-height are untouched, handles grouped
selectors and skips nested rules.

Stable twin of #1135.
2026-08-22 21:36:51 +02:00
Paul Nothaft da44f1947b fix(gallery): a missing file must not take the backend down (#1128)
LocalFsStorage.get() returns an fs.createReadStream, which is lazy: it resolves
immediately and opens the file on a later tick, so an ENOENT arrives after the
await returned and outside the route's try/catch. An unhandled 'error' event is
a process-level throw Express cannot catch — the backend exits and every gallery
goes blank until the container restarts.

gallery.js had ten .pipe(res) calls and zero error handlers.

pipeStreamToResponse attaches the missing handler: a vanished source becomes a
404 (410 for a prepared zip), anything else a 500, and a source that dies
mid-response destroys the connection rather than rewriting a status already on
the wire. Headers staged for the file are cleared first — Express does not
overwrite an existing Content-Type, and a surviving Cache-Control would let a
transient 404 be cached as a broken tile for up to an hour. It also releases the
source when a client hangs up.

Applied to all eight streaming responses, not just the thumbnail route.

Stable twin of #1133, reduced: the tier-race half does not apply here because
ensureThumbnailAtWidth does not exist on this branch.
2026-08-22 21:36:43 +02:00
Paul Nothaft dc9e3cdc5e fix(thumbnails): regenerate external photos, and stop destroying good ones (#1129)
POST /admin/thumbnails/regenerate resolved every source as
storage/events/active/<photo.path> and fs.access'd it. External and reference
rows are not there — their originals live under events.external_path — so every
one failed the check and was counted as an error, while the UI reported success
because the response is sent before the background loop starts.

It now goes through ensureThumbnail, which resolves both source kinds and writes
thumbnail_path back itself. Nulling thumbnail_path stops it short-circuiting on
isThumbnailValid, which matters because the old thumbnail is normally still
readable at exactly the moment someone presses regenerate.

And the half that destroys data: generateThumbnail deleted the target BEFORE
sharp had opened the source, and again in its catch. A source that could not be
read — a NAS mount that blipped — left the previous rendition gone and the
database pointing at it. Across a bulk regenerate that is the whole gallery.
Neither delete was needed: put stages to a temp file and renames atomically, and
put is the last statement in the try so no partial object can exist.

Also: videos filtered out, and the superseded rendition removed only when the
storage key actually moved, compared through the same canonicalisation the
backends apply.

Stable twin of #1134.
2026-08-22 21:36:19 +02:00
Paul Nothaft 7598e20f55 chore(stable): release 3.46.2 (#1121)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-21 20:30:47 +02:00
Paul Nothaft 32db1c8052 fix(ui): stop iOS Safari zooming in on 14px form fields (#1114)
Closes #1105.

iOS Safari zooms the whole page in when a focused form control computes to
under 16px, and it does not zoom back out. Unlocking a gallery is a
client-side transition rather than a document navigation, so the zoom the
password field triggers carries straight into the gallery: the layout pans
horizontally and the header actions sit off-screen until the visitor
pinch-zooms out by hand. A real page load would have reset it.

`.input` and `.input-themed` are `text-sm`, so the field is 14px on every
phone, and GalleryPage inverts the breakpoint on top of that
(`text-sm sm:text-base` — 14px below 640px, where iOS zooms, and 16px above
it, where it never does).

Keyed to the POINTER, not a width. The zoom depends on the computed font size
and a touch device, never on how wide the viewport is — and a phone in
landscape is 667-956 CSS px, above any width you could call "phone". Measured
on the admin login page, which has no `sm:` override:

  main   portrait 390x844    14px    zooms
  main   landscape 844x390   14px    zooms
  main   iPad 820x1180       14px    zooms
  fixed  all three           16px
  fixed  desktop (mouse)     14px    unchanged, no zoom off touch

One media query rather than flipping each call site. `.input` alone backs 334
`<Input>` usages, but there are also ~440 raw inputs, selects and textareas
carrying their own `text-sm`, and Tailwind utilities sit in a later layer than
@layer components — so a fix at the component definition misses most controls
and any new `text-sm` silently reintroduces the bug.

The `:not()` on each selector is load-bearing, not decoration: it buys the
specificity to beat a utility class. Measured in a browser —

  input.text-sm     16px   (0,2,1 beats .text-sm)
  select.text-sm    14px   (0,0,1 loses)
  textarea.text-sm  14px   (0,0,1 loses)

24 selects and textareas in the tree carry `text-sm`, so the bare form would
have left them zooming. Checkbox and radio stay excluded so font-size never
sizes their box.

max(16px, 1em, 1rem) is a FLOOR, not a size. A flat 16px would make controls
that are already bigger smaller: Typography -> Large sets --font-size-base to
18px on body, so anything inheriting it would be clamped down and the setting
quietly ignored. Each term covers a case the others miss:

  normal (body 16)       16px      Large theme (body 18)    18px
  Small theme (body 14)  16px      browser default 20px     20px

The viewport meta is deliberately left alone: `maximum-scale=1` would suppress
the zoom by disabling pinch-to-zoom for everyone.
2026-08-21 19:25:33 +02:00
17 changed files with 1126 additions and 55 deletions
+1 -1
View File
@@ -1 +1 @@
{".":"3.46.1"}
{".":"3.46.3"}
+16
View File
@@ -5,6 +5,22 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.46.3](https://github.com/PicPeak/picpeak/compare/v3.46.2...v3.46.3) (2026-08-22)
### Bug Fixes
* **gallery:** a missing file must not take the backend down ([#1128](https://github.com/PicPeak/picpeak/issues/1128)) ([da44f19](https://github.com/PicPeak/picpeak/commit/da44f1947b8317b47271f4f2a98b284b25d752c1))
* **gallery:** give masonry tiles their real shape back ([#1130](https://github.com/PicPeak/picpeak/issues/1130), [#1131](https://github.com/PicPeak/picpeak/issues/1131)) ([d977e3e](https://github.com/PicPeak/picpeak/commit/d977e3e296deeb19c26f1e5a98258eec323d120d))
* **thumbnails:** regenerate external photos, and stop destroying good ones ([#1129](https://github.com/PicPeak/picpeak/issues/1129)) ([dc9e3cd](https://github.com/PicPeak/picpeak/commit/dc9e3cdc5e00ac634f581e8d6b13107fe4839152))
## [3.46.2](https://github.com/PicPeak/picpeak/compare/v3.46.1...v3.46.2) (2026-08-21)
### Bug Fixes
* **ui:** stop iOS Safari zooming in on 14px form fields ([#1114](https://github.com/PicPeak/picpeak/issues/1114)) ([32db1c8](https://github.com/PicPeak/picpeak/commit/32db1c8052d324b09462a17859c7adb5ccfe56e3))
## [3.46.1](https://github.com/PicPeak/picpeak/compare/v3.46.0...v3.46.1) (2026-08-19)
@@ -0,0 +1,246 @@
/**
* POST /admin/thumbnails/regenerate for external/reference photos (#1129).
*
* STABLE TWIN. Diverges from the main version in one place: stable has no
* responsive ?w= tiers (#1095/#1109), so there is no deleteThumbnailTiers call
* to assert and the "drops the tiers first" test is absent here. Everything
* else — the external rebuild, the thumbnail_path:null contract, video
* skipping, per-event scoping and the superseded-key deletion — is identical.
*
* The route used to resolve every source as `storage/events/active/<path>` and
* `fs.access` it. External and reference rows do not live there — their
* originals sit under `events.external_path` — so every one of them failed the
* check and was counted as an error.
*
* That alone would be inert. What made it destructive is that the tier
* deletion runs FIRST (deliberately, so S3 and external rows are not skipped):
* on a reference install the button dropped every ?w= tier and rebuilt
* nothing, while the UI reported success — the response is sent before the
* background loop starts.
*
* The background work is fired with setImmediate, so every assertion here has
* to wait for it to drain rather than trusting the response.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('admin thumbnail regeneration (#1129)', () => {
let tmpDir; let db; let cleanup; let app; let imageProcessor; let storage;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
// One instance, not a fresh object per call — the route and the
// assertions have to be looking at the same mock.
jest.doMock('../../src/services/storage', () => {
const instance = { delete: jest.fn().mockResolvedValue(undefined) };
return { getStorage: () => instance };
});
jest.doMock('../../src/services/imageProcessor', () => ({
ensureThumbnail: jest.fn().mockResolvedValue('thumbnails/thumb_ext1_shot.jpg'),
ensurePreviewImage: jest.fn().mockResolvedValue('previews/p.jpg'),
deletePreviewTiers: jest.fn().mockResolvedValue(undefined),
}));
// bootCrmDb, not run-migrations: the latter calls process.exit(0) on
// success, which ends the jest worker mid-suite.
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
imageProcessor = require('../../src/services/imageProcessor');
storage = require('../../src/services/storage').getStorage();
app = express();
app.use(express.json());
app.use('/admin/thumbnails', require('../../src/routes/adminThumbnails'));
}, 180000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
jest.clearAllMocks();
await db('photos').del();
await db('events').del();
});
async function seedEvent() {
const [row] = await db('events').insert({
slug: 'nas-wedding', event_type: 'wedding', event_name: 'nas',
event_date: '2026-01-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: 'nas-share', expires_at: new Date().toISOString(),
source_mode: 'reference', external_path: 'weddings/2026-08',
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
async function seedPhoto(eventId, overrides = {}) {
const [row] = await db('photos').insert({
event_id: eventId, filename: 'shot.jpg', path: 'nas-wedding/shot.jpg',
type: 'individual', ...overrides,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
/** The work runs in setImmediate; give it room to finish. */
const drain = () => new Promise((resolve) => setTimeout(resolve, 150));
it('rebuilds the canonical thumbnail for an external photo instead of erroring', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'external',
external_relpath: 'shot.jpg',
thumbnail_path: 'thumbnails/stale.jpg',
});
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
expect(res.status).toBe(200);
await drain();
// The whole bug: this used to be zero calls and one logged
// "Original file not found" per photo.
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
});
it('nulls thumbnail_path so the valid-thumbnail short-circuit cannot skip the rebuild', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'external',
external_relpath: 'shot.jpg',
thumbnail_path: 'thumbnails/still-on-disk.jpg',
});
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
// Without this the endpoint is a no-op whenever the OLD thumbnail is still
// readable — which is the normal case after a settings change, and exactly
// when the admin pressed the button.
const [photoArg] = imageProcessor.ensureThumbnail.mock.calls[0];
expect(photoArg.thumbnail_path).toBeNull();
expect(photoArg.source_origin).toBe('external');
// Carried through so ensureThumbnail can resolve off the mount rather than
// under events/active.
expect(photoArg.external_relpath).toBe('shot.jpg');
});
it('leaves videos alone rather than handing a container file to Sharp', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, { source_origin: 'managed', media_type: 'video', filename: 'clip.mp4' });
await seedPhoto(eventId, { source_origin: 'managed', filename: 'still.jpg' });
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(res.body.count).toBe(1);
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
expect(imageProcessor.ensureThumbnail.mock.calls[0][0].filename).toBe('still.jpg');
});
/**
* On S3, ensureThumbnail downloads the source to a randomly-named temp file,
* and for non-RAW input withProcessableImage passes no outputBasename — so
* generateThumbnail derives the key from that random name and it differs on
* every run. Nulling thumbnail_path hides the old key from everything that
* would otherwise clean it up, so each regeneration would strand a full
* thumbnail in the bucket, once per photo per run.
*/
describe('superseded canonical renditions', () => {
it('removes the old thumbnail when the key moved', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'managed',
thumbnail_path: 'thumbnails/thumb_OLDRANDOM_shot.jpg',
});
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEWRANDOM_shot.jpg');
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(storage.delete).toHaveBeenCalledWith('thumbnails/thumb_OLDRANDOM_shot.jpg');
});
it('does NOT delete when the key is unchanged — that is the new file', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'managed',
thumbnail_path: 'thumbnails/thumb_stable.jpg',
});
// Local storage resolves to a stable path, so the key is identical.
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_stable.jpg');
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(storage.delete).not.toHaveBeenCalled();
});
it.each([
['a Windows-style legacy path', 'thumbnails\\thumb_ext1_shot.jpg'],
['a leading ./', './thumbnails/thumb_ext1_shot.jpg'],
['a doubled separator', 'thumbnails//thumb_ext1_shot.jpg'],
])('does not delete the file it just wrote when the old path is %s', async (_name, stored) => {
const eventId = await seedEvent();
await seedPhoto(eventId, { source_origin: 'managed', thumbnail_path: stored });
// Both storage backends fold these to the same key, so this is the SAME
// object — deleting it would remove the freshly generated thumbnail and
// leave the row pointing at nothing.
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_ext1_shot.jpg');
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(storage.delete).not.toHaveBeenCalled();
});
it('counts the photo as regenerated even if the old object cannot be removed', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'managed',
thumbnail_path: 'thumbnails/thumb_OLD.jpg',
});
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEW.jpg');
storage.delete.mockRejectedValueOnce(new Error('bucket said no'));
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
// Losing the old object is untidy; the regeneration itself succeeded.
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
});
});
it('scopes to one event when asked', async () => {
const a = await seedEvent();
await seedPhoto(a, { source_origin: 'external', external_relpath: 'a.jpg' });
const [b] = await db('events').insert({
slug: 'other', event_type: 'wedding', event_name: 'other', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: 'other-share', expires_at: new Date().toISOString(),
}).returning('id');
await seedPhoto(typeof b === 'object' ? b.id : b, { source_origin: 'managed' });
const res = await request(app).post('/admin/thumbnails/regenerate').send({ eventId: a });
await drain();
expect(res.body.count).toBe(1);
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,242 @@
/**
* Repairing the bundled templates' fixed image height (#1131).
*
* The risk in a migration that rewrites user-visible CSS is doing too much,
* so most of what is pinned here is what it must NOT touch: the other pixel
* heights inside the very same templates (a 1px divider, an 8px scrollbar),
* and any rule a user wrote themselves.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const migration = require('../../migrations/core/175_fix_css_template_photo_height');
const ELEGANT_DARK = `
.photo-card {
border-radius: 12px;
}
.photo-card img {
width: 100%;
height: 200px;
object-fit: cover;
transition: transform 0.3s ease;
}
`;
const LIQUID_GLASS_DARK = `
.gallery-page::after {
content: '';
height: 1px;
background: linear-gradient(90deg, transparent, #fff, transparent);
}
.photo-card img {
width: 100%;
height: 240px;
object-fit: cover;
filter: brightness(0.9);
}
.gallery-page ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
@media (max-width: 640px) {
.photo-card img {
height: 180px;
}
}
`;
describe('migration 175 — CSS template image height (#1131)', () => {
let knex; let tmpDir;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig175-'));
knex = require('knex')({
client: 'sqlite3',
connection: { filename: path.join(tmpDir, 'db.sqlite') },
useNullAsDefault: true,
});
await knex.schema.createTable('css_templates', (t) => {
t.increments('id').primary();
t.string('name');
t.text('css_content');
});
});
afterAll(async () => {
if (knex) await knex.destroy();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => { await knex('css_templates').del(); });
const contentOf = async (name) =>
(await knex('css_templates').where({ name }).first()).css_content;
it('relaxes the default template so the layouts h-full can win', async () => {
await knex('css_templates').insert({ name: 'Elegant Dark', css_content: ELEGANT_DARK });
await migration.up(knex);
const css = await contentOf('Elegant Dark');
expect(css).toContain('height: 100%');
expect(css).not.toContain('height: 200px');
// Everything else about the rule survives.
expect(css).toContain('object-fit: cover');
expect(css).toContain('transition: transform 0.3s ease');
});
it('fixes both the base rule and the mobile override of the dark glass template', async () => {
await knex('css_templates').insert({ name: 'Liquid Glass Dark', css_content: LIQUID_GLASS_DARK });
await migration.up(knex);
const css = await contentOf('Liquid Glass Dark');
expect(css).not.toContain('height: 240px');
expect(css).not.toContain('height: 180px');
expect(css.match(/height: 100%/g)).toHaveLength(2);
});
it('leaves the divider and the scrollbar alone', async () => {
await knex('css_templates').insert({ name: 'Liquid Glass Dark', css_content: LIQUID_GLASS_DARK });
await migration.up(knex);
// The whole reason this matches full rule bodies rather than every
// `height: <n>px`: these are in the same stylesheet and are correct.
const css = await contentOf('Liquid Glass Dark');
expect(css).toContain('height: 1px');
expect(css).toContain('width: 8px');
expect(css).toContain('height: 8px');
});
/**
* The case that forced the scope wider. `sanitizeCSS` strips control
* characters, so any template ever saved through the editor — including a
* save that only changed its name — has had every newline REMOVED. An
* exact-text migration finds nothing on those installs, is recorded as
* applied, and leaves them broken permanently.
*/
it('fixes a template that has been through the editor, newlines and all', async () => {
const { sanitizeCSS } = require('../../src/utils/cssSanitizer');
const { sanitized } = sanitizeCSS(ELEGANT_DARK);
// Precondition: the sanitizer really did flatten it.
expect(sanitized).not.toContain('\n');
expect(sanitized).toContain('height: 200px');
await knex('css_templates').insert({ name: 'Saved Once', css_content: sanitized });
await migration.up(knex);
const css = await contentOf('Saved Once');
expect(css).not.toContain('200px');
expect(css).toContain('height: 100%');
});
it('relaxes a user-authored fixed height too, but only on .photo-card img', async () => {
// Deliberately broader than the seeded text — see the migration header. A
// pixel height on the image cannot be right under any of the seven
// layouts, whoever wrote it; a height anywhere else is none of our
// business.
const mine = '.photo-card img {\n height: 220px;\n}\n.hero { height: 400px; }';
await knex('css_templates').insert({ name: 'My Own', css_content: mine });
await migration.up(knex);
const css = await contentOf('My Own');
expect(css).toContain('height: 100%');
expect(css).not.toContain('220px');
expect(css).toContain('.hero { height: 400px; }');
});
it('does not rewrite other properties that merely end in -height', async () => {
// `line-height: 200px` contains `height: 200px` as a substring, so an
// unanchored pattern silently rewrites it — in a migration that cannot be
// undone.
const mine = [
'.photo-card img {',
' line-height: 200px;',
' max-height: 300px;',
' min-height: 14px;',
' --tile-height: 220px;',
' height: 200px;',
'}',
].join('\n');
await knex('css_templates').insert({ name: 'Adjacent Props', css_content: mine });
await migration.up(knex);
const css = await contentOf('Adjacent Props');
expect(css).toContain('line-height: 200px');
expect(css).toContain('max-height: 300px');
expect(css).toContain('min-height: 14px');
expect(css).toContain('--tile-height: 220px');
// Only the real one moved.
expect(css).toContain('height: 100%');
expect(css).not.toMatch(/(?<![\w-])height:\s*200px/);
});
it('handles a grouped selector list', async () => {
// Requiring `{` straight after `img` skipped these entirely — and the
// migration is still recorded as applied, so the template kept the bug.
const mine = '.photo-card img, .thumbnail img {\n height: 200px;\n}';
await knex('css_templates').insert({ name: 'Grouped', css_content: mine });
await migration.up(knex);
const css = await contentOf('Grouped');
expect(css).toContain('.photo-card img, .thumbnail img {');
expect(css).toContain('height: 100%');
expect(css).not.toContain('200px');
});
it('skips a nested rule rather than rewriting the wrong declaration', async () => {
// Valid nested CSS that passes the validator. A brace-greedy body would
// capture the inner block and rewrite the CAPTION's height, which cannot
// be undone. Leaving it untouched is the lesser evil.
const mine = '.photo-card img {\n & + .caption { height: 200px; }\n}';
await knex('css_templates').insert({ name: 'Nested', css_content: mine });
await migration.up(knex);
expect(await contentOf('Nested')).toBe(mine);
});
it('leaves non-pixel heights on the image alone', async () => {
const mine = '.photo-card img { height: 50vh; }\n.photo-card img { height: auto; }';
await knex('css_templates').insert({ name: 'Relative', css_content: mine });
await migration.up(knex);
expect(await contentOf('Relative')).toBe(mine);
});
it('is idempotent and safe on a row with no CSS', async () => {
await knex('css_templates').insert([
{ name: 'Elegant Dark', css_content: ELEGANT_DARK },
{ name: 'Empty', css_content: null },
]);
await migration.up(knex);
const once = await contentOf('Elegant Dark');
await migration.up(knex);
expect(await contentOf('Elegant Dark')).toBe(once);
expect(await contentOf('Empty')).toBeNull();
});
it('no-ops when the table does not exist yet', async () => {
await knex.schema.dropTable('css_templates');
await expect(migration.up(knex)).resolves.toBeUndefined();
await knex.schema.createTable('css_templates', (t) => {
t.increments('id').primary();
t.string('name');
t.text('css_content');
});
});
});
@@ -0,0 +1,90 @@
/**
* Regeneration must not destroy a good thumbnail when the source is
* unreadable (#1129).
*
* The old code deleted the target BEFORE sharp opened the source, so a NAS
* mount that blipped mid-run left the previous rendition gone and the database
* still pointing at it. Across a bulk regenerate that is the whole gallery,
* and it is precisely the "worse than before you pressed it" outcome #1129 is
* about.
*/
const path = require('path');
const fs = require('fs').promises;
const os = require('os');
const sharp = require('sharp');
jest.mock('../../src/database/db', () => ({
db: () => ({ where: () => ({ first: async () => null, update: async () => 1 }) }),
}));
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
const storageModule = require('../../src/services/storage');
describe('generateThumbnail — regenerate is non-destructive (#1129)', () => {
let storage; let root; let imageProcessor; let srcDir;
beforeAll(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-store-'));
srcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-src-'));
storage = new LocalFsStorage({ root });
await storage.init();
storageModule.setStorageForTesting(storage);
delete require.cache[require.resolve('../../src/services/imageProcessor')];
imageProcessor = require('../../src/services/imageProcessor');
}, 30000);
afterAll(async () => {
storageModule.resetStorage();
await fs.rm(root, { recursive: true, force: true }).catch(() => {});
await fs.rm(srcDir, { recursive: true, force: true }).catch(() => {});
});
async function writeSource(name, size = 400) {
const p = path.join(srcDir, name);
await sharp({ create: { width: size, height: size, channels: 3, background: { r: 1, g: 2, b: 3 } } })
.jpeg().toFile(p);
return p;
}
it('keeps the existing thumbnail when the source cannot be read', async () => {
const src = await writeSource('present.jpg');
const key = await imageProcessor.generateThumbnail(src, { regenerate: true });
expect(key).toBeTruthy();
expect(await storage.exists(key)).toBe(true);
const before = await storage.get(key).then((s) => new Promise((res) => {
const c = []; s.on('data', (d) => c.push(d)); s.on('end', () => res(Buffer.concat(c)));
}));
// The mount goes away between runs.
await fs.unlink(src);
const second = await imageProcessor.generateThumbnail(src, { regenerate: true })
.catch(() => null);
expect(second).toBeFalsy();
// The old rendition is still there and still serves. Previously it had
// been deleted before sharp ever looked at the source.
expect(await storage.exists(key)).toBe(true);
const after = await storage.get(key).then((s) => new Promise((res) => {
const c = []; s.on('data', (d) => c.push(d)); s.on('end', () => res(Buffer.concat(c)));
}));
expect(after.equals(before)).toBe(true);
});
it('still replaces the thumbnail when the source IS readable', async () => {
const src = await writeSource('replaceme.jpg', 400);
const key = await imageProcessor.generateThumbnail(src, { regenerate: true });
const firstSize = (await storage.stat(key)).size;
// Same key, different source content — the atomic rename in put() is what
// makes the pre-delete unnecessary.
await fs.rm(src);
await sharp({ create: { width: 400, height: 400, channels: 3, background: { r: 250, g: 40, b: 9 } } })
.jpeg().toFile(src);
const again = await imageProcessor.generateThumbnail(src, { regenerate: true });
expect(again).toBe(key);
expect((await storage.stat(key)).size).not.toBe(firstSize);
});
});
@@ -0,0 +1,160 @@
/**
* The contract that matters here is negative: a source that disappears must
* NOT be able to end the process (#1128).
*
* `fs.createReadStream` is lazy, so its ENOENT lands on a later tick, outside
* the route's try/catch. An EventEmitter emitting 'error' with no listener
* throws, and an uncaught throw from an I/O callback exits Node — which is how
* one missing thumbnail tier took every gallery on the install down.
*
* These use a REAL fs stream over a real missing path rather than a fake
* emitter: the point under test is the lazy-open timing, and a hand-rolled
* mock that emits synchronously would pass while proving nothing.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const { Readable } = require('stream');
const { EventEmitter } = require('events');
const { pipeStreamToResponse } = require('../../src/utils/streamResponse');
jest.mock('../../src/utils/logger', () => ({
warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn(),
}));
/** Minimal Express-ish response that records what happened to it. */
function makeRes() {
const res = new EventEmitter();
res.headers = { 'Content-Length': '1234', ETag: '"x"' };
res.statusCode = 200;
res.headersSent = false;
res.writableEnded = false;
res.body = null;
res.destroyed = false;
res.removeHeader = (h) => { delete res.headers[h]; };
res.setHeader = (h, v) => { res.headers[h] = v; };
res.status = (code) => { res.statusCode = code; return res; };
res.json = (payload) => { res.body = payload; res.writableEnded = true; return res; };
res.destroy = () => { res.destroyed = true; };
// pipe() target surface
res.write = () => true;
res.end = () => { res.writableEnded = true; };
res.on = EventEmitter.prototype.on.bind(res);
res.emit = EventEmitter.prototype.emit.bind(res);
return res;
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 50));
describe('pipeStreamToResponse (#1128)', () => {
it('turns a missing file into a 404 instead of an unhandled error', async () => {
const missing = path.join(os.tmpdir(), `picpeak-not-here-${Date.now()}.jpg`);
const res = makeRes();
pipeStreamToResponse(stream_(missing), res, { context: 'thumbnail for photo 1' });
await settle();
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'File not found' });
});
// How this test discriminates, since the failure mode is a process-level
// one: replacing the call above with a bare `stream.pipe(res)` — what the
// thumbnail route did — makes jest fail this suite on the unhandled 'error'
// event before either assertion runs. Verified by doing exactly that.
// Catching the throw with a process.on('uncaughtException') listener does
// NOT work here and would be theatre: the runner installs its own handling,
// so such a listener never sees it and the assertion could never fail.
function stream_(p) { return fs.createReadStream(p); }
it('strips every header that described the file it can no longer send', async () => {
const res = makeRes();
// What the image and zip routes actually stage before streaming.
res.headers = {
'Content-Length': '1234',
ETag: '"x"',
'Content-Type': 'image/jpeg',
'Content-Disposition': 'attachment; filename="gallery.zip"',
'Cache-Control': 'private, max-age=1800',
};
const stream = fs.createReadStream(path.join(os.tmpdir(), `gone-${Date.now()}.jpg`));
pipeStreamToResponse(stream, res);
await settle();
expect(res.headers['Content-Length']).toBeUndefined();
expect(res.headers.ETag).toBeUndefined();
// Express does NOT overwrite an existing Content-Type, so leaving it makes
// res.json() emit JSON labelled image/jpeg — or a corrupt .zip download.
expect(res.headers['Content-Type']).toBeUndefined();
expect(res.headers['Content-Disposition']).toBeUndefined();
});
it('does not let a transient 404 be cached as a broken tile', async () => {
const res = makeRes();
// The thumbnail route stages 30 minutes; the hero route an hour.
res.headers = { 'Cache-Control': 'private, max-age=1800' };
const stream = fs.createReadStream(path.join(os.tmpdir(), `gone3-${Date.now()}.jpg`));
pipeStreamToResponse(stream, res);
await settle();
// The regeneration race is transient by definition: the tier exists moments
// later. Caching this 404 would keep the tile broken long after the file is
// back — the opposite of what this helper is for.
expect(res.headers['Cache-Control']).toBe('no-store');
});
it('honours a caller that wants a different missing-status', async () => {
const res = makeRes();
const stream = fs.createReadStream(path.join(os.tmpdir(), `gone2-${Date.now()}.zip`));
pipeStreamToResponse(stream, res, { missingStatus: 410 });
await settle();
expect(res.statusCode).toBe(410);
});
it('destroys the response instead of rewriting a status that is already sent', async () => {
const res = makeRes();
res.headersSent = true;
const stream = new Readable({ read() {} });
pipeStreamToResponse(stream, res, { context: 'photo 9' });
stream.emit('error', Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
await settle();
// Once bytes are on the wire a 404 is not available; a truncated image the
// client would cache is worse than a broken connection.
expect(res.destroyed).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBeNull();
});
it('reports a non-ENOENT failure as a 500 rather than a 404', async () => {
const res = makeRes();
const stream = new Readable({ read() {} });
pipeStreamToResponse(stream, res);
stream.emit('error', Object.assign(new Error('disk exploded'), { code: 'EIO' }));
await settle();
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: 'Failed to serve file' });
});
it('releases the source when the client hangs up mid-download', async () => {
const res = makeRes();
let destroyed = false;
const stream = new Readable({ read() {}, destroy(err, cb) { destroyed = true; cb(err); } });
pipeStreamToResponse(stream, res);
res.emit('close');
await settle();
// Otherwise an abandoned grid leaks one open fd per tile.
expect(destroyed).toBe(true);
});
});
@@ -77,7 +77,11 @@ const DEFAULT_CSS_TEMPLATE = `/*
.photo-card img {
width: 100%;
height: 200px;
/* 100%, not a fixed pixel height: every aspect-ratio layout (masonry,
justified, mosaic, gallery-premium) gives .photo-card a definite height
computed from photos.width/height, and this rule's specificity (0,1,1)
beats the .h-full utility (0,1,0) the layouts rely on — #1131. */
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
@@ -503,7 +503,9 @@ const LIQUID_GLASS_DARK = `/*
.photo-card img {
width: 100%;
height: 240px;
/* See #1131: a fixed height here beats the layouts' .h-full utility and
detaches the image from its aspect-ratio-sized card. */
height: 100%;
object-fit: cover;
transition: transform 0.4s ease, filter 0.4s ease;
filter: brightness(0.9);
@@ -639,7 +641,7 @@ const LIQUID_GLASS_DARK = `/*
}
.photo-card img {
height: 180px;
height: 100%;
}
/* Reduce animation complexity on mobile */
@@ -0,0 +1,123 @@
/**
* The bundled CSS templates pinned every gallery image to a fixed pixel
* height, which broke every aspect-ratio layout (#1131).
*
* Six of the seven layouts size a tile by putting a computed pixel height on
* `.photo-card` and letting the image fill it with `h-full`. A template rule
* of `.photo-card img { height: 200px }` has specificity (0,1,1) and beats
* `.h-full` at (0,1,0), so the image detached from its card: masonry rendered
* correctly-shaped cards with a 200px image glued to the top and empty
* background below — or, where the computed card was shorter than 200px, an
* image taller than its own container.
*
* "Elegant Dark" is seeded `is_default = true`, so this was the out-of-the-box
* result for anyone choosing any layout other than grid/timeline (where a
* fixed square happens to look deliberate).
*
* Migrations 052 and 053 are corrected for fresh installs; this repairs the
* rows already seeded. Templates are referenced by `events.css_template_id`
* and read at serve time rather than copied onto the event, so fixing the row
* fixes every gallery using it.
*
* SCOPE: every `.photo-card img` rule that carries a fixed PIXEL height, in
* every template — not just the two we seeded, and not just their pristine
* copies.
*
* That is broader than it first looks, and deliberately so. It is also not the
* scope this started with: matching the exact seeded text missed every install
* where the template had ever been saved through the editor, because
* `sanitizeCSS` strips newlines. Those are the majority, and a migration that
* silently no-ops on them while being recorded as applied is worse than none.
*
* The cost is that a fixed pixel height a user wrote themselves is rewritten
* too. That is judged acceptable because there is no layout it can be right
* for: all seven give `.photo-card` a definite height and expect the image to
* fill it, so a pixel height on the image can only detach it from its card.
* Anything that is not a fixed px height — %, vh, auto — is left alone, as is
* every declaration outside a `.photo-card img` body.
*/
/**
* Every `.photo-card img { … }` rule body, however it is spaced.
*
* Matching the exact seeded text does NOT work, and the reason is worth
* stating: `sanitizeCSS` strips all control characters (cssSanitizer.js:61),
* so the moment an admin saves a template through the editor — even only to
* rename it or toggle it — every newline is REMOVED from the stored CSS. The
* shipped `.photo-card img {\n height: 200px;` becomes
* `.photo-card img { height: 200px;`. An exact-match migration would find
* nothing on those installs, be recorded as applied, and leave the galleries
* broken with no second chance.
*
* Scoped to the rule body rather than the whole stylesheet, so the other pixel
* heights in these same templates — a 1px gradient divider, an 8px scrollbar —
* are untouched.
*/
/*
* Two details in this pattern are deliberate:
*
* * the selector part is a LIST, so `.photo-card img, .thumbnail img { … }`
* is recognised. Requiring `{` straight after `img` skipped grouped
* selectors entirely — and the migration would still be recorded as
* applied, so the template kept the bug with no second chance.
*
* * the body excludes braces, so a rule containing a NESTED block is not
* matched at all. `.photo-card img { & + .caption { height: 200px } }` is
* valid, passes the validator, and a `[^}]*` body would have captured the
* nested block and rewritten the caption's height instead. Skipping it
* means such a template keeps a fixed image height; corrupting unrelated
* declarations in a migration that cannot be undone is the worse of the
* two, and nesting does not appear in anything we ship.
*/
const PHOTO_CARD_IMG_RULE = /([^{}]*\.photo-card\s+img[^{}]*)\{([^{}]*)\}/g;
/**
* Only a fixed PIXEL height is wrong here; %, vh, auto and the rest stay.
*
* The lookbehind is load-bearing rather than defensive: without it the pattern
* matches the TAIL of `line-height`, `max-height`, `min-height` and any custom
* property ending in `-height`, and silently rewrites those instead — in a
* migration whose down() is deliberately irreversible.
*/
const FIXED_PX_HEIGHT = /(?<![\w-])height\s*:\s*\d+(?:\.\d+)?px/gi;
function relaxFixedImageHeights(css) {
return css.replace(PHOTO_CARD_IMG_RULE, (whole, selectors, body) => {
// .test() on a /g regex advances lastIndex, so it is reset on both sides
// of the check — leaving it set makes the NEXT rule start matching from an
// arbitrary offset and silently skip declarations.
FIXED_PX_HEIGHT.lastIndex = 0;
if (!FIXED_PX_HEIGHT.test(body)) return whole;
FIXED_PX_HEIGHT.lastIndex = 0;
return `${selectors}{${body.replace(FIXED_PX_HEIGHT, 'height: 100%')}}`;
});
}
exports.up = async function up(knex) {
if (!(await knex.schema.hasTable('css_templates'))) return;
const rows = await knex('css_templates').select('id', 'css_content');
let fixed = 0;
for (const row of rows) {
const original = row.css_content;
if (!original || typeof original !== 'string') continue;
const updated = relaxFixedImageHeights(original);
if (updated !== original) {
await knex('css_templates').where({ id: row.id }).update({ css_content: updated });
fixed += 1;
}
}
if (fixed > 0) {
console.log(` 175: relaxed the fixed image height in ${fixed} CSS template(s)`);
}
};
exports.down = async function down() {
// Deliberately irreversible. Putting the pixel heights back would re-break
// every aspect-ratio layout, and the rows may have been edited since — there
// is no version of "restore" here that is safer than doing nothing.
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.46.1",
"version": "3.46.3",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"engines": {
+62 -28
View File
@@ -3,12 +3,27 @@ const router = express.Router();
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { generateThumbnail, ensurePreviewImage } = require('../services/imageProcessor');
const path = require('path');
const fs = require('fs').promises;
const { ensureThumbnail, ensurePreviewImage } = require('../services/imageProcessor');
const { getStorage } = require('../services/storage');
const logger = require('../utils/logger');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/**
* Do these two stored paths address the same object?
*
* Compared the way the storage backends do, not as raw strings.
* LocalFsStorage._resolve and S3StorageBackend._key both fold `\` to `/` and
* strip a leading `./`, so a legacy thumbnail_path in any of those shapes is
* the SAME file as the freshly generated POSIX key while comparing unequal —
* and the "the key moved, delete the old one" branch below would then delete
* the thumbnail that had just been written.
*/
function sameStorageKey(a, b) {
const canonical = (key) => String(key)
.replace(/\\/g, '/')
.replace(/^\.?\/+/, '')
.replace(/\/+/g, '/');
return canonical(a) === canonical(b);
}
// Parse JSON-encoded setting values
function parseSettingValue(value) {
@@ -125,10 +140,21 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
try {
const { eventId } = req.body; // Optional: regenerate for specific event only
let query = db('photos').select('id', 'event_id', 'path');
// source_origin/external_relpath/filename are what ensureThumbnail branches
// on to resolve an external source off its mount instead of under
// events/active. thumbnail_path is selected so it can be nulled — see below.
let query = db('photos').select(
'id', 'event_id', 'path', 'media_type', 'mime_type', 'thumbnail_path',
'source_origin', 'external_relpath', 'filename'
);
if (eventId) {
query = query.where('event_id', eventId);
}
// Skip videos: their thumbnail is a poster frame from videoProcessor, so
// handing the container file to Sharp only ever produced an error per row.
query = query.where(function() {
this.whereNull('media_type').orWhere('media_type', '!=', 'video');
});
const photos = await query;
@@ -149,30 +175,38 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
for (const photo of photos) {
try {
const storagePath = getStoragePath();
const originalPath = path.join(storagePath, 'events/active', photo.path);
// Check if original file exists
try {
await fs.access(originalPath);
} catch (err) {
logger.warn(`Original file not found for photo ${photo.id}: ${originalPath}`);
errorCount++;
continue;
}
// Regenerate thumbnail
const thumbnailPath = await generateThumbnail(originalPath, { regenerate: true });
if (thumbnailPath) {
// Update database with new thumbnail path
await db('photos')
.where({ id: photo.id })
.update({
thumbnail_path: thumbnailPath,
updated_at: db.fn.now()
// Through ensureThumbnail, not a hand-rolled path (#1129). This route
// used to resolve every source as `storage/events/active/<path>` and
// fs.access it — a location that does not exist for external or
// reference rows, whose originals live under events.external_path. So
// every one of them failed the check and was counted as an error: on a
// reference install the endpoint rebuilt nothing while the UI reported
// success, because the response is sent before this loop starts.
//
// ensureThumbnail already resolves both source kinds, uses the
// per-photo ext<id>_ output name so two events referencing one NAS
// basename cannot clobber each other, and writes thumbnail_path back
// itself. Nulling thumbnail_path is what stops it short-circuiting on
// isThumbnailValid — necessary rather than cosmetic, because the old
// thumbnail is normally still readable at exactly the moment someone
// presses regenerate.
const newThumbnailPath = await ensureThumbnail({ ...photo, thumbnail_path: null });
if (newThumbnailPath) {
// Drop the superseded rendition when the key MOVED. On S3 the source
// is downloaded to a randomly-named temp file and, for non-RAW input,
// the key is derived from that name — so it differs every run, and
// nulling thumbnail_path hides the old key from everything that would
// otherwise clean it up. Guarded on the key actually changing: local
// storage is stable, and deleting the equal key would delete the file
// just written.
if (photo.thumbnail_path && !sameStorageKey(photo.thumbnail_path, newThumbnailPath)) {
await getStorage().delete(photo.thumbnail_path).catch((err) => {
logger.warn(
`Could not remove superseded thumbnail ${photo.thumbnail_path} for photo ${photo.id}: ${err.message}`
);
});
}
successCount++;
logger.info(`Regenerated thumbnail for photo ${photo.id}`);
} else {
+9 -8
View File
@@ -29,6 +29,7 @@ const { resolveGuest } = require('../middleware/guestAuth');
const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
const { pipeStreamToResponse } = require('../utils/streamResponse');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
@@ -1044,7 +1045,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
res.setHeader('Content-Length', zipInfo.size);
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
const stream = await storage.get(zipInfo.key);
stream.pipe(res);
pipeStreamToResponse(stream, res, { context: `prepared zip for event ${req.event.id}`, missingStatus: 410 });
// Log bulk download
db('access_logs').insert({
@@ -1505,7 +1506,7 @@ router.get('/:slug/photo/:photoId',
const file = useStorageBackend
? await storage.getRange(storageKey, start, end)
: fs.createReadStream(filePath, { start, end });
file.pipe(res);
pipeStreamToResponse(file, res, { context: `video range for photo ${photo.id}` });
} else {
res.writeHead(200, {
'Content-Length': fileSize,
@@ -1517,7 +1518,7 @@ router.get('/:slug/photo/:photoId',
const file = useStorageBackend
? await storage.get(storageKey)
: fs.createReadStream(filePath);
file.pipe(res);
pipeStreamToResponse(file, res, { context: `video for photo ${photo.id}` });
}
return;
}
@@ -1551,7 +1552,7 @@ router.get('/:slug/photo/:photoId',
'X-Protection-Level': 'basic'
});
const wmStream = await storage.get(photo.watermark_path);
return wmStream.pipe(res);
return pipeStreamToResponse(wmStream, res, { context: `watermarked photo ${photo.id}` });
}
} else {
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
@@ -1600,7 +1601,7 @@ router.get('/:slug/photo/:photoId',
res.set('Content-Length', stat.size);
if (photo.mime_type) res.set('Content-Type', photo.mime_type);
const stream = await storage.get(storageKey);
stream.pipe(res);
pipeStreamToResponse(stream, res, { context: `photo ${photo.id}` });
} else {
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
res.sendFile(absolutePath);
@@ -1695,7 +1696,7 @@ router.get('/:slug/thumbnail/:photoId',
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(thumbnailPath);
stream.pipe(res);
pipeStreamToResponse(stream, res, { context: `thumbnail for photo ${photoId}` });
}
} catch (error) {
errorResponse(res, error, 500, 'Failed to serve thumbnail');
@@ -1781,7 +1782,7 @@ router.get('/:slug/hero/:photoId',
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(heroPath);
stream.pipe(res);
pipeStreamToResponse(stream, res, { context: `hero for photo ${photoId}` });
}
} catch (error) {
logger.error('Error serving hero image:', {
@@ -1877,7 +1878,7 @@ router.get('/:slug/preview/:photoId',
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(previewPath);
stream.pipe(res);
pipeStreamToResponse(stream, res, { context: `preview for photo ${photoId}` });
}
} catch (error) {
logger.error('Error serving preview image:', {
+18 -7
View File
@@ -133,10 +133,18 @@ async function generateThumbnail(imagePath, options = {}) {
// Get thumbnail settings
const settings = await getThumbnailSettings();
// Force regeneration: drop the existing object before writing the new one
if (options.regenerate) {
await storage.delete(thumbnailRelKey).catch(() => {});
}
// `options.regenerate` deliberately does NOT delete the existing object first
// (#1129).
//
// It used to, and the delete ran BEFORE sharp had even opened the source — so
// a source that could not be read (a NAS mount that blipped, a corrupt file)
// left the old thumbnail already gone and returned null, with the database
// still pointing at it. One bulk regeneration during a mount outage could
// therefore strip every canonical thumbnail in a reference gallery.
//
// Nothing is lost by dropping it: LocalFsStorage.put stages to a temp file and
// renames over the target, which replaces atomically, and an S3 put overwrites
// by key. The delete only added a window with no thumbnail at all.
try {
// First, verify the source image is complete and valid
@@ -192,9 +200,12 @@ async function generateThumbnail(imagePath, options = {}) {
const msg = (error && error.message) ? error.message : String(error);
logger.error(`Failed to generate thumbnail for ${sourceBasename}: ${msg}`);
// Clean up any partially uploaded object
await storage.delete(thumbnailRelKey).catch(() => {});
// No cleanup delete here either, for the same reason (#1129). This was
// "clean up any partially uploaded object", but there cannot be one:
// storage.put is the LAST statement in the try, every throw above it
// happens before anything is written, and put unlinks its own temp file on
// failure. The only object this could remove is the PREVIOUS, valid
// rendition — exactly the thumbnail a failed regeneration must leave alone.
return null;
}
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Pipe a file/storage stream to an Express response without betting the
* process on the source still being there (#1128).
*
* `fs.createReadStream` — what LocalFsStorage.get() returns — is LAZY. It
* resolves immediately and only opens the file on a later tick, so an ENOENT
* arrives AFTER the `await` returned and outside the route's try/catch. An
* EventEmitter that emits 'error' with no listener throws, and an uncaught
* throw from an I/O callback is not something Express can catch: Node exits.
*
* That is how one missing thumbnail tier took down every gallery on the
* install — the process died on the first grid load and only came back
* because Docker restarted it.
*
* The window is real and cannot be closed by a stat() beforehand: between the
* stat and the open, another request regenerating the same derivative can
* unlink it. So the handler is the fix, not the preflight.
*/
const logger = require('./logger');
/**
* @param {import('stream').Readable} stream source, already opened or lazy
* @param {import('express').Response} res
* @param {object} [options]
* @param {string} [options.context] what was being served, for the log line
* @param {number} [options.missingStatus=404] status when the source is gone
*/
function pipeStreamToResponse(stream, res, options = {}) {
const { context = 'file', missingStatus = 404 } = options;
stream.on('error', (err) => {
const gone = err && (err.code === 'ENOENT' || err.code === 'EISDIR');
// Once bytes are on the wire the status line is spent — there is no way to
// turn this into a 404. Destroy the response so the client sees a broken
// connection rather than a silently truncated image it would cache.
if (res.headersSent) {
logger.warn(`Stream failed mid-response for ${context}: ${err.message}`);
res.destroy(err);
return;
}
// Every header staged for the FILE now describes a body that will never
// be sent. They are cleared rather than left to Express, which does not
// overwrite a Content-Type that is already set — so without this the JSON
// error goes out as `image/jpeg`, or as an `application/zip` attachment
// that saves to disk as a corrupt download.
//
// Cache-Control matters most. The image routes stage `max-age=1800` (the
// hero route 3600), so a 404 from the regeneration race — the transient
// case this whole helper exists for — would be cached as a broken tile for
// up to an hour after the tier finished generating.
res.removeHeader('Content-Length');
res.removeHeader('ETag');
res.removeHeader('Content-Type');
res.removeHeader('Content-Disposition');
res.setHeader('Cache-Control', 'no-store');
if (gone) {
// Expected under the regeneration race — the tier existed at stat time
// and was replaced before the open. One broken tile, not an outage.
logger.warn(`Source vanished while serving ${context}: ${err.message}`);
res.status(missingStatus).json({ error: 'File not found' });
return;
}
logger.error(`Failed to stream ${context}`, { error: err.message, code: err.code });
res.status(500).json({ error: 'Failed to serve file' });
});
// A client that navigates away mid-download leaves the source handle open
// otherwise; on a gallery grid that is one leaked fd per abandoned tile.
res.on('close', () => {
if (!res.writableEnded) stream.destroy();
});
stream.pipe(res);
}
module.exports = { pipeStreamToResponse };
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.46.1",
"version": "3.46.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -54,7 +54,7 @@ interface PhotoCardProps {
const PhotoCard: React.FC<PhotoCardProps> = ({
photo,
width,
height: _height,
height,
onClick,
onLike,
onSelect,
@@ -70,8 +70,13 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
allowLikes = false,
index
}) => {
// Note: height is passed but not used as we maintain aspect ratio via width
void _height;
// The height MasonryPhotoAlbum computed from photos.width/height is used, not
// discarded (#1130). Letting the tile size itself from the image meant the
// rendered shape came from whatever rendition was served — and with
// thumbnail_fit seeded to 'cover' (migration 040) every rendition is square,
// so the masonry laid out identical squares and was indistinguishable from
// the fixed grid. The photo's real aspect ratio is in the DB and is what the
// album already laid out against.
const { ref, inView } = useInView({
triggerOnce: true,
threshold: 0.1,
@@ -85,7 +90,7 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
<motion.div
ref={ref}
className={`gallery-premium-photo-card group ${isSelected ? 'selected' : ''}`}
style={{ width: '100%', height: 'auto', display: 'block' }}
style={{ width: '100%', height, display: 'block' }}
initial={{ opacity: 0, y: 20 }}
animate={inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.4, delay: Math.min(index * 0.05, 0.3) }}
@@ -95,8 +100,11 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
style={{ width, height: 'auto' }}
className="w-full h-auto object-cover"
// No inline height: the card now has a definite one, so the
// stylesheet's `.gallery-premium-photo-card img { height: 100% }` can
// finally apply and object-fit: cover crops a square rendition INTO the
// correctly-shaped tile, rather than the rendition dictating the shape.
className="w-full h-full object-cover"
loading="lazy"
isGallery={true}
slug={slug}
+53
View File
@@ -716,3 +716,56 @@
margin-top: 0;
}
}
/*
* iOS Safari zooms the whole page in when a focused form control computes to
* less than 16px, and it does not zoom back out (#1105). Unlocking a gallery
* is a client-side transition rather than a document navigation, so the zoom
* the password field triggered carries straight into the gallery: the layout
* pans horizontally and the header actions sit off-screen until the visitor
* pinch-zooms out by hand.
*
* The lever is the font size, not the viewport meta — adding maximum-scale=1
* would suppress the zoom by disabling pinch-to-zoom for everyone, which is an
* accessibility regression, so index.html deliberately omits it.
*
* Keyed to the POINTER, not a width. The zoom depends on the computed font
* size and a touch device, never on how wide the viewport is — and a phone in
* landscape is 667956 CSS px, above any width you could call "phone". A
* max-width query fixes portrait and leaves every landscape phone (and iPad)
* still zooming. `pointer: coarse` is the population that actually has the
* behaviour; a mouse-driven desktop reports `fine` and keeps its 14px density.
*
* Deliberately NOT inside @layer, and deliberately more specific than a single
* utility class: `.input` is 14px and ~440 raw controls carry their own
* `text-sm`, so a rule that loses to a utility fixes almost nothing. The
* `:not()` on each selector is what buys that specificity — without it,
* `select`/`textarea` (0,0,1) lose to `.text-sm` (0,1,0) and keep zooming,
* while `input` alone happens to win. Excluding checkbox and radio keeps
* font-size off controls that size their box from it.
*
* max(16px, 1em, 1rem) is a FLOOR, not a size. Writing a flat 16px would make
* controls that are already larger smaller: Typography -> Large sets
* --font-size-base to 18px on body, so anything inheriting it would be clamped
* down and the setting quietly ignored. Each term covers a case the others
* miss - 1em follows the theme's body size, 1rem follows a browser default the
* visitor raised themselves, 16px catches Small themes and .text-sm controls:
*
* normal (body 16) 16px Large theme (body 18) 18px
* Small theme (body 14) 16px browser default 20px 20px
*
* The specificity that beats a utility class also beats a gallery's custom CSS
* (Theme -> Custom CSS), so `.input-themed { font-size: 20px }` lands at 16px
* on touch. That is unavoidable here rather than an oversight: nothing in CSS
* distinguishes a class that sets 14px from one that sets 20px, so a rule that
* loses to the second also loses to the first and fixes nothing. Overriding
* DOWNWARD is the point; upward is the cost. `font-size: 20px !important`
* still wins for anyone who wants it.
*/
@media (pointer: coarse) {
input:not([type="checkbox"]):not([type="radio"]),
select:not([hidden]),
textarea:not([hidden]) {
font-size: max(16px, 1em, 1rem);
}
}