Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f99357460f | |||
| 90b589a88e | |||
| 1ad8ad5b68 | |||
| 962f1d9586 | |||
| a7885846ac | |||
| d868aac703 |
@@ -1 +1 @@
|
||||
{".":"3.45.6"}
|
||||
{".":"3.45.9"}
|
||||
|
||||
@@ -5,6 +5,27 @@ 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.45.9](https://github.com/PicPeak/picpeak/compare/v3.45.8...v3.45.9) (2026-07-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **analytics:** make per-photo view/download counters actually count ([#895](https://github.com/PicPeak/picpeak/issues/895)) (stable) ([#905](https://github.com/PicPeak/picpeak/issues/905)) ([90b589a](https://github.com/PicPeak/picpeak/commit/90b589a88e4c56ccac6dba86c48a604f2abcc008))
|
||||
|
||||
## [3.45.8](https://github.com/PicPeak/picpeak/compare/v3.45.7...v3.45.8) (2026-07-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tests:** raise jest timeouts to the 120s convention (stable) ([#902](https://github.com/PicPeak/picpeak/issues/902)) ([962f1d9](https://github.com/PicPeak/picpeak/commit/962f1d95868251ddeb01a5234c8d9bd1d57429e8))
|
||||
|
||||
## [3.45.7](https://github.com/PicPeak/picpeak/compare/v3.45.6...v3.45.7) (2026-07-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** close 5 Trivy alerts — postcss/tar bumps + drop npm from the runtime image (stable) ([#879](https://github.com/PicPeak/picpeak/issues/879)) ([d868aac](https://github.com/PicPeak/picpeak/commit/d868aac70300149e77fb9568735b6481f88a644f))
|
||||
|
||||
## [3.45.6](https://github.com/PicPeak/picpeak/compare/v3.45.5...v3.45.6) (2026-07-27)
|
||||
|
||||
|
||||
|
||||
+10
-8
@@ -37,14 +37,16 @@ ARG CACHEBUST=1
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
|
||||
|
||||
# Upgrade the npm CLI in the final image so its bundled deps are patched
|
||||
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
|
||||
# Trivy alerts. Safe here: only the CLI present in the image changes. Runtime
|
||||
# dependencies come from the builder stage (COPY --from=builder node_modules
|
||||
# below) and the entrypoint runs node, not npm — so npm 11's install behaviour
|
||||
# (the reason 10.x was pinned) never executes in this stage. npm 11 needs
|
||||
# Node >=22.9, satisfied by node:22-alpine.
|
||||
RUN npm install -g npm@11
|
||||
# Remove the npm CLI from the final image. Nothing runs npm here: the
|
||||
# entrypoint is node, runtime deps are COPY'd from the builder stage, and
|
||||
# wait-for-db.sh invokes the migration runners via node directly. npm's
|
||||
# bundled node_modules kept tripping Trivy (sigstore, tar 7.5.19,
|
||||
# brace-expansion 5.0.7 — even npm 12.0.1 still ships the vulnerable
|
||||
# copies), so shipping no npm ends that alert class instead of chasing
|
||||
# per-release patches. Note: `docker exec … npm run <script>` no longer
|
||||
# works in the container — use `node migrations/run-migrations-safe.js`
|
||||
# and friends instead.
|
||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
# Install dumb-init for proper signal handling, postgresql-client for database
|
||||
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
|
||||
|
||||
@@ -40,7 +40,7 @@ jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('GET /api/admin/system-health/backup-coverage', () => {
|
||||
let db;
|
||||
|
||||
@@ -29,7 +29,7 @@ jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('GET /api/admin/system-health/backup-integrity', () => {
|
||||
let cleanup;
|
||||
|
||||
@@ -23,7 +23,7 @@ const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupService — configurable walker (backup_paths)', () => {
|
||||
let db;
|
||||
|
||||
@@ -34,7 +34,7 @@ jest.mock('../../src/services/databaseBackup', () => ({
|
||||
DatabaseBackupService: class {},
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupService — inline DB dump + fail-loud guard', () => {
|
||||
let db;
|
||||
|
||||
@@ -23,7 +23,7 @@ const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupService — per-Stage-B-path statistics', () => {
|
||||
let db;
|
||||
|
||||
@@ -14,7 +14,7 @@ const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupService — config + file collection + manifest (smoke)', () => {
|
||||
let db;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
const crypto = require('crypto');
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('booking cutover — draft invoices on hold', () => {
|
||||
let db; let cleanup; let adminId; let customerId; let quoteService;
|
||||
|
||||
@@ -14,7 +14,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
// Service-level CRM calls cold-require heavy modules (pdfService,
|
||||
// nodemailer, etc.) on first use; the global 5 s per-test budget is
|
||||
// too tight for that. Bump it for this file only.
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('discount line items (negative unit_price_minor)', () => {
|
||||
let db;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// bootCrmDb runs the full core-migration set in beforeAll.
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('event type slug rename cascade', () => {
|
||||
let db;
|
||||
|
||||
@@ -17,7 +17,7 @@ const request = require('supertest');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
let db; let cleanup; let service; let app;
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
let db; let cleanup; let service; let adminId;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// Service-level CRM calls cold-require heavy modules (pdfService, nodemailer)
|
||||
// on first use; bump the budget for this file.
|
||||
jest.setTimeout(60000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('incoming-invoice categorise / re-bill chain', () => {
|
||||
let db;
|
||||
|
||||
@@ -32,7 +32,7 @@ jest.mock('../../src/services/restoreService', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('installFromBackupBoot', () => {
|
||||
let db;
|
||||
|
||||
@@ -13,7 +13,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
|
||||
// parallel load on a small CI runner that can exceed the 5s default. Match the
|
||||
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
@@ -21,7 +21,7 @@ beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir
|
||||
({ createPicpeak } = require('../../src/services/picpeakExportService'));
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
@@ -28,7 +28,7 @@ beforeAll(async () => {
|
||||
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
superAdminRoleId = role.id;
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
@@ -13,14 +13,14 @@ const { execFileSync } = require('child_process');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -183,22 +183,24 @@ describe('restoreService — PG branch scope contract (PR #596 review)', () => {
|
||||
expect(window).toMatch(/was_successful:\s*true/);
|
||||
});
|
||||
|
||||
it('npm run migrate:safe is invoked after the replay in restore()', () => {
|
||||
it('the safe migration runner is invoked after the replay in restore()', () => {
|
||||
// Contract from PR #596 round 4: backups taken on older picpeak
|
||||
// versions must restore COMPLETELY on a newer image — even if new
|
||||
// migrations have been added since the backup was taken. The
|
||||
// restore() flow shells out to `npm run migrate:safe` AFTER the
|
||||
// restore() flow shells out to the safe migration runner AFTER the
|
||||
// operator-meta replay so the schema catches up to the running
|
||||
// code WITHIN the restore boundary (not on the next container
|
||||
// restart).
|
||||
// restart). Invoked as `node migrations/run-migrations-safe.js` —
|
||||
// the runtime image ships no npm, so the former `npm run
|
||||
// migrate:safe` would ENOENT into the non-fatal catch.
|
||||
//
|
||||
// Contract:
|
||||
// 1. A `migrate:safe` shell-out exists somewhere in restoreService
|
||||
// 1. A run-migrations-safe shell-out exists somewhere in restoreService
|
||||
// 2. It sits AFTER the replay drain — verification → replay →
|
||||
// migrations is the documented order
|
||||
// 3. It does NOT sit inside performDatabaseRestore (must run
|
||||
// against the reinit'd pool from the parent restore())
|
||||
const migrateLine = findFirst(/['"]migrate:safe['"]/);
|
||||
const migrateLine = findFirst(/run-migrations-safe\.js/);
|
||||
expect(migrateLine).toBeGreaterThan(0);
|
||||
|
||||
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
|
||||
|
||||
@@ -27,7 +27,7 @@ beforeAll(async () => {
|
||||
setupService = require('../../src/services/setupService');
|
||||
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
|
||||
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
@@ -10,7 +10,7 @@ const { bootCrmDb } = require('./helpers/crmDb');
|
||||
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
|
||||
// parallel load on a small CI runner that can exceed the 5s default. Match the
|
||||
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
@@ -9,7 +9,7 @@ const {
|
||||
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
|
||||
// parallel load on a small CI runner that can exceed the 5s default. Match the
|
||||
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('admin CRM routes — auth + permission gate', () => {
|
||||
// Invalid: signed with a different secret. adminAuth must reject.
|
||||
const jwt = require('jsonwebtoken');
|
||||
invalidToken = jwt.sign({ id: adminId, type: 'admin' }, 'WRONG-SECRET', { issuer: 'picpeak-auth' });
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -39,7 +39,7 @@ const {
|
||||
bootCrmDb, mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
@@ -95,7 +95,7 @@ beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth'));
|
||||
authApp = buildRouteApp('/api/auth', require('../../src/routes/auth'));
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Per-photo engagement counters (#895).
|
||||
*
|
||||
* Pins the contract that the admin EVENT > IMAGES table depends on:
|
||||
* - photos.view_count increments when the full-size photo is served
|
||||
* (it existed in the schema + admin UI but had NO writer at all)
|
||||
* - the slideshow kiosk never increments views (migration 138 design)
|
||||
* - single-photo downloads increment download_count (regression pin)
|
||||
* - zip downloads (download-all, download-selected) increment
|
||||
* download_count for the contained photos — previously they didn't,
|
||||
* so zip-heavy galleries showed 0 per-photo downloads forever
|
||||
* - the admin event-detail total_downloads counts singles AND zips
|
||||
* (it counted action='download' only, disagreeing with the dashboard)
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-engagement-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'engagement-test-secret';
|
||||
// Real files on disk so /photo and the zip routes actually stream bytes.
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-engagement-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
const SLUG = 'engagement-test-event';
|
||||
|
||||
describe('photo engagement counters (#895)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let eventId;
|
||||
let photoIds;
|
||||
let adminToken;
|
||||
|
||||
const galleryToken = (extra = {}) => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery', ...extra },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const getPhoto = async (id) => db('photos').where('id', id).first();
|
||||
// The counter writes are fire-and-forget on purpose — give the event
|
||||
// loop a beat before asserting.
|
||||
const settle = () => new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const inserted = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Engagement Test',
|
||||
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: 'engagement-test-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
allow_downloads: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
const photoDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
|
||||
fs.mkdirSync(photoDir, { recursive: true });
|
||||
|
||||
photoIds = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const filename = `photo-${i}.jpg`;
|
||||
fs.writeFileSync(path.join(photoDir, filename), Buffer.from(`fake-jpeg-bytes-${i}`));
|
||||
const p = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename,
|
||||
path: `${SLUG}/${filename}`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
photoIds.push(p[0]?.id ?? p[0]);
|
||||
}
|
||||
|
||||
const superRole = await db('roles').where({ name: 'super_admin' }).first();
|
||||
const [rootId] = await db('admin_users').insert({
|
||||
username: 'engagement-admin',
|
||||
email: 'engagement-admin@example.com',
|
||||
password_hash: await bcrypt.hash('EngagementAdmin123', 4),
|
||||
role_id: superRole.id,
|
||||
is_active: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id').then((r) => [r[0]?.id || r[0]]);
|
||||
adminToken = jwt.sign(
|
||||
{ id: rootId, username: 'engagement-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('photos').where('event_id', eventId).update({ view_count: 0, download_count: 0 });
|
||||
await db('access_logs').where('event_id', eventId).del();
|
||||
});
|
||||
|
||||
describe('view_count via the view beacon (#895 — previously never written)', () => {
|
||||
const beacon = (photoId, token = galleryToken()) => request(app)
|
||||
.post(`/api/gallery/${SLUG}/photo/${photoId}/view`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
it('increments exactly the beaconed photo', async () => {
|
||||
expect((await beacon(photoIds[0])).status).toBe(204);
|
||||
expect((await getPhoto(photoIds[0])).view_count).toBe(1);
|
||||
|
||||
expect((await beacon(photoIds[0])).status).toBe(204);
|
||||
expect((await getPhoto(photoIds[0])).view_count).toBe(2);
|
||||
// Other photos untouched
|
||||
expect((await getPhoto(photoIds[1])).view_count).toBe(0);
|
||||
});
|
||||
|
||||
it('serving the image bytes does NOT count (preloads must not inflate)', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photo/${photoIds[0]}`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
await settle();
|
||||
expect((await getPhoto(photoIds[0])).view_count).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects the slideshow kiosk (migration 138 design)', async () => {
|
||||
const res = await beacon(photoIds[0], galleryToken({ accessLevel: 'slideshow' }));
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect((await getPhoto(photoIds[0])).view_count).toBe(0);
|
||||
});
|
||||
|
||||
it("404s a photo that isn't in the event", async () => {
|
||||
const res = await beacon(999999);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('download_count', () => {
|
||||
it('single-photo download increments (regression pin)', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/download/${photoIds[0]}`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
await settle();
|
||||
expect((await getPhoto(photoIds[0])).download_count).toBe(1);
|
||||
expect((await getPhoto(photoIds[1])).download_count).toBe(0);
|
||||
});
|
||||
|
||||
it('download-selected increments exactly the selected photos (#895)', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/gallery/${SLUG}/download-selected`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.send({ photo_ids: [photoIds[0], photoIds[1]] });
|
||||
expect(res.status).toBe(200);
|
||||
await settle();
|
||||
expect((await getPhoto(photoIds[0])).download_count).toBe(1);
|
||||
expect((await getPhoto(photoIds[1])).download_count).toBe(1);
|
||||
expect((await getPhoto(photoIds[2])).download_count).toBe(0);
|
||||
});
|
||||
|
||||
it('download-all increments every downloadable photo (#895)', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/download-all`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
await settle();
|
||||
for (const id of photoIds) {
|
||||
expect((await getPhoto(id)).download_count).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('skipped archive entries do not count (missing source file)', async () => {
|
||||
// Own event so the on-the-fly archiver path is guaranteed — the
|
||||
// main event may have a cached zip from the previous test's
|
||||
// background generation, and racing its build/invalidate hangs.
|
||||
const slug2 = `${SLUG}-skip`;
|
||||
const ev = await db('events').insert({
|
||||
slug: slug2,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Engagement Skip Test',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${slug2}/share`,
|
||||
share_token: 'engagement-skip-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
allow_downloads: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const eventId2 = ev[0]?.id ?? ev[0];
|
||||
const dir2 = path.join(process.env.STORAGE_PATH, 'events/active', slug2);
|
||||
fs.mkdirSync(dir2, { recursive: true });
|
||||
const ids2 = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
// Only photo 0 gets a real file — photo 1's source is missing.
|
||||
if (i === 0) fs.writeFileSync(path.join(dir2, `photo-${i}.jpg`), Buffer.from('skip-test-bytes'));
|
||||
const p = await db('photos').insert({
|
||||
event_id: eventId2,
|
||||
filename: `photo-${i}.jpg`,
|
||||
path: `${slug2}/photo-${i}.jpg`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
ids2.push(p[0]?.id ?? p[0]);
|
||||
}
|
||||
const token2 = jwt.sign(
|
||||
{ eventId: eventId2, eventSlug: slug2, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${slug2}/download-all`)
|
||||
.set('Authorization', `Bearer ${token2}`);
|
||||
expect(res.status).toBe(200);
|
||||
await settle();
|
||||
expect((await db('photos').where('id', ids2[0]).first()).download_count).toBe(1);
|
||||
// photo-1's source was missing → skipped from the zip → not counted
|
||||
expect((await db('photos').where('id', ids2[1]).first()).download_count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin event-detail total_downloads (#895 — one definition everywhere)', () => {
|
||||
it('counts singles and every zip variant, one row each', async () => {
|
||||
const row = (action) => ({
|
||||
event_id: eventId,
|
||||
ip_address: '127.0.0.1',
|
||||
user_agent: 'jest',
|
||||
action,
|
||||
});
|
||||
await db('access_logs').insert([
|
||||
row('download'),
|
||||
row('download_all'),
|
||||
row('download_all_presigned'),
|
||||
row('download_selected'),
|
||||
row('view'), // not a download
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/admin/events/${eventId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.total_downloads).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ describe('publicContracts routes', () => {
|
||||
contractId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('publicPaymentCheck routes', () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
app = buildRouteApp('/api/public/payment-check', require('../../src/routes/publicPaymentCheck'));
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('publicQuotes routes', () => {
|
||||
quoteId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
app = buildRouteApp('/api/public/quotes', require('../../src/routes/publicQuotes'));
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('admin Live Slideshow endpoints', () => {
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 30000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
|
||||
@@ -67,11 +67,10 @@ async function insertEvent(db, over = {}) {
|
||||
describe('public Live Slideshow routes', () => {
|
||||
let db; let cleanup; let app;
|
||||
|
||||
// bootCrmDb runs the full migration set against a fresh SQLite file, which
|
||||
// takes <2s locally but has been observed to exceed Jest's default 5s
|
||||
// `beforeAll` timeout on slower GitHub Actions runners (~5.4s — runner-to-
|
||||
// runner I/O variance). Raise the hook timeout so this doesn't intermittently
|
||||
// block PRs on CI; doesn't affect happy-path local runs.
|
||||
// bootCrmDb runs the full migration set against a fresh SQLite file and the
|
||||
// chain keeps growing via backports. Hook-argument timeouts OVERRIDE the
|
||||
// 120s jest.config default (same trap as the jest.setTimeout pins) — keep
|
||||
// this at 120000, matching the config.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
@@ -86,7 +85,7 @@ describe('public Live Slideshow routes', () => {
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 30000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const crypto = require('crypto');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupIntegrityService.verifyDocumentArtefacts', () => {
|
||||
let db;
|
||||
|
||||
@@ -27,7 +27,7 @@ let db; let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 30000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('userManagementService — activate + delete (#574 follow-up)', () => {
|
||||
is_active: 1, created_at: new Date(),
|
||||
}).returning('id');
|
||||
targetId = targetInsert[0]?.id ?? targetInsert[0];
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -115,7 +115,7 @@ beforeAll(async () => {
|
||||
}).returning('id');
|
||||
photoIds.push(r[0]?.id ?? r[0]);
|
||||
}
|
||||
}, 30000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
// bootCrmDb() runs EVERY core migration in beforeAll and the chain keeps
|
||||
// growing (134 migrations and counting via backports). 120s matches the
|
||||
// beta-branch convention from #860.
|
||||
testTimeout: 120000,
|
||||
coverageDirectory: 'coverage',
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.js',
|
||||
|
||||
Generated
+14
-14
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.45.5",
|
||||
"version": "3.45.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.45.5",
|
||||
"version": "3.45.6",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -45,7 +45,7 @@
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pg": "^8.16.3",
|
||||
"postcss": "8.5.10",
|
||||
"postcss": "8.5.18",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
@@ -54,7 +54,7 @@
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"swissqrbill": "^4.3.0",
|
||||
"tar": ">=7.5.16",
|
||||
"tar": ">=7.5.21",
|
||||
"uuid": "^11.1.1",
|
||||
"winston": "^3.8.2",
|
||||
"zxcvbn": "^4.4.2"
|
||||
@@ -9079,9 +9079,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -10036,9 +10036,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"version": "8.5.18",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz",
|
||||
"integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -10055,7 +10055,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -11754,9 +11754,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.19",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz",
|
||||
"integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==",
|
||||
"version": "7.5.22",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
|
||||
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.45.6",
|
||||
"version": "3.45.9",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"engines": {
|
||||
@@ -54,7 +54,7 @@
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pg": "^8.16.3",
|
||||
"postcss": "8.5.10",
|
||||
"postcss": "8.5.18",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
@@ -63,7 +63,7 @@
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"swissqrbill": "^4.3.0",
|
||||
"tar": ">=7.5.16",
|
||||
"tar": ">=7.5.21",
|
||||
"uuid": "^11.1.1",
|
||||
"winston": "^3.8.2",
|
||||
"zxcvbn": "^4.4.2"
|
||||
@@ -83,7 +83,7 @@
|
||||
"js-yaml": "^4.2.0",
|
||||
"fast-xml-parser": ">=5.7.0",
|
||||
"qs": ">=6.15.2",
|
||||
"tar": ">=7.5.16",
|
||||
"tar": ">=7.5.21",
|
||||
"brace-expansion": ">=5.0.7",
|
||||
"minimatch": ">=9.0.7",
|
||||
"path-to-regexp": "0.1.13",
|
||||
|
||||
@@ -69,7 +69,7 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
|
||||
|
||||
// Get total downloads (last 30 days) - include both single and bulk downloads
|
||||
const totalDownloads = await db('access_logs')
|
||||
.whereIn('action', ['download', 'download_all'])
|
||||
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
|
||||
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
|
||||
.count('id as count')
|
||||
.first();
|
||||
@@ -99,7 +99,7 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
|
||||
.first();
|
||||
|
||||
const previousDownloads = await db('access_logs')
|
||||
.whereIn('action', ['download', 'download_all'])
|
||||
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
|
||||
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
|
||||
.where('timestamp', '<', thirtyDaysAgo.toISOString())
|
||||
.count('id as count')
|
||||
@@ -271,7 +271,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
||||
// Get downloads per day - include both single and bulk downloads
|
||||
const downloadsData = await db('access_logs')
|
||||
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
|
||||
.whereIn('action', ['download', 'download_all'])
|
||||
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.groupByRaw('DATE(timestamp)');
|
||||
|
||||
@@ -307,7 +307,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
||||
.select('events.id', 'events.event_name', 'events.slug')
|
||||
.select(db.raw('COUNT(CASE WHEN action = \'view\' THEN 1 END) as views'))
|
||||
.select(db.raw('COUNT(DISTINCT CASE WHEN action = \'view\' THEN ip_address END) as uniqueVisitors'))
|
||||
.select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\') THEN 1 END) as downloads'))
|
||||
.select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\', \'download_all_presigned\', \'download_selected\') THEN 1 END) as downloads'))
|
||||
.join('events', 'access_logs.event_id', 'events.id')
|
||||
.where('access_logs.timestamp', '>=', startDateStr)
|
||||
.groupBy('events.id', 'events.event_name', 'events.slug')
|
||||
@@ -377,7 +377,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
||||
.first();
|
||||
|
||||
const totalDownloadsCount = await db('access_logs')
|
||||
.whereIn('action', ['download', 'download_all'])
|
||||
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
@@ -771,9 +771,11 @@ module.exports = (router) => {
|
||||
.where('action', 'view')
|
||||
.count('* as totalViews');
|
||||
|
||||
// One row per download event: singles AND zips (#895). Must stay in
|
||||
// sync with adminDashboard's definition or the two surfaces disagree.
|
||||
const [{ totalDownloads }] = await db('access_logs')
|
||||
.where('event_id', id)
|
||||
.where('action', 'download')
|
||||
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
|
||||
.count('* as totalDownloads');
|
||||
|
||||
const [{ uniqueVisitors }] = await db('access_logs')
|
||||
|
||||
@@ -933,6 +933,22 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
|
||||
});
|
||||
|
||||
// Download all photos as ZIP
|
||||
// Zip downloads count toward each contained photo's download_count (#895)
|
||||
// — previously only single-photo downloads did, so galleries whose guests
|
||||
// grab the zip showed 0 per-photo downloads forever. Used by the
|
||||
// pre-generated-zip branches only: it mirrors downloadZipService._build,
|
||||
// which zips EVERY event photo with no per-category allow_downloads
|
||||
// filter — the counter has to reflect what actually shipped. (That the
|
||||
// prebuilt zip ignores per-category download opt-outs is a separate,
|
||||
// pre-existing issue.) Known approximation: _build skips entries whose
|
||||
// WATERMARK step fails and still publishes the zip; counting those
|
||||
// would need a persisted archive manifest, which isn't worth it for
|
||||
// that tail case. Fire-and-forget at the call sites: counters must
|
||||
// never fail a download.
|
||||
async function bumpEventDownloadCounts(eventId) {
|
||||
await db('photos').where('event_id', eventId).increment('download_count', 1);
|
||||
}
|
||||
|
||||
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
|
||||
try {
|
||||
// Check if downloads are allowed for this event
|
||||
@@ -962,6 +978,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download_all_presigned'
|
||||
}).catch(() => {});
|
||||
bumpEventDownloadCounts(req.event.id).catch(() => {});
|
||||
res.redirect(302, url);
|
||||
return;
|
||||
} catch (err) {
|
||||
@@ -985,6 +1002,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download_all'
|
||||
}).catch(() => {});
|
||||
bumpEventDownloadCounts(req.event.id).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1044,6 +1062,10 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
// get a deterministic `_1` suffix before the entries hit the archive.
|
||||
const useOriginalBulk = await getUseOriginalFilenames();
|
||||
const bulkEntryNames = getZipEntryNames(photos, useOriginalBulk);
|
||||
// Only photos whose append succeeded count as downloaded (#895) — the
|
||||
// catch below deliberately skips missing/corrupt sources, and those
|
||||
// never make it into the archive.
|
||||
const appendedIds = [];
|
||||
for (let i = 0; i < photos.length; i += 1) {
|
||||
const photo = photos[i];
|
||||
const storageKey = resolvePhotoStorageKey(req.event, photo);
|
||||
@@ -1057,6 +1079,22 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the source exists BEFORE appending — but only for local
|
||||
// sources: fs.createReadStream is lazy, so its error fires outside
|
||||
// this try/catch and the archive 'error' handler then kills the
|
||||
// whole response instead of skipping one photo (#895 review). S3's
|
||||
// get() awaits GetObject and rejects right here on a missing key,
|
||||
// so a preflight HEAD per entry would just be a redundant serial
|
||||
// round trip (500-photo zip = 500 extra HEADs).
|
||||
if (storageKey && storage.kind() === 'local') {
|
||||
const srcStat = await storage.stat(storageKey);
|
||||
if (!srcStat) {
|
||||
throw new Error(`Photo missing in storage: ${storageKey}`);
|
||||
}
|
||||
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
|
||||
throw new Error('Photo file missing on disk');
|
||||
}
|
||||
|
||||
if (shouldApplyWatermark && effectiveSettings) {
|
||||
// Watermark service operates on a local path. For managed photos in
|
||||
// S3 mode, materialize a tmp local copy first.
|
||||
@@ -1076,9 +1114,9 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
const stream = await storage.get(storageKey);
|
||||
archive.append(stream, { name: archiveName });
|
||||
} else {
|
||||
const filePath = resolvePhotoFilePath(req.event, photo);
|
||||
archive.file(filePath, { name: archiveName });
|
||||
archive.file(resolvePhotoFilePath(req.event, photo), { name: archiveName });
|
||||
}
|
||||
appendedIds.push(photo.id);
|
||||
} catch (err) {
|
||||
logger.warn('Skipping photo in bulk download due to error', {
|
||||
slug: req.params.slug,
|
||||
@@ -1098,6 +1136,12 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download_all'
|
||||
});
|
||||
// Exactly the photos that made it into this archive (#895) — skipped
|
||||
// (missing/corrupt) sources don't count.
|
||||
if (appendedIds.length > 0) {
|
||||
db('photos').whereIn('id', appendedIds)
|
||||
.increment('download_count', 1).catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to create download archive');
|
||||
}
|
||||
@@ -1179,11 +1223,26 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
||||
// #493: same display-name resolution as bulk download, with dedup.
|
||||
const useOriginalSelected = await getUseOriginalFilenames();
|
||||
const selectedEntryNames = getZipEntryNames(photos, useOriginalSelected);
|
||||
// Only photos whose append succeeded count as downloaded (#895).
|
||||
const appendedIds = [];
|
||||
for (let i = 0; i < photos.length; i += 1) {
|
||||
const photo = photos[i];
|
||||
const name = selectedEntryNames[i] || `photo-${photo.id}.jpg`;
|
||||
const storageKey = resolveSelectedKey(req.event, photo);
|
||||
try {
|
||||
// Same pre-append source check as download-all (#895 review),
|
||||
// local backend only: a lazy fs stream's async error would kill
|
||||
// the response instead of skipping the photo; S3's get() rejects
|
||||
// at the await below, so no redundant per-entry HEAD there.
|
||||
if (storageKey && selectedStorage.kind() === 'local') {
|
||||
const srcStat = await selectedStorage.stat(storageKey);
|
||||
if (!srcStat) {
|
||||
throw new Error(`Photo missing in storage: ${storageKey}`);
|
||||
}
|
||||
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
|
||||
throw new Error('Photo file missing on disk');
|
||||
}
|
||||
|
||||
if (shouldApplyWatermark && effectiveSettings) {
|
||||
const buf = storageKey
|
||||
? await withSelectedLocalCopy(storageKey, (lp) =>
|
||||
@@ -1197,6 +1256,7 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
||||
} else {
|
||||
archive.file(resolvePhotoFilePath(req.event, photo), { name });
|
||||
}
|
||||
appendedIds.push(photo.id);
|
||||
} catch (err) {
|
||||
logger.warn('Skipping selected photo due to error', {
|
||||
slug: req.params.slug,
|
||||
@@ -1215,12 +1275,49 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download_selected'
|
||||
});
|
||||
// Exactly the photos that made it into this archive (#895) — skipped
|
||||
// (missing/corrupt) sources don't count.
|
||||
if (appendedIds.length > 0) {
|
||||
db('photos').whereIn('id', appendedIds)
|
||||
.increment('download_count', 1).catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to download selected photos');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Explicit per-photo view beacon (#895). Counting views on the image-
|
||||
// serving routes is wrong in both directions: the lightbox preloads the
|
||||
// prev/next neighbours (three fetches per open), while a preloaded
|
||||
// neighbour that becomes the current slide is never re-fetched (#505
|
||||
// keeps the DOM node alive across the swipe) — so request-level counters
|
||||
// overcount preloads AND undercount swipe-throughs. Instead the lightbox
|
||||
// pings this endpoint exactly when a photo becomes the visible slide.
|
||||
// This also covers enhanced/maximum-protection galleries, whose bytes
|
||||
// are served by /api/secure-images and never pass the routes below.
|
||||
// The slideshow kiosk is excluded (denySlideshowToken; migration 138).
|
||||
router.post('/:slug/photo/:photoId/view',
|
||||
verifyGalleryAccess,
|
||||
denySlideshowToken,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const photo = await db('photos')
|
||||
.where({ id: req.params.photoId, event_id: req.event.id })
|
||||
.first('id', 'visibility');
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
await db('photos').where('id', photo.id).increment('view_count', 1);
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to record view');
|
||||
}
|
||||
});
|
||||
|
||||
// View single photo (with watermark if enabled)
|
||||
router.get('/:slug/photo/:photoId',
|
||||
verifyGalleryAccess,
|
||||
|
||||
@@ -298,7 +298,10 @@ class RestoreService {
|
||||
this.log('info', 'Applying post-restore migrations to restored database...');
|
||||
this.updateProgress('Applying any post-backup migrations...');
|
||||
const backendRoot = path.join(__dirname, '..', '..');
|
||||
const { stderr } = await spawnAsync('npm', ['run', 'migrate:safe'], {
|
||||
// Invoked via node directly — the runtime image ships no npm
|
||||
// (see Dockerfile), and an ENOENT here would be swallowed by the
|
||||
// non-fatal catch below, silently skipping post-restore migrations.
|
||||
const { stderr } = await spawnAsync('node', ['migrations/run-migrations-safe.js'], {
|
||||
cwd: backendRoot,
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
@@ -120,12 +120,14 @@ echo "Ensuring storage directories exist..."
|
||||
STORAGE_BASE="${STORAGE_PATH:-/app/storage}"
|
||||
mkdir -p "$STORAGE_BASE/events/active" "$STORAGE_BASE/events/archived" "$STORAGE_BASE/thumbnails" 2>/dev/null || true
|
||||
|
||||
# Run migrations (use safe runner in production)
|
||||
# Run migrations (use safe runner in production). Invoked via node directly —
|
||||
# the runtime image no longer ships npm (see Dockerfile: its bundled deps kept
|
||||
# tripping CVE scanners while npm itself never runs in production).
|
||||
echo "Running database migrations..."
|
||||
if [ "$NODE_ENV" = "production" ]; then
|
||||
npm run migrate:safe
|
||||
node migrations/run-migrations-safe.js
|
||||
else
|
||||
npm run migrate
|
||||
node migrations/run-migrations.js
|
||||
fi
|
||||
|
||||
# Execute the main command
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.45.6",
|
||||
"version": "3.45.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSavePhotoToDevice } from '../../hooks/useGallery';
|
||||
import { AuthenticatedImage } from '../common';
|
||||
import { PhotoFeedback } from './PhotoFeedback';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
@@ -102,6 +103,17 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
// View beacon (#895): count exactly the photo that became the visible
|
||||
// slide. The image fetches themselves can't be counted — preloaded
|
||||
// neighbours would inflate, and a neighbour promoted by a swipe is
|
||||
// never re-fetched (#505).
|
||||
const currentPhotoId = photos[currentIndex]?.id;
|
||||
useEffect(() => {
|
||||
if (currentPhotoId !== undefined) {
|
||||
galleryService.trackPhotoView(slug, currentPhotoId);
|
||||
}
|
||||
}, [slug, currentPhotoId]);
|
||||
|
||||
|
||||
// Save-aware download. On mobile (where Web Share + files is supported)
|
||||
// this opens the OS share sheet so "Save to Photos" actually lands in
|
||||
|
||||
@@ -563,6 +563,14 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
close={() => setLightboxIndex(-1)}
|
||||
index={lightboxIndex}
|
||||
slides={slides}
|
||||
// View beacon (#895): yarl fires `view` on open and on every
|
||||
// slide change — same semantics as PhotoLightbox's beacon.
|
||||
on={{
|
||||
view: ({ index }) => {
|
||||
const photo = filteredPhotos[index];
|
||||
if (photo) galleryService.trackPhotoView(slug, photo.id);
|
||||
},
|
||||
}}
|
||||
plugins={[
|
||||
Thumbnails,
|
||||
Zoom,
|
||||
|
||||
@@ -198,6 +198,15 @@ export const galleryService = {
|
||||
this.triggerBrowserDownload(fetched.blob, fetched.serverFilename || filename);
|
||||
},
|
||||
|
||||
// Per-photo view beacon (#895). Fired by the lightbox when a photo
|
||||
// becomes the visible slide — request-level counting on the image
|
||||
// endpoints can't tell the current slide from its preloaded
|
||||
// neighbours. Fire-and-forget: view counting must never surface an
|
||||
// error to the guest.
|
||||
trackPhotoView(slug: string, photoId: number): void {
|
||||
api.post(`/gallery/${slug}/photo/${photoId}/view`).catch(() => {});
|
||||
},
|
||||
|
||||
// Download all photos as ZIP
|
||||
// When a pre-generated zip is available, use native browser download (Content-Length → progress bar).
|
||||
// Otherwise fall back to blob download.
|
||||
|
||||
Reference in New Issue
Block a user