Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f83d144f28 | |||
| b62cd2c290 | |||
| eaa8b41ba3 | |||
| d46397d92a | |||
| e46260ad07 | |||
| e9fadd2ef4 | |||
| d977e3e296 | |||
| da44f1947b | |||
| dc9e3cdc5e | |||
| 7598e20f55 | |||
| 32db1c8052 |
@@ -1 +1 @@
|
||||
{".":"3.46.1"}
|
||||
{".":"3.46.4"}
|
||||
|
||||
@@ -5,6 +5,32 @@ 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.4](https://github.com/PicPeak/picpeak/compare/v3.46.3...v3.46.4) (2026-08-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** a guest's own hidden feedback is hidden from them too ([#1150](https://github.com/PicPeak/picpeak/issues/1150)) ([#1157](https://github.com/PicPeak/picpeak/issues/1157)) ([b62cd2c](https://github.com/PicPeak/picpeak/commit/b62cd2c290d54820e8f58d11719d48592a1cd1f1))
|
||||
* **gallery:** guest filters respect show_feedback_to_guests ([#1044](https://github.com/PicPeak/picpeak/issues/1044)) ([#1156](https://github.com/PicPeak/picpeak/issues/1156)) ([eaa8b41](https://github.com/PicPeak/picpeak/commit/eaa8b41ba323c7eac22e04947fead8e468e9c6c2))
|
||||
* **gallery:** no Logout button on galleries that don't require a password ([#1149](https://github.com/PicPeak/picpeak/issues/1149)) ([#1154](https://github.com/PicPeak/picpeak/issues/1154)) ([d46397d](https://github.com/PicPeak/picpeak/commit/d46397d92a7648910075fb774b14abf77d893865))
|
||||
* **scripts:** regenerate-thumbnails resolves external sources through ensureThumbnail ([#1148](https://github.com/PicPeak/picpeak/issues/1148)) ([#1155](https://github.com/PicPeak/picpeak/issues/1155)) ([e46260a](https://github.com/PicPeak/picpeak/commit/e46260ad0799bd411a4158c4cc31d587ba85d4ca))
|
||||
|
||||
## [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,216 @@
|
||||
/**
|
||||
* Guest filters must respect show_feedback_to_guests (#1044 follow-up).
|
||||
*
|
||||
* Every filter token on /photos is an OR of two halves: what THIS viewer
|
||||
* marked, and what ANYONE marked. The response fields built from the second
|
||||
* half — like_count, comment_count — are all gated on
|
||||
* show_feedback_to_guests. The FILTER was not.
|
||||
*
|
||||
* So with the setting off, the numbers were hidden but `?filter=liked` still
|
||||
* returned exactly the photos other people had liked: the same information as
|
||||
* a set instead of a count, one token at a time. These tests pin the gate on
|
||||
* every token, and pin that the viewer's own half is never gated — filtering
|
||||
* by what you yourself marked is yours to do regardless.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'filter-visibility-secret';
|
||||
|
||||
const SLUG = 'filter-visibility-event';
|
||||
const ME = 'guest-me-identifier';
|
||||
const SOMEONE_ELSE = 'guest-other-identifier';
|
||||
|
||||
describe('guest filters and show_feedback_to_guests (#1044)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let eventId;
|
||||
let mine;
|
||||
let theirs;
|
||||
let myGuestRowId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const setVisibility = (visible) => db('event_feedback_settings')
|
||||
.where({ event_id: eventId })
|
||||
.update({ show_feedback_to_guests: visible });
|
||||
|
||||
// A real verified guest, which is how the viewer's own feedback is actually
|
||||
// identified — NOT the `guest_id` query parameter the frontend invents.
|
||||
const guestToken = () => jwt.sign(
|
||||
{ type: 'guest', guestId: myGuestRowId, eventId },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const filter = async (token, { as = 'me', claimGuestId } = {}) => {
|
||||
const req = request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.query({ filter: token, ...(claimGuestId ? { guest_id: claimGuestId } : {}) })
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
if (as === 'me') req.set('x-guest-token', guestToken());
|
||||
const res = await req;
|
||||
expect(res.status).toBe(200);
|
||||
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
return (photos || []).map((p) => p.id).sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const inserted = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Filter Visibility',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'filter-visibility-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
const addPhoto = async (name) => {
|
||||
const p = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: name,
|
||||
path: `events/filter/${name}`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return p[0]?.id ?? p[0];
|
||||
};
|
||||
mine = await addPhoto('mine.jpg');
|
||||
theirs = await addPhoto('theirs.jpg');
|
||||
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId,
|
||||
feedback_enabled: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_ratings: true,
|
||||
allow_favorites: true,
|
||||
moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
});
|
||||
|
||||
const guestRow = await db('gallery_guests').insert({
|
||||
event_id: eventId,
|
||||
name: 'Me',
|
||||
identifier: ME,
|
||||
created_at: new Date().toISOString(),
|
||||
last_seen_at: new Date().toISOString(),
|
||||
is_deleted: false,
|
||||
}).returning('id');
|
||||
myGuestRowId = guestRow[0]?.id ?? guestRow[0];
|
||||
|
||||
const feedback = (photoId, who, type, extra = {}) => db('photo_feedback').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
guest_identifier: who,
|
||||
// Submission links to the per-person guest row when one is present, and
|
||||
// that is the column the viewer's own half resolves through.
|
||||
guest_id: who === ME ? myGuestRowId : null,
|
||||
feedback_type: type,
|
||||
is_approved: true,
|
||||
is_hidden: false,
|
||||
created_at: new Date().toISOString(),
|
||||
...extra,
|
||||
});
|
||||
|
||||
// Everything on `theirs` belongs to somebody else; `mine` is this viewer's.
|
||||
await feedback(mine, ME, 'like');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'like');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'favorite');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'comment', { comment_text: 'lovely' });
|
||||
await feedback(theirs, SOMEONE_ELSE, 'rating', { rating: 5 });
|
||||
|
||||
// The denormalized counters the aggregate half of the filter reads.
|
||||
await db('photos').where('id', theirs).update({
|
||||
like_count: 1, favorite_count: 1, comment_count: 1, average_rating: 5,
|
||||
});
|
||||
await db('photos').where('id', mine).update({ like_count: 1 });
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('with feedback visible to guests', () => {
|
||||
beforeAll(() => setVisibility(true));
|
||||
|
||||
it('shows other people\'s marks through every token, as before', async () => {
|
||||
expect(await filter('liked')).toEqual([mine, theirs].sort((a, b) => a - b));
|
||||
expect(await filter('favorited')).toEqual([theirs]);
|
||||
expect(await filter('rated')).toEqual([theirs]);
|
||||
expect(await filter('commented')).toEqual([theirs]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with feedback hidden from guests', () => {
|
||||
beforeAll(() => setVisibility(false));
|
||||
|
||||
it('stops every token from selecting on other people\'s marks', async () => {
|
||||
// `theirs` is the photo only other guests marked. It must not come back
|
||||
// through any token — a filter that selects on hidden feedback reports
|
||||
// that feedback just as surely as a count would.
|
||||
expect(await filter('favorited')).toEqual([]);
|
||||
expect(await filter('rated')).toEqual([]);
|
||||
expect(await filter('commented')).toEqual([]);
|
||||
});
|
||||
|
||||
it('still filters by what the viewer marked themselves', async () => {
|
||||
// The viewer's own half is never gated: this is their own action, and
|
||||
// hiding it would break "show me the ones I liked" for no privacy gain.
|
||||
expect(await filter('liked')).toEqual([mine]);
|
||||
});
|
||||
|
||||
it('drops the viewer\'s own feedback once an admin hides it', async () => {
|
||||
// Moderation has to reach the filter too. getPhotoFeedback excludes
|
||||
// hidden rows for the guest's OWN feedback, so a photo matching here
|
||||
// would come back with nothing visible on it to explain why.
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
expect(await filter('liked')).toEqual([]);
|
||||
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: false });
|
||||
expect(await filter('liked')).toEqual([mine]);
|
||||
});
|
||||
|
||||
it('ignores a guest_id supplied by the caller', async () => {
|
||||
// The own-half is resolved from the request identity. If it honoured the
|
||||
// query string instead, anyone holding another guest's identifier could
|
||||
// read that guest's hidden memberships one token at a time — straight
|
||||
// back through the gate this file exists to pin.
|
||||
expect(await filter('favorited', { claimGuestId: SOMEONE_ELSE })).toEqual([]);
|
||||
// And an anonymous caller claiming to be me gets nothing of mine.
|
||||
expect(await filter('liked', { as: 'anon', claimGuestId: ME })).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Hidden feedback, seen from the guest who left it (#1150).
|
||||
*
|
||||
* Everything in the system treats a hidden row as absent: getPhotoFeedback
|
||||
* drops it even for the guest's own feedback, the /photos filters drop it, and
|
||||
* updatePhotoFeedbackStats does not count it. One place disagreed — the
|
||||
* per-viewer `is_liked` heart — so a like the photographer had hidden still
|
||||
* showed as liked on a photo whose like_count was zero. (The `my_color_label`
|
||||
* badge has the same shape on main; colour labels are not on this branch.)
|
||||
*
|
||||
* Making those two agree exposes the second half: the duplicate check that
|
||||
* powers like/favorite toggling did NOT skip hidden rows, so the now-empty
|
||||
* heart, when clicked, found the hidden row and toggled it OFF. The click
|
||||
* appeared to do nothing and it took two more to get back to a filled heart.
|
||||
*
|
||||
* Hiding a non-comment is deliberate, not an accident of the raw route: #839
|
||||
* and #1044 both ship it, with tests asserting that a hidden reaction or
|
||||
* colour label stops counting. So the fix is to make hidden mean absent
|
||||
* consistently — not to stop admins hiding these.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-feedback-secret';
|
||||
|
||||
const SLUG = 'hidden-own-feedback';
|
||||
const ME = 'guest-me-identifier';
|
||||
|
||||
describe('a guest\'s own hidden feedback (#1150)', () => {
|
||||
let db; let cleanup; let app; let feedbackService;
|
||||
let eventId; let photoId; let myGuestRowId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
const guestToken = () => jwt.sign(
|
||||
{ type: 'guest', guestId: myGuestRowId, eventId },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const getPhoto = async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.set('x-guest-token', guestToken());
|
||||
expect(res.status).toBe(200);
|
||||
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
return (photos || []).find((p) => p.id === photoId);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
feedbackService = require('../../src/services/feedbackService');
|
||||
|
||||
const [ev] = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Hidden Own Feedback',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'hidden-own-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = typeof ev === 'object' ? ev.id : ev;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'shot.jpg', path: 'events/hidden/shot.jpg',
|
||||
type: 'individual', uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const [g] = await db('gallery_guests').insert({
|
||||
event_id: eventId, name: 'Me', identifier: ME,
|
||||
created_at: new Date().toISOString(), last_seen_at: new Date().toISOString(),
|
||||
is_deleted: false,
|
||||
}).returning('id');
|
||||
myGuestRowId = typeof g === 'object' ? g.id : g;
|
||||
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId, feedback_enabled: true, allow_likes: true,
|
||||
moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
});
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
const like = () => db('photo_feedback').insert({
|
||||
photo_id: photoId, event_id: eventId, guest_identifier: ME,
|
||||
guest_id: myGuestRowId, feedback_type: 'like',
|
||||
is_approved: true, is_hidden: false, created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('photo_feedback').where({ photo_id: photoId }).del();
|
||||
await db('photos').where('id', photoId).update({ like_count: 0 });
|
||||
});
|
||||
|
||||
describe('the read surfaces agree with each other', () => {
|
||||
it('un-fills the heart once the like is hidden', async () => {
|
||||
await like();
|
||||
await feedbackService.updatePhotoFeedbackStats(photoId);
|
||||
expect((await getPhoto()).is_liked).toBe(true);
|
||||
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
await feedbackService.updatePhotoFeedbackStats(photoId);
|
||||
|
||||
const photo = await getPhoto();
|
||||
// like_count already ignored hidden rows, so the heart was the only
|
||||
// thing still claiming this photo was liked.
|
||||
expect(photo.like_count).toBe(0);
|
||||
expect(photo.is_liked).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('and every other surface agrees', () => {
|
||||
it('keeps a hidden like out of /my-feedback', async () => {
|
||||
await like();
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/my-feedback`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.set('x-guest-token', guestToken());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// In guest identity mode the Liked/Favorited/Rated chips and their
|
||||
// filters are built from THIS array, not from is_liked — so a hidden
|
||||
// like left an empty heart while the chip still counted it.
|
||||
expect(res.body.filter((f) => f.feedback_type === 'like')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not count a hidden row against the guest cap', async () => {
|
||||
await db('event_feedback_settings')
|
||||
.where({ event_id: eventId }).update({ max_likes_per_guest: 1 });
|
||||
await like();
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
// The hidden row is room, not an occupant: the guest sees an empty
|
||||
// heart, and meeting that click with limit_reached leaves the control
|
||||
// dead until they un-like something they can still see.
|
||||
const result = await feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
|
||||
});
|
||||
expect(result.limit_reached).toBeUndefined();
|
||||
|
||||
await db('event_feedback_settings')
|
||||
.where({ event_id: eventId }).update({ max_likes_per_guest: null });
|
||||
});
|
||||
|
||||
it('leaves other anonymous rows alone when there is no identity to scope by', async () => {
|
||||
// With neither guest_id nor guest_identifier the collapse scope degrades
|
||||
// to `guest_identifier IS NULL` — every identifier-less row on the
|
||||
// photo, i.e. other people's.
|
||||
const anon = (extra) => ({
|
||||
photo_id: photoId, event_id: eventId, feedback_type: 'like',
|
||||
is_approved: true, created_at: new Date().toISOString(), ...extra,
|
||||
});
|
||||
const [h] = await db('photo_feedback').insert(anon({ is_hidden: true })).returning('id');
|
||||
const hiddenId = typeof h === 'object' ? h.id : h;
|
||||
await db('photo_feedback').insert(anon({ is_hidden: false }));
|
||||
await db('photo_feedback').insert(anon({ is_hidden: false }));
|
||||
|
||||
await feedbackService.moderateFeedback(hiddenId, 'approve', 1);
|
||||
|
||||
expect(await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false }))
|
||||
.toHaveLength(3);
|
||||
});
|
||||
|
||||
it('collapses the replacement when an admin unhides the original', async () => {
|
||||
await like();
|
||||
const original = await db('photo_feedback').where({ photo_id: photoId }).first();
|
||||
await db('photo_feedback').where('id', original.id).update({ is_hidden: true });
|
||||
|
||||
await feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
|
||||
});
|
||||
expect(await db('photo_feedback').where({ photo_id: photoId })).toHaveLength(2);
|
||||
|
||||
await feedbackService.moderateFeedback(original.id, 'approve', 1);
|
||||
|
||||
// Two visible rows for one guest would double-count in the tallies and
|
||||
// need two toggles to clear, since each deletes a single row.
|
||||
const visible = await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
|
||||
expect(visible).toHaveLength(1);
|
||||
expect(visible[0].id).toBe(original.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and clicking still works afterwards', () => {
|
||||
it('re-liking creates a fresh row instead of toggling the hidden one off', async () => {
|
||||
await like();
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
// What the guest sees is an empty heart, so this is an ADD.
|
||||
const result = await feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'like',
|
||||
guest_identifier: ME,
|
||||
guest_id: myGuestRowId,
|
||||
});
|
||||
|
||||
// Before this, the duplicate check found the hidden row and deleted it —
|
||||
// `removed: true` — so the click did nothing visible and the moderation
|
||||
// was silently undone.
|
||||
expect(result.removed).toBeUndefined();
|
||||
|
||||
const visible = await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
|
||||
expect(visible).toHaveLength(1);
|
||||
expect((await getPhoto()).is_liked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* scripts/regenerate-thumbnails.js against external photos (#1148).
|
||||
*
|
||||
* The same defect #1129 fixed in the admin route, still standing in the CLI
|
||||
* fallback: the script resolved every source as
|
||||
* `storage/events/active/<photo.path>` and fs.access'd it. External and
|
||||
* reference rows do not live there — their originals sit under
|
||||
* `events.external_path` — so every one failed the check and was counted as an
|
||||
* error. On an install where all photos are external the script did nothing at
|
||||
* all, while reporting one error per photo.
|
||||
*
|
||||
* Driven against a REAL file on a REAL external mount with the real
|
||||
* imageProcessor, not a mock: the whole point is that the source resolves off
|
||||
* the mount, and a mocked ensureThumbnail would assert nothing about that.
|
||||
*
|
||||
* Responsive tiers (#1095/#1109) do not exist on this branch, so the tier
|
||||
* backfill in the main twin has nothing to port. Everything else does.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const sharp = require('sharp');
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
describe('regenerate-thumbnails script (#1148)', () => {
|
||||
let tmpDir; let db; let cleanup; let regenerateThumbnails;
|
||||
let eventId; let externalPhotoId; let videoPhotoId; let watcherVideoId; let repairPhotoId;
|
||||
let vanishingPhotoId;
|
||||
let externalRoot;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-script-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
// External sources are sandboxed under EXTERNAL_MEDIA_ROOT; event
|
||||
// external_path is relative to it, exactly as on a real install.
|
||||
process.env.EXTERNAL_MEDIA_ROOT = path.join(tmpDir, 'media');
|
||||
externalRoot = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'wedding');
|
||||
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
|
||||
await fs.promises.mkdir(externalRoot, { recursive: true });
|
||||
|
||||
jest.resetModules();
|
||||
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
// A real image on the external mount — never under events/active.
|
||||
await sharp({
|
||||
create: { width: 1200, height: 800, channels: 3, background: { r: 10, g: 90, b: 160 } },
|
||||
}).jpeg().toFile(path.join(externalRoot, 'shot.jpg'));
|
||||
|
||||
const [ev] = await db('events').insert({
|
||||
slug: 'regen-script-event',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Regen Script',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: '/gallery/regen-script-event/share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
source_mode: 'reference',
|
||||
external_path: 'wedding',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = typeof ev === 'object' ? ev.id : ev;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'shot.jpg',
|
||||
// `path` is what the old script joined onto events/active. Left
|
||||
// populated on purpose: the fix must ignore it for an external row.
|
||||
path: 'regen-script-event/shot.jpg',
|
||||
type: 'individual',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'shot.jpg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
externalPhotoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const [v] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'clip.mp4',
|
||||
path: 'regen-script-event/clip.mp4',
|
||||
type: 'individual',
|
||||
media_type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'clip.mp4',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
videoPhotoId = typeof v === 'object' ? v.id : v;
|
||||
|
||||
// How fileWatcher.processNewPhoto actually writes a video: `type` and
|
||||
// `mime_type` set, media_type left to its 'image' default. A media_type-only
|
||||
// filter lets this through and hands the container to Sharp.
|
||||
//
|
||||
// The file has to EXIST, otherwise the row fails resolution and looks
|
||||
// skipped for the wrong reason — the bug is Sharp being handed a video, not
|
||||
// a missing source. Real MP4 header bytes, no image in sight.
|
||||
await fs.promises.writeFile(
|
||||
path.join(externalRoot, 'watched.mp4'),
|
||||
Buffer.from('00000018667479706d70343200000000', 'hex')
|
||||
);
|
||||
const [wv] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'watched.mp4',
|
||||
path: 'regen-script-event/watched.mp4',
|
||||
type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'watched.mp4',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
watcherVideoId = typeof wv === 'object' ? wv.id : wv;
|
||||
expect((await db('photos').where('id', watcherVideoId).first()).media_type).not.toBe('video');
|
||||
|
||||
// A photo whose thumbnail_path points at something that is no longer there.
|
||||
await sharp({
|
||||
create: { width: 900, height: 600, channels: 3, background: { r: 200, g: 40, b: 40 } },
|
||||
}).jpeg().toFile(path.join(externalRoot, 'repair.jpg'));
|
||||
const [rp] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'repair.jpg',
|
||||
path: 'regen-script-event/repair.jpg',
|
||||
type: 'individual',
|
||||
thumbnail_path: 'thumbnails/thumb_ext_missing_repair.jpg',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'repair.jpg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
repairPhotoId = typeof rp === 'object' ? rp.id : rp;
|
||||
|
||||
// A photo whose source is not on the mount at all — an unavailable mount,
|
||||
// which is the failure an operator most needs to hear about.
|
||||
const [vp] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'missing.jpg',
|
||||
path: 'regen-script-event/missing.jpg',
|
||||
type: 'individual',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'missing.jpg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
vanishingPhotoId = typeof vp === 'object' ? vp.id : vp;
|
||||
|
||||
({ regenerateThumbnails } = require('../../scripts/regenerate-thumbnails'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it('builds a thumbnail for an external photo instead of erroring on events/active', async () => {
|
||||
// The location the old script computed and fs.access'd. Nothing is there,
|
||||
// which is the whole defect — it is not where an external original lives.
|
||||
// (The old script cannot be driven from a test directly: it had no export
|
||||
// and ran on require, calling process.exit. Making it importable is part
|
||||
// of this fix.)
|
||||
const legacyPath = path.join(process.env.STORAGE_PATH, 'events/active', 'regen-script-event/shot.jpg');
|
||||
expect(fs.existsSync(legacyPath)).toBe(false);
|
||||
|
||||
const result = await regenerateThumbnails(eventId);
|
||||
|
||||
// The old script reported an error for this photo and wrote nothing.
|
||||
// The unresolvable row fails; the external photo and the repair row build.
|
||||
expect(result.errorCount).toBe(1);
|
||||
expect(result.successCount).toBe(2);
|
||||
|
||||
const row = await db('photos').where('id', externalPhotoId).first();
|
||||
expect(row.thumbnail_path).toBeTruthy();
|
||||
const onDisk = path.join(process.env.STORAGE_PATH, row.thumbnail_path);
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
|
||||
// Named per-photo so two events referencing one NAS basename cannot
|
||||
// clobber each other — the property ensureThumbnail owns and the reason
|
||||
// the script must not build this name itself.
|
||||
expect(path.basename(row.thumbnail_path)).toContain(`ext${externalPhotoId}_`);
|
||||
});
|
||||
|
||||
it('leaves videos alone', async () => {
|
||||
// A video thumbnail is a poster frame from videoProcessor; handing the
|
||||
// container to Sharp produced one error per video row.
|
||||
const row = await db('photos').where('id', videoPhotoId).first();
|
||||
expect(row.thumbnail_path).toBeFalsy();
|
||||
});
|
||||
|
||||
it('leaves a watcher-imported video alone, which carries no media_type', async () => {
|
||||
// fileWatcher writes type + mime_type and lets media_type default to
|
||||
// 'image', so filtering on media_type alone still fed these to Sharp. The
|
||||
// signal is errorCount: the images are already done by now, so the only
|
||||
// NEW thing that could fail this run is a video reaching Sharp. One error
|
||||
// is the deliberately unresolvable row; two would be the video.
|
||||
const result = await regenerateThumbnails(eventId);
|
||||
|
||||
expect(result.errorCount).toBe(1);
|
||||
const row = await db('photos').where('id', watcherVideoId).first();
|
||||
expect(row.thumbnail_path).toBeFalsy();
|
||||
});
|
||||
|
||||
it('is idempotent — a second run skips instead of rebuilding', async () => {
|
||||
const before = await db('photos').where('id', externalPhotoId).first();
|
||||
const result = await regenerateThumbnails(eventId);
|
||||
|
||||
expect(result.errorCount).toBe(1);
|
||||
expect(result.successCount).toBe(0);
|
||||
expect(result.skipCount).toBe(2);
|
||||
|
||||
const after = await db('photos').where('id', externalPhotoId).first();
|
||||
expect(after.thumbnail_path).toBe(before.thumbnail_path);
|
||||
});
|
||||
|
||||
it('counts a repaired thumbnail as generated, not skipped', async () => {
|
||||
// Both images are valid at this point. Destroy ONE thumbnail object while
|
||||
// leaving thumbnail_path pointing at it — the corrupt/missing case.
|
||||
const row = await db('photos').where('id', repairPhotoId).first();
|
||||
const onDisk = path.join(process.env.STORAGE_PATH, row.thumbnail_path);
|
||||
await fs.promises.rm(onDisk);
|
||||
|
||||
const result = await regenerateThumbnails(eventId);
|
||||
|
||||
// On local and external storage the rebuilt key is identical, so inferring
|
||||
// "skipped" from an unchanged path reports this repair as already valid —
|
||||
// the one number an operator running this is actually reading.
|
||||
expect(result.successCount).toBe(1);
|
||||
expect(result.skipCount).toBe(1);
|
||||
expect(result.errorCount).toBe(1);
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
});
|
||||
|
||||
/** Run the CLI the way cron does, and hand back its exit status. */
|
||||
const runCli = (args = []) => new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[path.join(__dirname, '..', '..', 'scripts', 'regenerate-thumbnails.js'), ...args],
|
||||
{ env: { ...process.env }, cwd: path.join(__dirname, '..', '..') },
|
||||
(error, stdout, stderr) => resolve({ code: error?.code ?? 0, stdout, stderr })
|
||||
);
|
||||
});
|
||||
|
||||
it('exits nonzero when a photo could not be built', async () => {
|
||||
// Exit status is the only thing a cron job reads, and `missing.jpg` has no
|
||||
// source on the mount.
|
||||
const failed = await runCli([String(eventId)]);
|
||||
expect(failed.code).toBe(1);
|
||||
expect(failed.stderr).toContain('completed with failures');
|
||||
}, 120000);
|
||||
|
||||
it('exits zero when every photo resolves', async () => {
|
||||
// Drop the unresolvable row: a clean run must not cry wolf at automation.
|
||||
await db('photos').where('id', vanishingPhotoId).del();
|
||||
const ok = await runCli([String(eventId)]);
|
||||
expect(ok.code).toBe(0);
|
||||
expect(ok.stdout).toContain('Script completed successfully');
|
||||
}, 120000);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -135,9 +135,9 @@ function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
|
||||
);
|
||||
}
|
||||
|
||||
function signGalleryToken({ eventId = 100, eventSlug = 'wedding' } = {}) {
|
||||
function signGalleryToken({ eventId = 100, eventSlug = 'wedding', ...extra } = {}) {
|
||||
return jwt.sign(
|
||||
{ eventId, eventSlug, type: 'gallery' },
|
||||
{ eventId, eventSlug, type: 'gallery', ...extra },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
@@ -288,6 +288,56 @@ describe('GET /auth/session — symmetry with protected middleware', () => {
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* What KIND of gallery session this is (#1149).
|
||||
*
|
||||
* The frontend used to keep this in sessionStorage, which is per-TAB while
|
||||
* the cookie is per-browser: a gallery reopened in a second tab lost
|
||||
* 'client' even though the backend still served it as one, and the UI hid
|
||||
* the only control that clears the privileged cookie. Reported from the
|
||||
* token so a restored session knows what it actually is.
|
||||
*/
|
||||
describe('gallery session kind', () => {
|
||||
beforeEach(() => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a PIN-client session as client', async () => {
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${signGalleryToken({ accessLevel: 'client' })}`);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.accessLevel).toBe('client');
|
||||
expect(res.body.viaCustomer).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a customer-portal session, which looks like a guest', async () => {
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${signGalleryToken({ via: 'customer', customerId: 7 })}`);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.accessLevel).toBe('guest');
|
||||
expect(res.body.viaCustomer).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a plain guest as neither', async () => {
|
||||
// The flags have to discriminate, or they would just hand every visitor
|
||||
// a Logout button back.
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${signGalleryToken()}`);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.accessLevel).toBe('guest');
|
||||
expect(res.body.viaCustomer).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns valid:false when the token is revoked', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.46.1",
|
||||
"version": "3.46.4",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"engines": {
|
||||
|
||||
@@ -1,141 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to regenerate missing thumbnails for photos in the database
|
||||
* Usage: node scripts/regenerate-thumbnails.js [eventId]
|
||||
* Fill in missing thumbnails for photos already in the database.
|
||||
*
|
||||
* The CLI fallback for when the admin UI is not reachable. It is deliberately
|
||||
* "missing only": ensureThumbnail short-circuits on a thumbnail that is
|
||||
* already present and valid, so re-running this is cheap and safe. To REBUILD
|
||||
* everything after a settings change, use POST /api/admin/thumbnails/regenerate
|
||||
* — that path drops the existing renditions first, which this one must not do.
|
||||
*
|
||||
* Resolution goes through ensureThumbnail rather than a hand-built path
|
||||
* (#1148, same defect as #1129). This script used to compute
|
||||
* `storage/events/active/<photo.path>` and fs.access it, a location that does
|
||||
* not exist for `external` or `reference` rows — their originals live under
|
||||
* the mount in events.external_path. Every such photo failed the check and was
|
||||
* counted as an error, so on an external-media install the script was inert
|
||||
* while reporting one error per photo.
|
||||
*
|
||||
* ensureThumbnail already branches on source_origin, resolves both kinds via
|
||||
* photoResolver, 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. Sharing it is what stops the script and the
|
||||
* route drifting apart again.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/regenerate-thumbnails.js [eventId]
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const sharp = require('sharp');
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
// Configuration
|
||||
const THUMBNAIL_SIZE = 300;
|
||||
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
|
||||
|
||||
async function ensureDirectoryExists(dirPath) {
|
||||
try {
|
||||
await fs.access(dirPath);
|
||||
} catch {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
console.log(`Created directory: ${dirPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateThumbnail(photoPath, thumbnailPath) {
|
||||
try {
|
||||
await sharp(photoPath)
|
||||
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
|
||||
fit: 'cover',
|
||||
position: 'center'
|
||||
})
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(thumbnailPath);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate thumbnail for ${photoPath}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const { ensureThumbnail, isThumbnailValid } = require('../src/services/imageProcessor');
|
||||
|
||||
async function regenerateThumbnails(eventId = null) {
|
||||
try {
|
||||
console.log('Starting thumbnail regeneration...');
|
||||
console.log(`Storage path: ${STORAGE_PATH}`);
|
||||
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}`);
|
||||
|
||||
// Ensure thumbnails directory exists
|
||||
await ensureDirectoryExists(THUMBNAILS_DIR);
|
||||
|
||||
// Build query
|
||||
let query = db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.path',
|
||||
'photos.thumbnail_path',
|
||||
'events.slug as event_slug'
|
||||
);
|
||||
|
||||
if (eventId) {
|
||||
query = query.where('photos.event_id', eventId);
|
||||
console.log(`Filtering for event ID: ${eventId}`);
|
||||
}
|
||||
|
||||
const photos = await query;
|
||||
console.log(`Found ${photos.length} photos to process`);
|
||||
|
||||
let successCount = 0;
|
||||
let skipCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const photo of photos) {
|
||||
const photoPath = path.join(STORAGE_PATH, 'events/active', photo.path);
|
||||
const thumbnailFilename = `thumb_${photo.filename}`;
|
||||
const thumbnailPath = path.join(THUMBNAILS_DIR, thumbnailFilename);
|
||||
|
||||
try {
|
||||
// Check if photo file exists
|
||||
await fs.access(photoPath);
|
||||
|
||||
// Check if thumbnail already exists
|
||||
try {
|
||||
await fs.access(thumbnailPath);
|
||||
console.log(`Thumbnail already exists for ${photo.filename}, skipping...`);
|
||||
skipCount++;
|
||||
continue;
|
||||
} catch {
|
||||
// Thumbnail doesn't exist, generate it
|
||||
}
|
||||
|
||||
console.log(`Generating thumbnail for ${photo.filename}...`);
|
||||
const success = await generateThumbnail(photoPath, thumbnailPath);
|
||||
|
||||
if (success) {
|
||||
// Update database with thumbnail path
|
||||
await db('photos')
|
||||
.where('id', photo.id)
|
||||
.update({
|
||||
thumbnail_path: `thumbnails/${thumbnailFilename}`
|
||||
});
|
||||
|
||||
successCount++;
|
||||
console.log(`✓ Generated thumbnail for ${photo.filename}`);
|
||||
} else {
|
||||
errorCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`✗ Photo file not found: ${photoPath}`);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nThumbnail regeneration complete!');
|
||||
console.log(`- Successfully generated: ${successCount}`);
|
||||
console.log(`- Skipped (already exist): ${skipCount}`);
|
||||
console.log(`- Errors: ${errorCount}`);
|
||||
console.log(`- Total processed: ${photos.length}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during thumbnail regeneration:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
console.log('Starting thumbnail regeneration...');
|
||||
|
||||
// These columns are what ensureThumbnail branches on to resolve a source and
|
||||
// name its output. Selecting a subset that misses
|
||||
// source_origin/external_relpath is how the old path bug would come back —
|
||||
// an external row would look managed and resolve under events/active.
|
||||
let query = db('photos').select(
|
||||
'id', 'event_id', 'path', 'filename', 'thumbnail_path',
|
||||
'type', 'media_type', 'mime_type', 'source_origin', 'external_relpath'
|
||||
);
|
||||
|
||||
if (eventId) {
|
||||
query = query.where('event_id', eventId);
|
||||
console.log(`Filtering for event ID: ${eventId}`);
|
||||
}
|
||||
|
||||
// Skip videos. A video's thumbnail is a poster frame produced by
|
||||
// videoProcessor, not a resize of the stored file, so handing the container
|
||||
// to Sharp here only ever produced one error per row.
|
||||
//
|
||||
// Tested on every marker a video row can carry, not media_type alone:
|
||||
// fileWatcher.processNewPhoto writes `type` and `mime_type` but never
|
||||
// media_type, which defaults to 'image' — so an auto-imported video passes a
|
||||
// media_type-only filter. Each clause is null-safe on its own so a row that
|
||||
// simply has no mime_type is not swept up with them.
|
||||
query = query
|
||||
.where(function () {
|
||||
this.whereNull('media_type').orWhere('media_type', '!=', 'video');
|
||||
})
|
||||
.where(function () {
|
||||
this.whereNull('type').orWhere('type', '!=', 'video');
|
||||
})
|
||||
.where(function () {
|
||||
this.whereNull('mime_type').orWhereNot('mime_type', 'like', 'video/%');
|
||||
});
|
||||
|
||||
const photos = await query;
|
||||
console.log(`Found ${photos.length} photos to process`);
|
||||
|
||||
let successCount = 0;
|
||||
let skipCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const photo of photos) {
|
||||
const label = photo.filename || `photo ${photo.id}`;
|
||||
try {
|
||||
const existing = photo.thumbnail_path;
|
||||
// Asked BEFORE the call, not inferred from the returned path afterwards.
|
||||
// On local and external storage the key is deterministic, so repairing a
|
||||
// missing or corrupt thumbnail hands back the identical string — and
|
||||
// comparing paths would report that repair as "already valid", which is
|
||||
// the one number an operator running this is actually reading.
|
||||
const wasValid = existing ? await isThumbnailValid(existing) : false;
|
||||
const thumbnailPath = await ensureThumbnail(photo);
|
||||
|
||||
if (!thumbnailPath) {
|
||||
console.error(`✗ Could not generate thumbnail for ${label}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (wasValid && thumbnailPath === existing) {
|
||||
skipCount++;
|
||||
} else {
|
||||
successCount++;
|
||||
console.log(`✓ Generated thumbnail for ${label}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed for ${label}: ${error.message}`);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nThumbnail regeneration complete!');
|
||||
console.log(`- Generated: ${successCount}`);
|
||||
console.log(`- Skipped (already valid): ${skipCount}`);
|
||||
console.log(`- Errors: ${errorCount}`);
|
||||
console.log(`- Total processed: ${photos.length}`);
|
||||
|
||||
return { successCount, skipCount, errorCount };
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const eventId = process.argv[2] ? parseInt(process.argv[2]) : null;
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
const eventArg = args.find((a) => !a.startsWith('--'));
|
||||
const eventId = eventArg ? parseInt(eventArg, 10) : null;
|
||||
|
||||
// Run the script
|
||||
regenerateThumbnails(eventId).then(() => {
|
||||
console.log('Script completed successfully');
|
||||
process.exit(0);
|
||||
}).catch(error => {
|
||||
console.error('Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
if (eventArg && !Number.isInteger(eventId)) {
|
||||
console.error(`Not an event id: ${eventArg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
regenerateThumbnails(eventId)
|
||||
.then(async (result) => {
|
||||
await db.destroy();
|
||||
// Exit status is the only thing a cron job reads. Resolving with a
|
||||
// nonzero errorCount and still exiting 0 told automation the backfill
|
||||
// was done when it had failed — which is how an unavailable mount stays
|
||||
// unnoticed until someone opens a gallery.
|
||||
if (result.errorCount) {
|
||||
console.error(`Script completed with failures: ${result.errorCount} photo(s)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(async (error) => {
|
||||
console.error('Script failed:', error);
|
||||
await db.destroy().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { regenerateThumbnails };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -725,7 +725,18 @@ router.get('/session', async (req, res) => {
|
||||
expiresIn: Math.floor(remainingTime),
|
||||
user: decoded.username || decoded.eventSlug,
|
||||
eventSlug: decoded.eventSlug,
|
||||
adminUsername: decoded.username
|
||||
adminUsername: decoded.username,
|
||||
// What KIND of gallery session this cookie is (#1149). The frontend
|
||||
// kept this in sessionStorage, which is per-tab: reopening a gallery
|
||||
// in a second tab lost 'client' while the cookie — and therefore the
|
||||
// backend — still treated it as one. Reported from the token so a
|
||||
// restored session knows what it actually is.
|
||||
//
|
||||
// viaCustomer marks a portal-minted token, which opens the gallery
|
||||
// without the password. Also a credential, and it does not look like
|
||||
// one: it runs at accessLevel 'guest'.
|
||||
accessLevel: decoded.type === 'gallery' ? (decoded.accessLevel || 'guest') : undefined,
|
||||
viaCustomer: decoded.type === 'gallery' ? decoded.via === 'customer' : undefined
|
||||
});
|
||||
} catch (err) {
|
||||
res.json({
|
||||
|
||||
@@ -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');
|
||||
@@ -412,7 +413,10 @@ router.get('/:slug/show/:token/state', handleAsync(async (req, res) => {
|
||||
router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res) => {
|
||||
try {
|
||||
// Get filter and sort parameters from query
|
||||
const { filter, guest_id, sort = 'upload_date', order = 'desc' } = req.query;
|
||||
// `guest_id` is deliberately NOT read from the query string: the viewer's
|
||||
// own feedback is resolved from the request identity instead (see the
|
||||
// filter block). The frontend still sends it; it is ignored.
|
||||
const { filter, sort = 'upload_date', order = 'desc' } = req.query;
|
||||
|
||||
// Get watermark settings to generate cache-busting version for URLs
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
@@ -457,6 +461,13 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
// Execute the query
|
||||
let photos = await photosQuery;
|
||||
|
||||
// Check if feedback should be visible to guests. Read BEFORE the filter
|
||||
// block, not after: the filters below consult it, because a filter that
|
||||
// selects on other people's feedback is a way of reading that feedback.
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const feedbackSettings = await feedbackService.getEventFeedbackSettings(req.event.id);
|
||||
const showFeedbackToGuests = isClient || parseBooleanInput(feedbackSettings.show_feedback_to_guests, true);
|
||||
|
||||
// Apply filtering if requested (supports global stats + per-guest interactions)
|
||||
if (filter) {
|
||||
const filterTokens = new Set(
|
||||
@@ -486,10 +497,37 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
});
|
||||
};
|
||||
|
||||
// Whose feedback counts as "mine" for these filters.
|
||||
//
|
||||
// Resolved from the REQUEST, the same either/or the per-viewer is_liked
|
||||
// query below uses — never from the `guest_id` query parameter. Two
|
||||
// reasons, and both matter now that this is the only half left when
|
||||
// feedback is hidden:
|
||||
//
|
||||
// - It never matched. The frontend's `gallery_guest_id` is a
|
||||
// localStorage string it invents (`guest_<ts>_<rand>`) and never
|
||||
// sends when submitting feedback; submissions store
|
||||
// generateGuestIdentifier(req). So this lookup found nothing, and
|
||||
// the filters only ever worked through the aggregate half — which
|
||||
// is exactly the half now gated.
|
||||
// - It is caller-controlled. Accepting an identifier from the query
|
||||
// string would let anyone holding someone else's read their hidden
|
||||
// memberships one token at a time, straight back through the gate.
|
||||
//
|
||||
// Hidden rows are excluded, matching what the viewer can actually SEE:
|
||||
// getPhotoFeedback drops is_hidden for the guest's own feedback too.
|
||||
// Unapproved rows are NOT excluded — a comment still in the moderation
|
||||
// queue is still the viewer's own, and that same read keeps it.
|
||||
let guestFeedbackByType = null;
|
||||
if (guest_id) {
|
||||
const guestFeedbackRows = await db('photo_feedback')
|
||||
.where({ event_id: req.event.id, guest_identifier: guest_id })
|
||||
{
|
||||
const viewerFeedback = db('photo_feedback')
|
||||
.where({ event_id: req.event.id, is_hidden: false });
|
||||
if (req.guest?.id) {
|
||||
viewerFeedback.where('guest_id', req.guest.id);
|
||||
} else {
|
||||
viewerFeedback.where('guest_identifier', generateGuestIdentifier(req));
|
||||
}
|
||||
const guestFeedbackRows = await viewerFeedback
|
||||
.select('photo_id', 'feedback_type');
|
||||
|
||||
guestFeedbackByType = guestFeedbackRows.reduce((acc, row) => {
|
||||
@@ -508,39 +546,50 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
}
|
||||
};
|
||||
|
||||
// Every token below is an OR of two halves: what THIS viewer marked,
|
||||
// and what ANYONE marked. The second half is other people's feedback,
|
||||
// so it is gated on show_feedback_to_guests exactly like the counts
|
||||
// this endpoint returns.
|
||||
//
|
||||
// Without the gate the setting only hides the numbers. A guest could
|
||||
// still send `?filter=liked` and get back precisely the set of photos
|
||||
// other people liked — the membership, one token at a time, which is
|
||||
// most of what the counts would have told them. The viewer's own half
|
||||
// is always theirs to filter by.
|
||||
const includeAggregate = (predicate) => {
|
||||
if (showFeedbackToGuests) includeBy(predicate);
|
||||
};
|
||||
|
||||
if (filterTokens.has('liked')) {
|
||||
includeGuestMatches('like');
|
||||
includeBy(photo => (photo.like_count || 0) > 0);
|
||||
includeAggregate(photo => (photo.like_count || 0) > 0);
|
||||
}
|
||||
|
||||
if (filterTokens.has('favorited')) {
|
||||
includeGuestMatches('favorite');
|
||||
includeBy(photo => (photo.favorite_count || 0) > 0);
|
||||
includeAggregate(photo => (photo.favorite_count || 0) > 0);
|
||||
}
|
||||
|
||||
if (filterTokens.has('rated')) {
|
||||
includeGuestMatches('rating');
|
||||
includeBy(photo => (photo.average_rating || 0) > 0);
|
||||
includeAggregate(photo => (photo.average_rating || 0) > 0);
|
||||
}
|
||||
|
||||
if (filterTokens.has('commented')) {
|
||||
includeGuestMatches('comment');
|
||||
const commentedRows = await db('photo_feedback')
|
||||
.where({ event_id: req.event.id, feedback_type: 'comment', is_approved: true, is_hidden: false })
|
||||
.groupBy('photo_id')
|
||||
.select('photo_id');
|
||||
commentedRows.forEach(row => include.add(row.photo_id));
|
||||
if (showFeedbackToGuests) {
|
||||
const commentedRows = await db('photo_feedback')
|
||||
.where({ event_id: req.event.id, feedback_type: 'comment', is_approved: true, is_hidden: false })
|
||||
.groupBy('photo_id')
|
||||
.select('photo_id');
|
||||
commentedRows.forEach(row => include.add(row.photo_id));
|
||||
}
|
||||
}
|
||||
|
||||
photos = photos.filter(photo => include.has(photo.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if feedback should be visible to guests
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const feedbackSettings = await feedbackService.getEventFeedbackSettings(req.event.id);
|
||||
const showFeedbackToGuests = isClient || parseBooleanInput(feedbackSettings.show_feedback_to_guests, true);
|
||||
|
||||
// Then get comment counts separately
|
||||
const commentCounts = await db('photo_feedback')
|
||||
.whereIn('photo_id', photos.map(p => p.id))
|
||||
@@ -568,7 +617,12 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
const likedPhotoIds = new Set();
|
||||
if (showFeedbackToGuests && photos.length > 0) {
|
||||
const likeQuery = db('photo_feedback')
|
||||
.where({ event_id: req.event.id, feedback_type: 'like' })
|
||||
// Hidden rows are not there, for the viewer's OWN feedback as much as
|
||||
// anyone's (#1150). getPhotoFeedback drops them and
|
||||
// updatePhotoFeedbackStats does not count them — leaving the heart
|
||||
// filled was the one place that disagreed, so a like the photographer
|
||||
// had hidden still showed as liked on a photo whose like_count was 0.
|
||||
.where({ event_id: req.event.id, feedback_type: 'like', is_hidden: false })
|
||||
.whereIn('photo_id', photos.map(p => p.id));
|
||||
if (req.guest?.id) {
|
||||
likeQuery.where('guest_id', req.guest.id);
|
||||
@@ -1044,7 +1098,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 +1559,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 +1571,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 +1605,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 +1654,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 +1749,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 +1835,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 +1931,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:', {
|
||||
|
||||
@@ -367,7 +367,14 @@ router.get('/:slug/my-feedback',
|
||||
|
||||
const query = db('photo_feedback')
|
||||
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||
.where('photo_feedback.event_id', event.id);
|
||||
.where('photo_feedback.event_id', event.id)
|
||||
// Hidden rows are absent for the guest who left them too (#1150). In
|
||||
// guest identity mode GalleryView builds its Liked/Favorited/Rated
|
||||
// chips and their filters from THIS array rather than from is_liked,
|
||||
// so without this a hidden like left an empty heart while the Liked
|
||||
// chip still counted it and still surfaced the photo. Unapproved rows
|
||||
// stay: a comment in the moderation queue is still the guest's own.
|
||||
.where('photo_feedback.is_hidden', false);
|
||||
|
||||
// Prefer guest_id lookup when a verified guest token is present
|
||||
// (per-person identity). Fall back to the device hash otherwise.
|
||||
|
||||
@@ -124,7 +124,12 @@ class FeedbackService {
|
||||
*/
|
||||
async countGuestFeedback(eventId, feedbackType, guestId, guestIdentifier) {
|
||||
const query = db('photo_feedback')
|
||||
.where({ event_id: eventId, feedback_type: feedbackType });
|
||||
// Hidden rows do not count against the guest's cap (#1150). They are
|
||||
// absent everywhere else — the heart is empty, the tallies skip them,
|
||||
// and submitFeedback now treats one as room for a fresh row. Counting
|
||||
// them here would meet that fresh row with limit_reached and leave the
|
||||
// control dead until the guest un-likes something they can still see.
|
||||
.where({ event_id: eventId, feedback_type: feedbackType, is_hidden: false });
|
||||
if (guestId) {
|
||||
query.where('guest_id', guestId);
|
||||
} else {
|
||||
@@ -152,6 +157,13 @@ class FeedbackService {
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
feedback_type,
|
||||
// A hidden row is not there (#1150). Without this the guest saw an
|
||||
// empty heart — every read surface treats hidden as absent — and
|
||||
// clicking it found the hidden row and TOGGLED IT OFF, so the
|
||||
// click appeared to do nothing and it took two more to get back to
|
||||
// a filled heart. Skipping it makes the click create a fresh,
|
||||
// visible row, which is what the guest is asking for.
|
||||
is_hidden: false,
|
||||
});
|
||||
if (guest_id) {
|
||||
duplicateQuery.where('guest_id', guest_id);
|
||||
@@ -297,6 +309,11 @@ class FeedbackService {
|
||||
|
||||
const totalStats = await db('photo_feedback')
|
||||
.where('event_id', eventId)
|
||||
// Hidden rows do not count, the same rule the photo counters above
|
||||
// already apply — without this the two halves of THIS response
|
||||
// disagreed, and a hidden row preserved beside its replacement (#1150)
|
||||
// is counted twice.
|
||||
.where('is_hidden', false)
|
||||
.select(
|
||||
db.raw('COUNT(DISTINCT CASE WHEN feedback_type = ? THEN guest_identifier END) as unique_raters', ['rating']),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_ratings', ['rating']),
|
||||
@@ -379,7 +396,33 @@ class FeedbackService {
|
||||
await db('photo_feedback')
|
||||
.where('id', feedbackId)
|
||||
.update(updates);
|
||||
|
||||
|
||||
// Unhiding can collide with a replacement (#1150). A hidden row reads as
|
||||
// absent, so the guest may well have re-added the same feedback in the
|
||||
// meantime; making the original visible again would leave TWO visible
|
||||
// rows for one guest on one photo — double-counted in the tallies, and
|
||||
// needing two toggles to clear because each one deletes a single row.
|
||||
//
|
||||
// Needs a stable identity to scope by. With neither id nor identifier
|
||||
// the fallback degrades to `guest_identifier IS NULL`, which is every
|
||||
// identifier-less row on the photo — other people's, deleted. Nothing to
|
||||
// converge in that case, so leave it alone. Comments are exempt: several
|
||||
// from one guest on one photo is normal.
|
||||
const collapseIdentity = feedback.guest_id || feedback.guest_identifier;
|
||||
if (updates.is_hidden === false && feedback.feedback_type !== 'comment' && collapseIdentity) {
|
||||
const superseded = db('photo_feedback')
|
||||
.where({
|
||||
photo_id: feedback.photo_id,
|
||||
event_id: feedback.event_id,
|
||||
feedback_type: feedback.feedback_type,
|
||||
is_hidden: false,
|
||||
})
|
||||
.whereNot('id', feedbackId);
|
||||
if (feedback.guest_id) superseded.where('guest_id', feedback.guest_id);
|
||||
else superseded.where('guest_identifier', feedback.guest_identifier);
|
||||
await superseded.delete();
|
||||
}
|
||||
|
||||
// Update photo stats if visibility changed
|
||||
await this.updatePhotoFeedbackStats(feedback.photo_id);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.46.1",
|
||||
"version": "3.46.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -33,6 +33,20 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface GalleryViewProps {
|
||||
slug: string;
|
||||
/**
|
||||
* Whether this gallery is password-protected (#1149).
|
||||
*
|
||||
* Drives the Logout button. Logging out of a gallery that asks for nothing
|
||||
* is meaningless — there is no credential to drop and nothing to return to
|
||||
* — and it used to strand the visitor: GalleryPage's auto-login is a
|
||||
* one-shot latch, so clearing the session left the page rendering its
|
||||
* skeleton until a manual reload.
|
||||
*
|
||||
* A client (PIN) session still gets the button on a public gallery: that
|
||||
* one IS a credential, and it is the only way back to the guest view. So is
|
||||
* a customer-portal session.
|
||||
*/
|
||||
requiresPassword?: boolean;
|
||||
event: {
|
||||
id: number;
|
||||
event_name: string;
|
||||
@@ -67,9 +81,9 @@ const parseDefaultPhotoSort = (defaultSort?: string): { sortBy: 'date' | 'name'
|
||||
}
|
||||
};
|
||||
|
||||
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresPassword = true }) => {
|
||||
const { t } = useTranslation();
|
||||
const { logout, isClient } = useGalleryAuth();
|
||||
const { logout, isClient, viaCustomer } = useGalleryAuth();
|
||||
const { setTheme, theme } = useTheme();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
||||
@@ -705,6 +719,18 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
// Skip all wrapper elements (header, footer, sidebar, filters) for these layouts
|
||||
const isFullPageLayout = theme.galleryLayout === 'gallery-premium' || theme.galleryLayout === 'gallery-story';
|
||||
|
||||
// Does this session hold something worth dropping? A password gallery and a
|
||||
// PIN client obviously do, and so does a customer-portal session — its token
|
||||
// opens the gallery without the password and lives for 24h in a cookie the
|
||||
// customer logout does not clear.
|
||||
//
|
||||
// Read from the auth context, which resolves it from /auth/session on mount.
|
||||
// accessLevel used to come from sessionStorage alone, which is per-TAB while
|
||||
// the cookie is per-browser: a gallery reopened in a second tab lost
|
||||
// 'client' while the backend went on serving it as one, and the gate would
|
||||
// then hide the only control that clears the privileged cookie (#1149).
|
||||
const showLogoutControl = requiresPassword || isClient || viaCustomer;
|
||||
|
||||
// For full-page layouts, render just the PhotoGridWithLayouts without any wrappers
|
||||
if (isFullPageLayout) {
|
||||
return (
|
||||
@@ -745,7 +771,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
|
||||
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
|
||||
welcomeMessage={event.welcome_message}
|
||||
onLogout={logout}
|
||||
// Same gate as the standard layout below (#1149). These layouts
|
||||
// render the button on the callback being present rather than on a
|
||||
// showLogout flag, so withholding it is how the gate reaches them.
|
||||
onLogout={showLogoutControl ? logout : undefined}
|
||||
showOriginalFilename={showOriginalFilename}
|
||||
/>
|
||||
|
||||
@@ -823,7 +852,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
heroLogoVisible={data?.event?.hero_logo_visible !== false}
|
||||
heroLogoSize={data?.event?.hero_logo_size || undefined}
|
||||
headerStyle={data?.event?.header_style || theme.headerStyle}
|
||||
showLogout={true}
|
||||
showLogout={showLogoutControl}
|
||||
onLogout={logout}
|
||||
// Old Download All header button is replaced by the new
|
||||
// showHeaderDownload below — accent-coloured, always visible when
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -39,6 +39,8 @@ interface GalleryAuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
event: GalleryEvent | null;
|
||||
accessLevel: GalleryAccessLevel;
|
||||
/** Session was minted by the customer portal — credentialed, not a plain guest. */
|
||||
viaCustomer: boolean;
|
||||
isClient: boolean;
|
||||
login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise<void>;
|
||||
clientLogin: (slug: string, password: string) => Promise<void>;
|
||||
@@ -65,6 +67,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [event, setEvent] = useState<GalleryEvent | null>(null);
|
||||
const [accessLevel, setAccessLevel] = useState<GalleryAccessLevel>('guest');
|
||||
const [viaCustomer, setViaCustomer] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [routeError, setRouteError] = useState<string | null>(null);
|
||||
@@ -204,13 +207,25 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
const initialise = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>(
|
||||
const sessionResponse = await api.get<{
|
||||
valid: boolean; type: string; eventSlug?: string;
|
||||
accessLevel?: GalleryAccessLevel; viaCustomer?: boolean;
|
||||
}>(
|
||||
'/auth/session',
|
||||
{ params: { slug: currentSlug } }
|
||||
);
|
||||
|
||||
if (sessionResponse.data?.valid && sessionResponse.data.type === 'gallery' && sessionResponse.data.eventSlug === currentSlug) {
|
||||
setIsAuthenticated(true);
|
||||
// The SERVER's view of this session, not the per-tab sessionStorage
|
||||
// guess above (#1149). A second tab has no sessionStorage but the
|
||||
// same cookie, so the stored value silently downgraded a client
|
||||
// session to 'guest' while the backend kept serving it as a client.
|
||||
if (sessionResponse.data.accessLevel === 'client') {
|
||||
setAccessLevel('client');
|
||||
sessionStorage.setItem(`gallery_access_level_${currentSlug}`, 'client');
|
||||
}
|
||||
setViaCustomer(Boolean(sessionResponse.data.viaCustomer));
|
||||
|
||||
// Always refresh from the server — the stored event from sessionStorage
|
||||
// is shown above as an instant placeholder for perceived perf, but it
|
||||
@@ -340,6 +355,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
setIsAuthenticated(false);
|
||||
setEvent(null);
|
||||
setAccessLevel('guest');
|
||||
setViaCustomer(false);
|
||||
clearActiveGallerySlug();
|
||||
};
|
||||
|
||||
@@ -350,6 +366,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
event,
|
||||
accessLevel,
|
||||
isClient: accessLevel === 'client',
|
||||
viaCustomer,
|
||||
login,
|
||||
clientLogin: clientLoginFn,
|
||||
logout,
|
||||
|
||||
@@ -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 667–956 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,14 +345,51 @@ export const GalleryPage: React.FC = () => {
|
||||
|
||||
// Show gallery view if authenticated
|
||||
if (isAuthenticated && event) {
|
||||
return <GalleryView slug={gallerySlugForView} event={event} />;
|
||||
return <GalleryView slug={gallerySlugForView} event={event} requiresPassword={requiresPassword} />;
|
||||
}
|
||||
|
||||
// Public gallery: auto-login is in flight (or about to fire). Show the
|
||||
// skeleton instead of the "publicly accessible — loading photos" card so
|
||||
// visitors see one continuous skeleton until real photos appear (#321).
|
||||
if (!requiresPassword) {
|
||||
return <GallerySkeleton />;
|
||||
if (!autoLoginAttempted || isLoggingIn) {
|
||||
return <GallerySkeleton />;
|
||||
}
|
||||
|
||||
// Auto-login has run and we are still not authenticated (#1149).
|
||||
//
|
||||
// Returning the skeleton here meant it never stopped: the effect above is
|
||||
// latched on autoLoginAttempted and will not fire again, so the visitor
|
||||
// sat on a loading gallery until they reloaded by hand. It also swallowed
|
||||
// loginError completely — a public gallery that failed to open showed no
|
||||
// reason, because this branch returns before the form that renders it.
|
||||
//
|
||||
// Reachable two ways: a failed or expired auto-login, and clearing the
|
||||
// session from inside the gallery (the Logout button that should not have
|
||||
// been there, or GalleryView's 401 handler). Retry re-arms the latch; it
|
||||
// is a button rather than an automatic re-fire so a genuinely failing
|
||||
// gallery cannot spin.
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4"
|
||||
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="p-6 text-center">
|
||||
<AlertCircle className="w-10 h-10 mx-auto mb-3 text-muted-theme" />
|
||||
<p className="text-base mb-4">
|
||||
{loginError || t('gallery.failedToLoad', 'Failed to load gallery')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setLoginError(null);
|
||||
setAutoLoginAttempted(false);
|
||||
}}
|
||||
>
|
||||
{t('gallery.tryAgain', 'Try again')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show login form
|
||||
|
||||
Reference in New Issue
Block a user