Compare commits

...

5 Commits

Author SHA1 Message Date
Paul Nothaft f83d144f28 chore(stable): release 3.46.4 (#1159)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
2026-08-24 20:34:25 +02:00
Paul Nothaft b62cd2c290 fix(gallery): a guest's own hidden feedback is hidden from them too (#1150) (#1157)
Stable twin of #1153.

Everything in the system treats a hidden row as absent, but the per-viewer is_liked heart read the row without looking at is_hidden — so a like the photographer had hidden still showed as liked on a photo whose like_count was zero.

Making that agree exposes the second half: the duplicate check behind like/favorite toggling did not skip hidden rows either, so the now-empty heart, when clicked, found the hidden row and toggled it OFF. Skipping hidden rows there makes the click create a fresh, visible row.

Also carried from review: the per-guest caps, /my-feedback and getEventFeedbackSummary no longer count hidden rows, and unhiding collapses the guest's replacement — skipped when there is no stable identity, since that fallback was 'guest_identifier IS NULL', i.e. other visitors' rows.

Not carried: the my_color_label badge (colour labels are #1044) and the clearScope / singleValueScope visibility fix, neither of which exists on this branch.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:09:41 +02:00
Paul Nothaft eaa8b41ba3 fix(gallery): guest filters respect show_feedback_to_guests (#1044) (#1156)
Stable twin of #1147, filter half only.

Every filter token on /photos is an OR of two halves: what THIS viewer marked, and what ANYONE marked. The fields built from the second half are gated on show_feedback_to_guests; the filter was not, so with the setting off ?filter=liked still returned exactly the photos other people liked — the membership instead of the count, one token at a time.

The half it left standing was also the wrong half: it read guest_identifier from the guest_id query parameter, which never matched anything, and accepting a caller-supplied identifier was a way back through the gate. Resolved from the request now, hidden rows excluded to match what the viewer can see.

Not carried: the color: token and the photo_admin_marks concurrent-write fix — colour labels and admin marks are not on this branch.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:08:23 +02:00
Paul Nothaft d46397d92a fix(gallery): no Logout button on galleries that don't require a password (#1149) (#1154)
Stable twin of #1152. The reporter is on v3.46.1, so this branch is where the bug was actually seen.

showLogout was hard-coded true, so a gallery with no password showed a Logout button, and clicking it stranded the visitor on the loading skeleton — GalleryPage's auto-login is a one-shot latch that never re-fires.

The button is gated at both call sites, including the full-page layouts which render it on the callback rather than a flag. accessLevel and viaCustomer now come from /auth/session instead of per-tab sessionStorage, which silently downgraded a PIN-client session in a second tab. The public-gallery branch shows a reason and a Retry once auto-login has run and failed, instead of a skeleton that never stops.

Carries the full main fix including viaCustomer, even though reveal mode does not exist here, so the branches do not drift.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:07:05 +02:00
Paul Nothaft e46260ad07 fix(scripts): regenerate-thumbnails resolves external sources through ensureThumbnail (#1148) (#1155)
Stable twin of #1151.

The script here carried the identical defect: it computed `storage/events/active/<photo.path>` and fs.access'd it, which does not exist for external or reference rows. #1129 already landed on this branch, so the route was fixed and the script was the remaining half.

Resolution goes through ensureThumbnail, which stable already exports with the external branch intact. Also carried: videos skipped on every marker, skip-vs-generate asked from isThumbnailValid, and a nonzero exit when a photo could not be built.

Not carried: the responsive-tier backfill — THUMBNAIL_WIDTHS and ensureThumbnailAtWidth are #1095/#1109 and do not exist on this branch.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:06:30 +02:00
16 changed files with 1146 additions and 163 deletions
+1 -1
View File
@@ -1 +1 @@
{".":"3.46.3"}
{".":"3.46.4"}
+10
View File
@@ -5,6 +5,16 @@ 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)
@@ -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);
});
@@ -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,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.46.3",
"version": "3.46.4",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"engines": {
+135 -129
View File
@@ -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 };
+12 -1
View File
@@ -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({
+71 -18
View File
@@ -413,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();
@@ -458,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(
@@ -487,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) => {
@@ -509,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))
@@ -569,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);
+8 -1
View File
@@ -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.
+45 -2
View File
@@ -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);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.46.3",
"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
+18 -1
View File
@@ -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,
+39 -2
View File
@@ -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