Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f83d144f28 | |||
| b62cd2c290 | |||
| eaa8b41ba3 | |||
| d46397d92a | |||
| e46260ad07 | |||
| e9fadd2ef4 | |||
| d977e3e296 | |||
| da44f1947b | |||
| dc9e3cdc5e | |||
| 7598e20f55 | |||
| 32db1c8052 | |||
| 9833237d37 | |||
| 83290a0f1a | |||
| 6df42ab22c | |||
| 45ffe64b7c | |||
| 84eab88801 | |||
| 10d5cf54a5 | |||
| 376311cb90 | |||
| 88fa3c5297 | |||
| 980378a17b | |||
| 0a999795cc | |||
| ed4e32c4df | |||
| 9003b34c8a | |||
| de459c701f | |||
| 8b6cd3c74f | |||
| fb3d0b08b2 | |||
| 945e63ae86 | |||
| 93d4ae68f4 | |||
| 2bdb1204fe | |||
| cee0a380a6 |
@@ -203,6 +203,14 @@ jobs:
|
||||
format: 'sarif'
|
||||
output: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
# Base-image CVEs with no released fix are not actionable: the
|
||||
# Dockerfiles already run `apt-get upgrade -y` behind a CACHEBUST,
|
||||
# so a fix lands in the next build automatically. Reporting them
|
||||
# buries the findings someone can actually do something about.
|
||||
# Dropping them is also the precondition for ever setting
|
||||
# exit-code: 1, which build-backend's comment flags as a
|
||||
# deliberate follow-up.
|
||||
ignore-unfixed: true
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
@@ -425,6 +433,14 @@ jobs:
|
||||
format: 'sarif'
|
||||
output: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
# Base-image CVEs with no released fix are not actionable: the
|
||||
# Dockerfiles already run `apt-get upgrade -y` behind a CACHEBUST,
|
||||
# so a fix lands in the next build automatically. Reporting them
|
||||
# buries the findings someone can actually do something about.
|
||||
# Dropping them is also the precondition for ever setting
|
||||
# exit-code: 1, which build-backend's comment flags as a
|
||||
# deliberate follow-up.
|
||||
ignore-unfixed: true
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
|
||||
@@ -30,6 +30,29 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
# The .picpeak restore suites gate their real-Postgres cases behind
|
||||
# PICPEAK_PG_TEST_URL and `describe.skip` themselves out when it is
|
||||
# unset — so until now they never ran here. That hid the half that
|
||||
# matters: sequence resync, operator/role preservation across a
|
||||
# cross-instance restore, and (with #1041) whether a SQLite-shaped
|
||||
# row actually lands in Postgres with the right STORED VALUES rather
|
||||
# than merely not throwing. Everything else in the suite still runs
|
||||
# on SQLite; this service only un-gates those cases.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
env:
|
||||
POSTGRES_USER: picpeak
|
||||
POSTGRES_PASSWORD: testpass
|
||||
POSTGRES_DB: picpeak_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U picpeak -d picpeak_test"
|
||||
--health-interval 2s
|
||||
--health-timeout 2s
|
||||
--health-retries 30
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -52,6 +75,9 @@ jobs:
|
||||
# The S3 path itself is covered separately by the integration
|
||||
# suite when MinIO is provisioned.
|
||||
SKIP_S3_TESTS: 'true'
|
||||
# Un-gates the real-Postgres cases in the .picpeak restore suites
|
||||
# (see the `services:` note above). Absent it they silently skip.
|
||||
PICPEAK_PG_TEST_URL: 'postgres://picpeak:testpass@127.0.0.1:5432/picpeak_test'
|
||||
run: |
|
||||
# Excluded suites — fail on upstream/beta too, tracked
|
||||
# separately as test-infra debt:
|
||||
|
||||
+5
-2
@@ -130,5 +130,8 @@ docker-compose.dev.yml
|
||||
# New layout development files
|
||||
new-layouts/
|
||||
|
||||
# Generated CRM/accounting documents (runtime) — never commit
|
||||
backend/storage/business-docs/
|
||||
# Backend runtime storage (generated media, previews, thumbnails,
|
||||
# CRM/accounting documents) — never commit. Matches main: a dev instance
|
||||
# writes event photos into backend/storage/, and the narrower
|
||||
# business-docs-only rule let `git add -A` sweep them into a commit.
|
||||
backend/storage/
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"3.45.14"}
|
||||
{".":"3.46.4"}
|
||||
|
||||
@@ -5,6 +5,70 @@ All notable changes to PicPeak will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.46.4](https://github.com/PicPeak/picpeak/compare/v3.46.3...v3.46.4) (2026-08-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** a guest's own hidden feedback is hidden from them too ([#1150](https://github.com/PicPeak/picpeak/issues/1150)) ([#1157](https://github.com/PicPeak/picpeak/issues/1157)) ([b62cd2c](https://github.com/PicPeak/picpeak/commit/b62cd2c290d54820e8f58d11719d48592a1cd1f1))
|
||||
* **gallery:** guest filters respect show_feedback_to_guests ([#1044](https://github.com/PicPeak/picpeak/issues/1044)) ([#1156](https://github.com/PicPeak/picpeak/issues/1156)) ([eaa8b41](https://github.com/PicPeak/picpeak/commit/eaa8b41ba323c7eac22e04947fead8e468e9c6c2))
|
||||
* **gallery:** no Logout button on galleries that don't require a password ([#1149](https://github.com/PicPeak/picpeak/issues/1149)) ([#1154](https://github.com/PicPeak/picpeak/issues/1154)) ([d46397d](https://github.com/PicPeak/picpeak/commit/d46397d92a7648910075fb774b14abf77d893865))
|
||||
* **scripts:** regenerate-thumbnails resolves external sources through ensureThumbnail ([#1148](https://github.com/PicPeak/picpeak/issues/1148)) ([#1155](https://github.com/PicPeak/picpeak/issues/1155)) ([e46260a](https://github.com/PicPeak/picpeak/commit/e46260ad0799bd411a4158c4cc31d587ba85d4ca))
|
||||
|
||||
## [3.46.3](https://github.com/PicPeak/picpeak/compare/v3.46.2...v3.46.3) (2026-08-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gallery:** a missing file must not take the backend down ([#1128](https://github.com/PicPeak/picpeak/issues/1128)) ([da44f19](https://github.com/PicPeak/picpeak/commit/da44f1947b8317b47271f4f2a98b284b25d752c1))
|
||||
* **gallery:** give masonry tiles their real shape back ([#1130](https://github.com/PicPeak/picpeak/issues/1130), [#1131](https://github.com/PicPeak/picpeak/issues/1131)) ([d977e3e](https://github.com/PicPeak/picpeak/commit/d977e3e296deeb19c26f1e5a98258eec323d120d))
|
||||
* **thumbnails:** regenerate external photos, and stop destroying good ones ([#1129](https://github.com/PicPeak/picpeak/issues/1129)) ([dc9e3cd](https://github.com/PicPeak/picpeak/commit/dc9e3cdc5e00ac634f581e8d6b13107fe4839152))
|
||||
|
||||
## [3.46.2](https://github.com/PicPeak/picpeak/compare/v3.46.1...v3.46.2) (2026-08-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ui:** stop iOS Safari zooming in on 14px form fields ([#1114](https://github.com/PicPeak/picpeak/issues/1114)) ([32db1c8](https://github.com/PicPeak/picpeak/commit/32db1c8052d324b09462a17859c7adb5ccfe56e3))
|
||||
|
||||
## [3.46.1](https://github.com/PicPeak/picpeak/compare/v3.46.0...v3.46.1) (2026-08-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **preview:** generate lightbox previews for external/reference photos ([#1078](https://github.com/PicPeak/picpeak/issues/1078)) ([#1080](https://github.com/PicPeak/picpeak/issues/1080)) ([6df42ab](https://github.com/PicPeak/picpeak/commit/6df42ab22c705bcb731862db1ed5a27de0a64f30))
|
||||
|
||||
## [3.46.0](https://github.com/PicPeak/picpeak/compare/v3.45.16...v3.46.0) (2026-08-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **backup:** open sqlite → pg .picpeak restore as the supported upgrade direction ([#1041](https://github.com/PicPeak/picpeak/issues/1041)) ([#1059](https://github.com/PicPeak/picpeak/issues/1059)) ([980378a](https://github.com/PicPeak/picpeak/commit/980378a17ba873d0e2f3d76048dacb3b8d7a4eb2))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **pdf:** RFC 6266-encode Content-Disposition on quote/invoice PDFs ([#1024](https://github.com/PicPeak/picpeak/issues/1024)) ([#1062](https://github.com/PicPeak/picpeak/issues/1062)) ([376311c](https://github.com/PicPeak/picpeak/commit/376311cb9091ff1726e8b383312f22c607dcc8a0))
|
||||
* **storage:** add S3 client timeouts so a dropped connection can't wedge uploads ([#1049](https://github.com/PicPeak/picpeak/issues/1049)) ([#1054](https://github.com/PicPeak/picpeak/issues/1054)) ([88fa3c5](https://github.com/PicPeak/picpeak/commit/88fa3c52973fa122f8d4e7b21ba1ffc89f9f9c2e))
|
||||
|
||||
## [3.45.16](https://github.com/PicPeak/picpeak/compare/v3.45.15...v3.45.16) (2026-08-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** default NODE_ENV=production so non-compose deploys don't fall back to SQLite ([#1038](https://github.com/PicPeak/picpeak/issues/1038)) ([#1040](https://github.com/PicPeak/picpeak/issues/1040)) ([9003b34](https://github.com/PicPeak/picpeak/commit/9003b34c8a0396cd28906f089aef33f38a23ffb7))
|
||||
* **events:** make event_date/expires_at nullable on SQLite ([#1029](https://github.com/PicPeak/picpeak/issues/1029)) ([#1036](https://github.com/PicPeak/picpeak/issues/1036)) ([fb3d0b0](https://github.com/PicPeak/picpeak/commit/fb3d0b08b2dc34f7e7dab7da754a3522c52a9eb1))
|
||||
* **feedback:** persist guest feedback settings, unshadow the guest route ([#1030](https://github.com/PicPeak/picpeak/issues/1030)) ([#1032](https://github.com/PicPeak/picpeak/issues/1032)) ([de459c7](https://github.com/PicPeak/picpeak/commit/de459c701f28532ca53d52773b02de44c9978073))
|
||||
* **gallery:** coerce SQLite 0/1 booleans in the guest surface ([#1028](https://github.com/PicPeak/picpeak/issues/1028)) ([#1037](https://github.com/PicPeak/picpeak/issues/1037)) ([8b6cd3c](https://github.com/PicPeak/picpeak/commit/8b6cd3c74f2aeb5d38ebfeee04bbc211d6fa2c0c))
|
||||
|
||||
## [3.45.15](https://github.com/PicPeak/picpeak/compare/v3.45.14...v3.45.15) (2026-08-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** bump nanoid and js-yaml out of two HIGH advisories (stable) ([#1014](https://github.com/PicPeak/picpeak/issues/1014)) ([cee0a38](https://github.com/PicPeak/picpeak/commit/cee0a380a6faf2bb0a5c802057ba3140670840d2))
|
||||
* **slideshow:** stop "no crop" fit letterboxing a pre-cropped frame (stable) ([#1015](https://github.com/PicPeak/picpeak/issues/1015)) ([#1019](https://github.com/PicPeak/picpeak/issues/1019)) ([2bdb120](https://github.com/PicPeak/picpeak/commit/2bdb1204fe61a9b6cd704b35ccfd39efa15ed118))
|
||||
|
||||
## [3.45.14](https://github.com/PicPeak/picpeak/compare/v3.45.13...v3.45.14) (2026-08-04)
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,15 @@ FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# knexfile.js picks its config block by NODE_ENV, and the `development` block
|
||||
# defaults to sqlite3. Leaving NODE_ENV unset here meant every deployment that
|
||||
# doesn't go through our compose files — Kubernetes, Helm, plain `docker run` —
|
||||
# silently ran on SQLite and ignored DB_HOST/DB_USER/DB_PASSWORD, while
|
||||
# wait-for-db.sh (shell, reads DB_HOST directly) reported "PostgreSQL is up" in
|
||||
# the same log. The compose files still override this, so nothing changes for
|
||||
# compose users. See #1038.
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
|
||||
# stage's declaration never reached this stage. Consuming it in the RUN below
|
||||
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* POST /admin/thumbnails/regenerate for external/reference photos (#1129).
|
||||
*
|
||||
* STABLE TWIN. Diverges from the main version in one place: stable has no
|
||||
* responsive ?w= tiers (#1095/#1109), so there is no deleteThumbnailTiers call
|
||||
* to assert and the "drops the tiers first" test is absent here. Everything
|
||||
* else — the external rebuild, the thumbnail_path:null contract, video
|
||||
* skipping, per-event scoping and the superseded-key deletion — is identical.
|
||||
*
|
||||
* The route used to resolve every source as `storage/events/active/<path>` and
|
||||
* `fs.access` it. External and reference rows do not live there — their
|
||||
* originals sit under `events.external_path` — so every one of them failed the
|
||||
* check and was counted as an error.
|
||||
*
|
||||
* That alone would be inert. What made it destructive is that the tier
|
||||
* deletion runs FIRST (deliberately, so S3 and external rows are not skipped):
|
||||
* on a reference install the button dropped every ?w= tier and rebuilt
|
||||
* nothing, while the UI reported success — the response is sent before the
|
||||
* background loop starts.
|
||||
*
|
||||
* The background work is fired with setImmediate, so every assertion here has
|
||||
* to wait for it to drain rather than trusting the response.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('admin thumbnail regeneration (#1129)', () => {
|
||||
let tmpDir; let db; let cleanup; let app; let imageProcessor; let storage;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
// One instance, not a fresh object per call — the route and the
|
||||
// assertions have to be looking at the same mock.
|
||||
jest.doMock('../../src/services/storage', () => {
|
||||
const instance = { delete: jest.fn().mockResolvedValue(undefined) };
|
||||
return { getStorage: () => instance };
|
||||
});
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
ensureThumbnail: jest.fn().mockResolvedValue('thumbnails/thumb_ext1_shot.jpg'),
|
||||
ensurePreviewImage: jest.fn().mockResolvedValue('previews/p.jpg'),
|
||||
deletePreviewTiers: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// bootCrmDb, not run-migrations: the latter calls process.exit(0) on
|
||||
// success, which ends the jest worker mid-suite.
|
||||
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
storage = require('../../src/services/storage').getStorage();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/admin/thumbnails', require('../../src/routes/adminThumbnails'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
});
|
||||
|
||||
async function seedEvent() {
|
||||
const [row] = await db('events').insert({
|
||||
slug: 'nas-wedding', event_type: 'wedding', event_name: 'nas',
|
||||
event_date: '2026-01-01', host_email: 'h@example.com', admin_email: 'a@example.com',
|
||||
password_hash: 'x', share_link: 'nas-share', expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference', external_path: 'weddings/2026-08',
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function seedPhoto(eventId, overrides = {}) {
|
||||
const [row] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'shot.jpg', path: 'nas-wedding/shot.jpg',
|
||||
type: 'individual', ...overrides,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/** The work runs in setImmediate; give it room to finish. */
|
||||
const drain = () => new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
it('rebuilds the canonical thumbnail for an external photo instead of erroring', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'external',
|
||||
external_relpath: 'shot.jpg',
|
||||
thumbnail_path: 'thumbnails/stale.jpg',
|
||||
});
|
||||
|
||||
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
expect(res.status).toBe(200);
|
||||
await drain();
|
||||
|
||||
// The whole bug: this used to be zero calls and one logged
|
||||
// "Original file not found" per photo.
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('nulls thumbnail_path so the valid-thumbnail short-circuit cannot skip the rebuild', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'external',
|
||||
external_relpath: 'shot.jpg',
|
||||
thumbnail_path: 'thumbnails/still-on-disk.jpg',
|
||||
});
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
// Without this the endpoint is a no-op whenever the OLD thumbnail is still
|
||||
// readable — which is the normal case after a settings change, and exactly
|
||||
// when the admin pressed the button.
|
||||
const [photoArg] = imageProcessor.ensureThumbnail.mock.calls[0];
|
||||
expect(photoArg.thumbnail_path).toBeNull();
|
||||
expect(photoArg.source_origin).toBe('external');
|
||||
// Carried through so ensureThumbnail can resolve off the mount rather than
|
||||
// under events/active.
|
||||
expect(photoArg.external_relpath).toBe('shot.jpg');
|
||||
});
|
||||
|
||||
it('leaves videos alone rather than handing a container file to Sharp', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, { source_origin: 'managed', media_type: 'video', filename: 'clip.mp4' });
|
||||
await seedPhoto(eventId, { source_origin: 'managed', filename: 'still.jpg' });
|
||||
|
||||
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
expect(imageProcessor.ensureThumbnail.mock.calls[0][0].filename).toBe('still.jpg');
|
||||
});
|
||||
|
||||
/**
|
||||
* On S3, ensureThumbnail downloads the source to a randomly-named temp file,
|
||||
* and for non-RAW input withProcessableImage passes no outputBasename — so
|
||||
* generateThumbnail derives the key from that random name and it differs on
|
||||
* every run. Nulling thumbnail_path hides the old key from everything that
|
||||
* would otherwise clean it up, so each regeneration would strand a full
|
||||
* thumbnail in the bucket, once per photo per run.
|
||||
*/
|
||||
describe('superseded canonical renditions', () => {
|
||||
it('removes the old thumbnail when the key moved', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'managed',
|
||||
thumbnail_path: 'thumbnails/thumb_OLDRANDOM_shot.jpg',
|
||||
});
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEWRANDOM_shot.jpg');
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(storage.delete).toHaveBeenCalledWith('thumbnails/thumb_OLDRANDOM_shot.jpg');
|
||||
});
|
||||
|
||||
it('does NOT delete when the key is unchanged — that is the new file', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'managed',
|
||||
thumbnail_path: 'thumbnails/thumb_stable.jpg',
|
||||
});
|
||||
// Local storage resolves to a stable path, so the key is identical.
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_stable.jpg');
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(storage.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a Windows-style legacy path', 'thumbnails\\thumb_ext1_shot.jpg'],
|
||||
['a leading ./', './thumbnails/thumb_ext1_shot.jpg'],
|
||||
['a doubled separator', 'thumbnails//thumb_ext1_shot.jpg'],
|
||||
])('does not delete the file it just wrote when the old path is %s', async (_name, stored) => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, { source_origin: 'managed', thumbnail_path: stored });
|
||||
// Both storage backends fold these to the same key, so this is the SAME
|
||||
// object — deleting it would remove the freshly generated thumbnail and
|
||||
// leave the row pointing at nothing.
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_ext1_shot.jpg');
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(storage.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts the photo as regenerated even if the old object cannot be removed', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'managed',
|
||||
thumbnail_path: 'thumbnails/thumb_OLD.jpg',
|
||||
});
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEW.jpg');
|
||||
storage.delete.mockRejectedValueOnce(new Error('bucket said no'));
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
// Losing the old object is untidy; the regeneration itself succeeded.
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes to one event when asked', async () => {
|
||||
const a = await seedEvent();
|
||||
await seedPhoto(a, { source_origin: 'external', external_relpath: 'a.jpg' });
|
||||
const [b] = await db('events').insert({
|
||||
slug: 'other', event_type: 'wedding', event_name: 'other', event_date: '2026-01-01',
|
||||
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
|
||||
share_link: 'other-share', expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
await seedPhoto(typeof b === 'object' ? b.id : b, { source_origin: 'managed' });
|
||||
|
||||
const res = await request(app).post('/admin/thumbnails/regenerate').send({ eventId: a });
|
||||
await drain();
|
||||
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Guest filters must respect show_feedback_to_guests (#1044 follow-up).
|
||||
*
|
||||
* Every filter token on /photos is an OR of two halves: what THIS viewer
|
||||
* marked, and what ANYONE marked. The response fields built from the second
|
||||
* half — like_count, comment_count — are all gated on
|
||||
* show_feedback_to_guests. The FILTER was not.
|
||||
*
|
||||
* So with the setting off, the numbers were hidden but `?filter=liked` still
|
||||
* returned exactly the photos other people had liked: the same information as
|
||||
* a set instead of a count, one token at a time. These tests pin the gate on
|
||||
* every token, and pin that the viewer's own half is never gated — filtering
|
||||
* by what you yourself marked is yours to do regardless.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'filter-visibility-secret';
|
||||
|
||||
const SLUG = 'filter-visibility-event';
|
||||
const ME = 'guest-me-identifier';
|
||||
const SOMEONE_ELSE = 'guest-other-identifier';
|
||||
|
||||
describe('guest filters and show_feedback_to_guests (#1044)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let eventId;
|
||||
let mine;
|
||||
let theirs;
|
||||
let myGuestRowId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const setVisibility = (visible) => db('event_feedback_settings')
|
||||
.where({ event_id: eventId })
|
||||
.update({ show_feedback_to_guests: visible });
|
||||
|
||||
// A real verified guest, which is how the viewer's own feedback is actually
|
||||
// identified — NOT the `guest_id` query parameter the frontend invents.
|
||||
const guestToken = () => jwt.sign(
|
||||
{ type: 'guest', guestId: myGuestRowId, eventId },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const filter = async (token, { as = 'me', claimGuestId } = {}) => {
|
||||
const req = request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.query({ filter: token, ...(claimGuestId ? { guest_id: claimGuestId } : {}) })
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
if (as === 'me') req.set('x-guest-token', guestToken());
|
||||
const res = await req;
|
||||
expect(res.status).toBe(200);
|
||||
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
return (photos || []).map((p) => p.id).sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const inserted = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Filter Visibility',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'filter-visibility-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
const addPhoto = async (name) => {
|
||||
const p = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: name,
|
||||
path: `events/filter/${name}`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return p[0]?.id ?? p[0];
|
||||
};
|
||||
mine = await addPhoto('mine.jpg');
|
||||
theirs = await addPhoto('theirs.jpg');
|
||||
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId,
|
||||
feedback_enabled: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_ratings: true,
|
||||
allow_favorites: true,
|
||||
moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
});
|
||||
|
||||
const guestRow = await db('gallery_guests').insert({
|
||||
event_id: eventId,
|
||||
name: 'Me',
|
||||
identifier: ME,
|
||||
created_at: new Date().toISOString(),
|
||||
last_seen_at: new Date().toISOString(),
|
||||
is_deleted: false,
|
||||
}).returning('id');
|
||||
myGuestRowId = guestRow[0]?.id ?? guestRow[0];
|
||||
|
||||
const feedback = (photoId, who, type, extra = {}) => db('photo_feedback').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
guest_identifier: who,
|
||||
// Submission links to the per-person guest row when one is present, and
|
||||
// that is the column the viewer's own half resolves through.
|
||||
guest_id: who === ME ? myGuestRowId : null,
|
||||
feedback_type: type,
|
||||
is_approved: true,
|
||||
is_hidden: false,
|
||||
created_at: new Date().toISOString(),
|
||||
...extra,
|
||||
});
|
||||
|
||||
// Everything on `theirs` belongs to somebody else; `mine` is this viewer's.
|
||||
await feedback(mine, ME, 'like');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'like');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'favorite');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'comment', { comment_text: 'lovely' });
|
||||
await feedback(theirs, SOMEONE_ELSE, 'rating', { rating: 5 });
|
||||
|
||||
// The denormalized counters the aggregate half of the filter reads.
|
||||
await db('photos').where('id', theirs).update({
|
||||
like_count: 1, favorite_count: 1, comment_count: 1, average_rating: 5,
|
||||
});
|
||||
await db('photos').where('id', mine).update({ like_count: 1 });
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('with feedback visible to guests', () => {
|
||||
beforeAll(() => setVisibility(true));
|
||||
|
||||
it('shows other people\'s marks through every token, as before', async () => {
|
||||
expect(await filter('liked')).toEqual([mine, theirs].sort((a, b) => a - b));
|
||||
expect(await filter('favorited')).toEqual([theirs]);
|
||||
expect(await filter('rated')).toEqual([theirs]);
|
||||
expect(await filter('commented')).toEqual([theirs]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with feedback hidden from guests', () => {
|
||||
beforeAll(() => setVisibility(false));
|
||||
|
||||
it('stops every token from selecting on other people\'s marks', async () => {
|
||||
// `theirs` is the photo only other guests marked. It must not come back
|
||||
// through any token — a filter that selects on hidden feedback reports
|
||||
// that feedback just as surely as a count would.
|
||||
expect(await filter('favorited')).toEqual([]);
|
||||
expect(await filter('rated')).toEqual([]);
|
||||
expect(await filter('commented')).toEqual([]);
|
||||
});
|
||||
|
||||
it('still filters by what the viewer marked themselves', async () => {
|
||||
// The viewer's own half is never gated: this is their own action, and
|
||||
// hiding it would break "show me the ones I liked" for no privacy gain.
|
||||
expect(await filter('liked')).toEqual([mine]);
|
||||
});
|
||||
|
||||
it('drops the viewer\'s own feedback once an admin hides it', async () => {
|
||||
// Moderation has to reach the filter too. getPhotoFeedback excludes
|
||||
// hidden rows for the guest's OWN feedback, so a photo matching here
|
||||
// would come back with nothing visible on it to explain why.
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
expect(await filter('liked')).toEqual([]);
|
||||
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: false });
|
||||
expect(await filter('liked')).toEqual([mine]);
|
||||
});
|
||||
|
||||
it('ignores a guest_id supplied by the caller', async () => {
|
||||
// The own-half is resolved from the request identity. If it honoured the
|
||||
// query string instead, anyone holding another guest's identifier could
|
||||
// read that guest's hidden memberships one token at a time — straight
|
||||
// back through the gate this file exists to pin.
|
||||
expect(await filter('favorited', { claimGuestId: SOMEONE_ELSE })).toEqual([]);
|
||||
// And an anonymous caller claiming to be me gets nothing of mine.
|
||||
expect(await filter('liked', { as: 'anon', claimGuestId: ME })).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Hidden feedback, seen from the guest who left it (#1150).
|
||||
*
|
||||
* Everything in the system treats a hidden row as absent: getPhotoFeedback
|
||||
* drops it even for the guest's own feedback, the /photos filters drop it, and
|
||||
* updatePhotoFeedbackStats does not count it. One place disagreed — the
|
||||
* per-viewer `is_liked` heart — so a like the photographer had hidden still
|
||||
* showed as liked on a photo whose like_count was zero. (The `my_color_label`
|
||||
* badge has the same shape on main; colour labels are not on this branch.)
|
||||
*
|
||||
* Making those two agree exposes the second half: the duplicate check that
|
||||
* powers like/favorite toggling did NOT skip hidden rows, so the now-empty
|
||||
* heart, when clicked, found the hidden row and toggled it OFF. The click
|
||||
* appeared to do nothing and it took two more to get back to a filled heart.
|
||||
*
|
||||
* Hiding a non-comment is deliberate, not an accident of the raw route: #839
|
||||
* and #1044 both ship it, with tests asserting that a hidden reaction or
|
||||
* colour label stops counting. So the fix is to make hidden mean absent
|
||||
* consistently — not to stop admins hiding these.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-feedback-secret';
|
||||
|
||||
const SLUG = 'hidden-own-feedback';
|
||||
const ME = 'guest-me-identifier';
|
||||
|
||||
describe('a guest\'s own hidden feedback (#1150)', () => {
|
||||
let db; let cleanup; let app; let feedbackService;
|
||||
let eventId; let photoId; let myGuestRowId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
const guestToken = () => jwt.sign(
|
||||
{ type: 'guest', guestId: myGuestRowId, eventId },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const getPhoto = async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.set('x-guest-token', guestToken());
|
||||
expect(res.status).toBe(200);
|
||||
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
return (photos || []).find((p) => p.id === photoId);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
feedbackService = require('../../src/services/feedbackService');
|
||||
|
||||
const [ev] = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Hidden Own Feedback',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'hidden-own-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = typeof ev === 'object' ? ev.id : ev;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'shot.jpg', path: 'events/hidden/shot.jpg',
|
||||
type: 'individual', uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const [g] = await db('gallery_guests').insert({
|
||||
event_id: eventId, name: 'Me', identifier: ME,
|
||||
created_at: new Date().toISOString(), last_seen_at: new Date().toISOString(),
|
||||
is_deleted: false,
|
||||
}).returning('id');
|
||||
myGuestRowId = typeof g === 'object' ? g.id : g;
|
||||
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId, feedback_enabled: true, allow_likes: true,
|
||||
moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
});
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
const like = () => db('photo_feedback').insert({
|
||||
photo_id: photoId, event_id: eventId, guest_identifier: ME,
|
||||
guest_id: myGuestRowId, feedback_type: 'like',
|
||||
is_approved: true, is_hidden: false, created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('photo_feedback').where({ photo_id: photoId }).del();
|
||||
await db('photos').where('id', photoId).update({ like_count: 0 });
|
||||
});
|
||||
|
||||
describe('the read surfaces agree with each other', () => {
|
||||
it('un-fills the heart once the like is hidden', async () => {
|
||||
await like();
|
||||
await feedbackService.updatePhotoFeedbackStats(photoId);
|
||||
expect((await getPhoto()).is_liked).toBe(true);
|
||||
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
await feedbackService.updatePhotoFeedbackStats(photoId);
|
||||
|
||||
const photo = await getPhoto();
|
||||
// like_count already ignored hidden rows, so the heart was the only
|
||||
// thing still claiming this photo was liked.
|
||||
expect(photo.like_count).toBe(0);
|
||||
expect(photo.is_liked).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('and every other surface agrees', () => {
|
||||
it('keeps a hidden like out of /my-feedback', async () => {
|
||||
await like();
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/my-feedback`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.set('x-guest-token', guestToken());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// In guest identity mode the Liked/Favorited/Rated chips and their
|
||||
// filters are built from THIS array, not from is_liked — so a hidden
|
||||
// like left an empty heart while the chip still counted it.
|
||||
expect(res.body.filter((f) => f.feedback_type === 'like')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not count a hidden row against the guest cap', async () => {
|
||||
await db('event_feedback_settings')
|
||||
.where({ event_id: eventId }).update({ max_likes_per_guest: 1 });
|
||||
await like();
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
// The hidden row is room, not an occupant: the guest sees an empty
|
||||
// heart, and meeting that click with limit_reached leaves the control
|
||||
// dead until they un-like something they can still see.
|
||||
const result = await feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
|
||||
});
|
||||
expect(result.limit_reached).toBeUndefined();
|
||||
|
||||
await db('event_feedback_settings')
|
||||
.where({ event_id: eventId }).update({ max_likes_per_guest: null });
|
||||
});
|
||||
|
||||
it('leaves other anonymous rows alone when there is no identity to scope by', async () => {
|
||||
// With neither guest_id nor guest_identifier the collapse scope degrades
|
||||
// to `guest_identifier IS NULL` — every identifier-less row on the
|
||||
// photo, i.e. other people's.
|
||||
const anon = (extra) => ({
|
||||
photo_id: photoId, event_id: eventId, feedback_type: 'like',
|
||||
is_approved: true, created_at: new Date().toISOString(), ...extra,
|
||||
});
|
||||
const [h] = await db('photo_feedback').insert(anon({ is_hidden: true })).returning('id');
|
||||
const hiddenId = typeof h === 'object' ? h.id : h;
|
||||
await db('photo_feedback').insert(anon({ is_hidden: false }));
|
||||
await db('photo_feedback').insert(anon({ is_hidden: false }));
|
||||
|
||||
await feedbackService.moderateFeedback(hiddenId, 'approve', 1);
|
||||
|
||||
expect(await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false }))
|
||||
.toHaveLength(3);
|
||||
});
|
||||
|
||||
it('collapses the replacement when an admin unhides the original', async () => {
|
||||
await like();
|
||||
const original = await db('photo_feedback').where({ photo_id: photoId }).first();
|
||||
await db('photo_feedback').where('id', original.id).update({ is_hidden: true });
|
||||
|
||||
await feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
|
||||
});
|
||||
expect(await db('photo_feedback').where({ photo_id: photoId })).toHaveLength(2);
|
||||
|
||||
await feedbackService.moderateFeedback(original.id, 'approve', 1);
|
||||
|
||||
// Two visible rows for one guest would double-count in the tallies and
|
||||
// need two toggles to clear, since each deletes a single row.
|
||||
const visible = await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
|
||||
expect(visible).toHaveLength(1);
|
||||
expect(visible[0].id).toBe(original.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and clicking still works afterwards', () => {
|
||||
it('re-liking creates a fresh row instead of toggling the hidden one off', async () => {
|
||||
await like();
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
// What the guest sees is an empty heart, so this is an ADD.
|
||||
const result = await feedbackService.submitFeedback(photoId, eventId, {
|
||||
feedback_type: 'like',
|
||||
guest_identifier: ME,
|
||||
guest_id: myGuestRowId,
|
||||
});
|
||||
|
||||
// Before this, the duplicate check found the hidden row and deleted it —
|
||||
// `removed: true` — so the click did nothing visible and the moderation
|
||||
// was silently undone.
|
||||
expect(result.removed).toBeUndefined();
|
||||
|
||||
const visible = await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
|
||||
expect(visible).toHaveLength(1);
|
||||
expect((await getPhoto()).is_liked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Cross-engine .picpeak restore policy (#1041): a SQLite archive restored onto
|
||||
* a PostgreSQL instance — the official small-install → full-stack upgrade
|
||||
* path — now allowed by validateManifest's direction rule instead of the
|
||||
* former CLI-only allowEngineSwitch flag. The coercion engine itself
|
||||
* (typedColumnsFor / epochToIso / coerceForTargetEngine) landed with #1039;
|
||||
* these tests pin the direction policy and the coercion's cross-engine
|
||||
* value-correctness.
|
||||
*
|
||||
* Ungated: validateManifest direction rules and the pure coercion units.
|
||||
* The reverse direction (pg backup onto a sqlite instance) staying blocked is
|
||||
* pinned by picpeakRoundtrip.test.js, which runs on the real sqlite harness.
|
||||
*
|
||||
* Gated on PICPEAK_PG_TEST_URL (same contract as picpeakRestorePg.test.js):
|
||||
* sqlite-shaped NDJSON rows land in real Postgres with correct stored VALUES,
|
||||
* not just row counts, e.g.
|
||||
* PICPEAK_PG_TEST_URL="postgres://picpeak:pw@127.0.0.1:7102/picpeak_xengine_test" \
|
||||
* npx jest __tests__/integration/picpeakCrossEngine.test.js
|
||||
*/
|
||||
const knexLib = require('knex');
|
||||
|
||||
describe('validateManifest cross-engine direction (pg target)', () => {
|
||||
let validateManifest;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.resetModules();
|
||||
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
|
||||
// validateManifest wraps its knex_migrations lookup in try/catch — a
|
||||
// throwing stub simply skips the forward-only check, which is not under
|
||||
// test here.
|
||||
jest.doMock('../../src/database/db', () => ({ db: () => { throw new Error('stub'); } }));
|
||||
({ validateManifest } = require('../../src/services/picpeakImportService'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.dontMock('../../src/database/db');
|
||||
jest.dontMock('../../knexfile');
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('allows a sqlite backup onto a pg instance (upgrade direction)', async () => {
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1, database: { engine: 'sqlite' }, tables: {},
|
||||
});
|
||||
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still allows same-engine pg → pg', async () => {
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
|
||||
});
|
||||
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('epochToIso (landed with #1039)', () => {
|
||||
let epochToIso;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.resetModules();
|
||||
({ epochToIso } = require('../../src/services/picpeakImportService'));
|
||||
});
|
||||
|
||||
it('converts epoch milliseconds', () => {
|
||||
expect(epochToIso(1723400000000)).toBe('2024-08-11T18:13:20.000Z');
|
||||
});
|
||||
|
||||
it('converts epoch SECONDS to the same instant, not January 1970', () => {
|
||||
expect(epochToIso(1723400000)).toBe('2024-08-11T18:13:20.000Z');
|
||||
});
|
||||
|
||||
it('converts numeric strings', () => {
|
||||
expect(epochToIso('1723400000000')).toBe('2024-08-11T18:13:20.000Z');
|
||||
});
|
||||
|
||||
it('passes non-numeric values through untouched', () => {
|
||||
expect(epochToIso('2026-08-12 10:00:00')).toBe('2026-08-12 10:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('coerceForTargetEngine on sqlite-shaped rows', () => {
|
||||
let coerceForTargetEngine;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.resetModules();
|
||||
({ coerceForTargetEngine } = require('../../src/services/picpeakImportService'));
|
||||
});
|
||||
|
||||
const types = { timestamps: ['created_at', 'expires_at'], booleans: ['is_active'] };
|
||||
|
||||
it('coerces 0/1 booleans and epoch timestamps, leaves date strings alone', () => {
|
||||
const [row] = coerceForTargetEngine(
|
||||
[{ id: 1, is_active: 1, created_at: 1723400000000, expires_at: '2026-09-01 12:00:00' }],
|
||||
types
|
||||
);
|
||||
expect(row.is_active).toBe(true);
|
||||
expect(row.created_at).toBe('2024-08-11T18:13:20.000Z');
|
||||
expect(row.expires_at).toBe('2026-09-01 12:00:00'); // pg parses this natively
|
||||
});
|
||||
|
||||
it('coerces falsy variants and passes null/empty through', () => {
|
||||
const [row] = coerceForTargetEngine(
|
||||
[{ is_active: 0, created_at: null, expires_at: '' }],
|
||||
types
|
||||
);
|
||||
expect(row.is_active).toBe(false);
|
||||
expect(row.created_at).toBeNull();
|
||||
expect(row.expires_at).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Real-Postgres integration (gated) ────────────────────────────────────────
|
||||
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
||||
const maybe = PG_URL ? describe : describe.skip;
|
||||
|
||||
maybe('sqlite-shaped rows land correctly in real Postgres', () => {
|
||||
let pgDb;
|
||||
let svc;
|
||||
|
||||
beforeAll(async () => {
|
||||
pgDb = knexLib({ client: 'pg', connection: PG_URL });
|
||||
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
|
||||
await pgDb.schema.createTable('xengine_events', (t) => {
|
||||
t.increments('id');
|
||||
t.string('slug');
|
||||
t.boolean('is_active').defaultTo(true);
|
||||
t.boolean('allow_downloads').defaultTo(true);
|
||||
t.timestamp('created_at');
|
||||
t.timestamp('expires_at');
|
||||
});
|
||||
await pgDb.schema.createTable('xengine_settings', (t) => {
|
||||
t.increments('id');
|
||||
t.string('setting_key').notNullable().unique();
|
||||
t.jsonb('setting_value');
|
||||
});
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
|
||||
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
|
||||
svc = require('../../src/services/picpeakImportService');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.dontMock('../../src/database/db');
|
||||
jest.dontMock('../../knexfile');
|
||||
if (pgDb) {
|
||||
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
|
||||
await pgDb.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('typedColumnsFor classifies boolean and timestamp columns via columnInfo()', async () => {
|
||||
const types = await svc.typedColumnsFor(pgDb, 'xengine_events');
|
||||
expect(types.booleans.sort()).toEqual(['allow_downloads', 'is_active']);
|
||||
expect(types.timestamps.sort()).toEqual(['created_at', 'expires_at']);
|
||||
});
|
||||
|
||||
it('inserts a sqlite archive row (0/1 booleans, epoch dates, json text) with correct stored values', async () => {
|
||||
// Exactly what a sqlite-created .picpeak carries: integers for booleans,
|
||||
// epoch numbers for #485-shape timestamps (ms here, seconds covered by the
|
||||
// epochToIso unit), a "YYYY-MM-DD HH:MM:SS" string for clean ones, and
|
||||
// json columns as TEXT (the crossEngine path skips serialiseJsonColumns —
|
||||
// the text is already what pg wants).
|
||||
const epoch = 1723400000000;
|
||||
const eventRows = [
|
||||
{ id: 1, slug: 'wedding', is_active: 1, allow_downloads: 0, created_at: epoch, expires_at: '2026-09-01 12:00:00' },
|
||||
];
|
||||
const settingRows = [{ id: 1, setting_key: 'brand', setting_value: '{"name":"PicPeak","dark":true}' }];
|
||||
|
||||
await pgDb.transaction(async (trx) => {
|
||||
const evTypes = await svc.typedColumnsFor(trx, 'xengine_events');
|
||||
await trx.batchInsert('xengine_events', svc.coerceForTargetEngine(eventRows, evTypes), 100);
|
||||
const stTypes = await svc.typedColumnsFor(trx, 'xengine_settings');
|
||||
await trx.batchInsert('xengine_settings', svc.coerceForTargetEngine(settingRows, stTypes), 100);
|
||||
});
|
||||
|
||||
const ev = await pgDb('xengine_events').where({ id: 1 }).first();
|
||||
expect(ev.is_active).toBe(true); // 1 → true, not backwards (#1028 class)
|
||||
expect(ev.allow_downloads).toBe(false); // 0 → false
|
||||
expect(new Date(ev.created_at).getTime()).toBe(epoch);
|
||||
expect(new Date(ev.expires_at).toISOString().slice(0, 10)).toBe('2026-09-01');
|
||||
|
||||
const st = await pgDb('xengine_settings').where({ id: 1 }).first();
|
||||
// jsonb parsed back by the driver — value intact, no double encoding.
|
||||
expect(st.setting_value).toEqual({ name: 'PicPeak', dark: true });
|
||||
});
|
||||
|
||||
it('id sequence works after explicit-id insert + resync (next natural insert)', async () => {
|
||||
await svc.resyncSequences(['xengine_events']);
|
||||
const [next] = await pgDb('xengine_events')
|
||||
.insert({ slug: 'fresh', is_active: true })
|
||||
.returning('id');
|
||||
expect(Number(next.id || next)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* scripts/regenerate-thumbnails.js against external photos (#1148).
|
||||
*
|
||||
* The same defect #1129 fixed in the admin route, still standing in the CLI
|
||||
* fallback: the script resolved every source as
|
||||
* `storage/events/active/<photo.path>` and fs.access'd it. External and
|
||||
* reference rows do not live there — their originals sit under
|
||||
* `events.external_path` — so every one failed the check and was counted as an
|
||||
* error. On an install where all photos are external the script did nothing at
|
||||
* all, while reporting one error per photo.
|
||||
*
|
||||
* Driven against a REAL file on a REAL external mount with the real
|
||||
* imageProcessor, not a mock: the whole point is that the source resolves off
|
||||
* the mount, and a mocked ensureThumbnail would assert nothing about that.
|
||||
*
|
||||
* Responsive tiers (#1095/#1109) do not exist on this branch, so the tier
|
||||
* backfill in the main twin has nothing to port. Everything else does.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const sharp = require('sharp');
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
describe('regenerate-thumbnails script (#1148)', () => {
|
||||
let tmpDir; let db; let cleanup; let regenerateThumbnails;
|
||||
let eventId; let externalPhotoId; let videoPhotoId; let watcherVideoId; let repairPhotoId;
|
||||
let vanishingPhotoId;
|
||||
let externalRoot;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-script-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
// External sources are sandboxed under EXTERNAL_MEDIA_ROOT; event
|
||||
// external_path is relative to it, exactly as on a real install.
|
||||
process.env.EXTERNAL_MEDIA_ROOT = path.join(tmpDir, 'media');
|
||||
externalRoot = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'wedding');
|
||||
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
|
||||
await fs.promises.mkdir(externalRoot, { recursive: true });
|
||||
|
||||
jest.resetModules();
|
||||
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
// A real image on the external mount — never under events/active.
|
||||
await sharp({
|
||||
create: { width: 1200, height: 800, channels: 3, background: { r: 10, g: 90, b: 160 } },
|
||||
}).jpeg().toFile(path.join(externalRoot, 'shot.jpg'));
|
||||
|
||||
const [ev] = await db('events').insert({
|
||||
slug: 'regen-script-event',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Regen Script',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: '/gallery/regen-script-event/share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
source_mode: 'reference',
|
||||
external_path: 'wedding',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = typeof ev === 'object' ? ev.id : ev;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'shot.jpg',
|
||||
// `path` is what the old script joined onto events/active. Left
|
||||
// populated on purpose: the fix must ignore it for an external row.
|
||||
path: 'regen-script-event/shot.jpg',
|
||||
type: 'individual',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'shot.jpg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
externalPhotoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const [v] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'clip.mp4',
|
||||
path: 'regen-script-event/clip.mp4',
|
||||
type: 'individual',
|
||||
media_type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'clip.mp4',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
videoPhotoId = typeof v === 'object' ? v.id : v;
|
||||
|
||||
// How fileWatcher.processNewPhoto actually writes a video: `type` and
|
||||
// `mime_type` set, media_type left to its 'image' default. A media_type-only
|
||||
// filter lets this through and hands the container to Sharp.
|
||||
//
|
||||
// The file has to EXIST, otherwise the row fails resolution and looks
|
||||
// skipped for the wrong reason — the bug is Sharp being handed a video, not
|
||||
// a missing source. Real MP4 header bytes, no image in sight.
|
||||
await fs.promises.writeFile(
|
||||
path.join(externalRoot, 'watched.mp4'),
|
||||
Buffer.from('00000018667479706d70343200000000', 'hex')
|
||||
);
|
||||
const [wv] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'watched.mp4',
|
||||
path: 'regen-script-event/watched.mp4',
|
||||
type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'watched.mp4',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
watcherVideoId = typeof wv === 'object' ? wv.id : wv;
|
||||
expect((await db('photos').where('id', watcherVideoId).first()).media_type).not.toBe('video');
|
||||
|
||||
// A photo whose thumbnail_path points at something that is no longer there.
|
||||
await sharp({
|
||||
create: { width: 900, height: 600, channels: 3, background: { r: 200, g: 40, b: 40 } },
|
||||
}).jpeg().toFile(path.join(externalRoot, 'repair.jpg'));
|
||||
const [rp] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'repair.jpg',
|
||||
path: 'regen-script-event/repair.jpg',
|
||||
type: 'individual',
|
||||
thumbnail_path: 'thumbnails/thumb_ext_missing_repair.jpg',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'repair.jpg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
repairPhotoId = typeof rp === 'object' ? rp.id : rp;
|
||||
|
||||
// A photo whose source is not on the mount at all — an unavailable mount,
|
||||
// which is the failure an operator most needs to hear about.
|
||||
const [vp] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'missing.jpg',
|
||||
path: 'regen-script-event/missing.jpg',
|
||||
type: 'individual',
|
||||
source_origin: 'external',
|
||||
external_relpath: 'missing.jpg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
vanishingPhotoId = typeof vp === 'object' ? vp.id : vp;
|
||||
|
||||
({ regenerateThumbnails } = require('../../scripts/regenerate-thumbnails'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it('builds a thumbnail for an external photo instead of erroring on events/active', async () => {
|
||||
// The location the old script computed and fs.access'd. Nothing is there,
|
||||
// which is the whole defect — it is not where an external original lives.
|
||||
// (The old script cannot be driven from a test directly: it had no export
|
||||
// and ran on require, calling process.exit. Making it importable is part
|
||||
// of this fix.)
|
||||
const legacyPath = path.join(process.env.STORAGE_PATH, 'events/active', 'regen-script-event/shot.jpg');
|
||||
expect(fs.existsSync(legacyPath)).toBe(false);
|
||||
|
||||
const result = await regenerateThumbnails(eventId);
|
||||
|
||||
// The old script reported an error for this photo and wrote nothing.
|
||||
// The unresolvable row fails; the external photo and the repair row build.
|
||||
expect(result.errorCount).toBe(1);
|
||||
expect(result.successCount).toBe(2);
|
||||
|
||||
const row = await db('photos').where('id', externalPhotoId).first();
|
||||
expect(row.thumbnail_path).toBeTruthy();
|
||||
const onDisk = path.join(process.env.STORAGE_PATH, row.thumbnail_path);
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
|
||||
// Named per-photo so two events referencing one NAS basename cannot
|
||||
// clobber each other — the property ensureThumbnail owns and the reason
|
||||
// the script must not build this name itself.
|
||||
expect(path.basename(row.thumbnail_path)).toContain(`ext${externalPhotoId}_`);
|
||||
});
|
||||
|
||||
it('leaves videos alone', async () => {
|
||||
// A video thumbnail is a poster frame from videoProcessor; handing the
|
||||
// container to Sharp produced one error per video row.
|
||||
const row = await db('photos').where('id', videoPhotoId).first();
|
||||
expect(row.thumbnail_path).toBeFalsy();
|
||||
});
|
||||
|
||||
it('leaves a watcher-imported video alone, which carries no media_type', async () => {
|
||||
// fileWatcher writes type + mime_type and lets media_type default to
|
||||
// 'image', so filtering on media_type alone still fed these to Sharp. The
|
||||
// signal is errorCount: the images are already done by now, so the only
|
||||
// NEW thing that could fail this run is a video reaching Sharp. One error
|
||||
// is the deliberately unresolvable row; two would be the video.
|
||||
const result = await regenerateThumbnails(eventId);
|
||||
|
||||
expect(result.errorCount).toBe(1);
|
||||
const row = await db('photos').where('id', watcherVideoId).first();
|
||||
expect(row.thumbnail_path).toBeFalsy();
|
||||
});
|
||||
|
||||
it('is idempotent — a second run skips instead of rebuilding', async () => {
|
||||
const before = await db('photos').where('id', externalPhotoId).first();
|
||||
const result = await regenerateThumbnails(eventId);
|
||||
|
||||
expect(result.errorCount).toBe(1);
|
||||
expect(result.successCount).toBe(0);
|
||||
expect(result.skipCount).toBe(2);
|
||||
|
||||
const after = await db('photos').where('id', externalPhotoId).first();
|
||||
expect(after.thumbnail_path).toBe(before.thumbnail_path);
|
||||
});
|
||||
|
||||
it('counts a repaired thumbnail as generated, not skipped', async () => {
|
||||
// Both images are valid at this point. Destroy ONE thumbnail object while
|
||||
// leaving thumbnail_path pointing at it — the corrupt/missing case.
|
||||
const row = await db('photos').where('id', repairPhotoId).first();
|
||||
const onDisk = path.join(process.env.STORAGE_PATH, row.thumbnail_path);
|
||||
await fs.promises.rm(onDisk);
|
||||
|
||||
const result = await regenerateThumbnails(eventId);
|
||||
|
||||
// On local and external storage the rebuilt key is identical, so inferring
|
||||
// "skipped" from an unchanged path reports this repair as already valid —
|
||||
// the one number an operator running this is actually reading.
|
||||
expect(result.successCount).toBe(1);
|
||||
expect(result.skipCount).toBe(1);
|
||||
expect(result.errorCount).toBe(1);
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
});
|
||||
|
||||
/** Run the CLI the way cron does, and hand back its exit status. */
|
||||
const runCli = (args = []) => new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[path.join(__dirname, '..', '..', 'scripts', 'regenerate-thumbnails.js'), ...args],
|
||||
{ env: { ...process.env }, cwd: path.join(__dirname, '..', '..') },
|
||||
(error, stdout, stderr) => resolve({ code: error?.code ?? 0, stdout, stderr })
|
||||
);
|
||||
});
|
||||
|
||||
it('exits nonzero when a photo could not be built', async () => {
|
||||
// Exit status is the only thing a cron job reads, and `missing.jpg` has no
|
||||
// source on the mount.
|
||||
const failed = await runCli([String(eventId)]);
|
||||
expect(failed.code).toBe(1);
|
||||
expect(failed.stderr).toContain('completed with failures');
|
||||
}, 120000);
|
||||
|
||||
it('exits zero when every photo resolves', async () => {
|
||||
// Drop the unresolvable row: a clean run must not cry wolf at automation.
|
||||
await db('photos').where('id', vanishingPhotoId).del();
|
||||
const ok = await runCli([String(eventId)]);
|
||||
expect(ok.code).toBe(0);
|
||||
expect(ok.stdout).toContain('Script completed successfully');
|
||||
}, 120000);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Slideshow photo source (#1015).
|
||||
*
|
||||
* The bug: with `lightbox_preview_enabled` off (the default), /photos emitted
|
||||
* `preview_url: null`, so the slideshow's `preview_url || hero_url || url`
|
||||
* chain fell through to `hero_url` — a 1920x1080 `fit: 'cover'` centre crop
|
||||
* meant for gallery header banners. With the "Black Bars (No crop)" fit the
|
||||
* show then letterboxed an already-cropped frame: portrait photos lost their
|
||||
* top and bottom and the setting looked broken.
|
||||
*
|
||||
* The contract pinned here: `slideshow_url` points at the aspect-preserved
|
||||
* preview tier and is emitted for image photos REGARDLESS of the lightbox
|
||||
* toggle, so the slideshow never has a reason to reach for `hero_url`.
|
||||
* `preview_url` itself must stay gated — the lightbox opt-in is unchanged.
|
||||
*/
|
||||
|
||||
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 || 'slideshow-src-test-secret';
|
||||
|
||||
const SLUG = 'slideshow-source-event';
|
||||
|
||||
describe('Slideshow photo source (#1015)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let eventId;
|
||||
let imagePhotoId;
|
||||
let videoPhotoId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const setLightboxPreview = async (on) => {
|
||||
await db('app_settings').where({ setting_key: 'lightbox_preview_enabled' }).del();
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'lightbox_preview_enabled',
|
||||
setting_value: JSON.stringify(on),
|
||||
setting_type: 'general',
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const fetchPhotos = async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.expect(200);
|
||||
return res.body.photos;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const inserted = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Slideshow Source 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: 'slideshow-source-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 img = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'portrait.jpg',
|
||||
path: 'events/slideshow-source/portrait.jpg',
|
||||
type: 'individual',
|
||||
mime_type: 'image/jpeg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
imagePhotoId = img[0]?.id ?? img[0];
|
||||
|
||||
const vid = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'clip.mp4',
|
||||
path: 'events/slideshow-source/clip.mp4',
|
||||
type: 'individual',
|
||||
media_type: 'video',
|
||||
mime_type: 'video/mp4',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
videoPhotoId = vid[0]?.id ?? vid[0];
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('emits slideshow_url for image photos even when lightbox previews are OFF', async () => {
|
||||
await setLightboxPreview(false);
|
||||
const photos = await fetchPhotos();
|
||||
const image = photos.find((p) => p.id === imagePhotoId);
|
||||
|
||||
expect(image.slideshow_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
|
||||
// The regression: this is what used to be null, pushing the show to hero.
|
||||
expect(image.preview_url).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves preview_url gated so the lightbox opt-in is unchanged', async () => {
|
||||
await setLightboxPreview(true);
|
||||
const photos = await fetchPhotos();
|
||||
const image = photos.find((p) => p.id === imagePhotoId);
|
||||
|
||||
expect(image.preview_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
|
||||
expect(image.slideshow_url).toBe(image.preview_url);
|
||||
});
|
||||
|
||||
it('never points the slideshow at the cover-cropped hero tier', async () => {
|
||||
await setLightboxPreview(false);
|
||||
const photos = await fetchPhotos();
|
||||
const image = photos.find((p) => p.id === imagePhotoId);
|
||||
|
||||
// hero_url still ships (the gallery header uses it) — it just must not be
|
||||
// what the slideshow resolves to.
|
||||
expect(image.hero_url).toBe(`/api/gallery/${SLUG}/hero/${imagePhotoId}`);
|
||||
expect(image.slideshow_url).not.toBe(image.hero_url);
|
||||
});
|
||||
|
||||
it('emits slideshow_url: null for videos, which have no preview tier', async () => {
|
||||
await setLightboxPreview(false);
|
||||
const photos = await fetchPhotos();
|
||||
const video = photos.find((p) => p.id === videoPhotoId);
|
||||
|
||||
expect(video.slideshow_url).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Repairing the bundled templates' fixed image height (#1131).
|
||||
*
|
||||
* The risk in a migration that rewrites user-visible CSS is doing too much,
|
||||
* so most of what is pinned here is what it must NOT touch: the other pixel
|
||||
* heights inside the very same templates (a 1px divider, an 8px scrollbar),
|
||||
* and any rule a user wrote themselves.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const migration = require('../../migrations/core/175_fix_css_template_photo_height');
|
||||
|
||||
const ELEGANT_DARK = `
|
||||
.photo-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.photo-card img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
`;
|
||||
|
||||
const LIQUID_GLASS_DARK = `
|
||||
.gallery-page::after {
|
||||
content: '';
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, #fff, transparent);
|
||||
}
|
||||
|
||||
.photo-card img {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
object-fit: cover;
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.gallery-page ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.photo-card img {
|
||||
height: 180px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
describe('migration 175 — CSS template image height (#1131)', () => {
|
||||
let knex; let tmpDir;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig175-'));
|
||||
knex = require('knex')({
|
||||
client: 'sqlite3',
|
||||
connection: { filename: path.join(tmpDir, 'db.sqlite') },
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
await knex.schema.createTable('css_templates', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('name');
|
||||
t.text('css_content');
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (knex) await knex.destroy();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => { await knex('css_templates').del(); });
|
||||
|
||||
const contentOf = async (name) =>
|
||||
(await knex('css_templates').where({ name }).first()).css_content;
|
||||
|
||||
it('relaxes the default template so the layouts h-full can win', async () => {
|
||||
await knex('css_templates').insert({ name: 'Elegant Dark', css_content: ELEGANT_DARK });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('Elegant Dark');
|
||||
expect(css).toContain('height: 100%');
|
||||
expect(css).not.toContain('height: 200px');
|
||||
// Everything else about the rule survives.
|
||||
expect(css).toContain('object-fit: cover');
|
||||
expect(css).toContain('transition: transform 0.3s ease');
|
||||
});
|
||||
|
||||
it('fixes both the base rule and the mobile override of the dark glass template', async () => {
|
||||
await knex('css_templates').insert({ name: 'Liquid Glass Dark', css_content: LIQUID_GLASS_DARK });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('Liquid Glass Dark');
|
||||
expect(css).not.toContain('height: 240px');
|
||||
expect(css).not.toContain('height: 180px');
|
||||
expect(css.match(/height: 100%/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('leaves the divider and the scrollbar alone', async () => {
|
||||
await knex('css_templates').insert({ name: 'Liquid Glass Dark', css_content: LIQUID_GLASS_DARK });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
// The whole reason this matches full rule bodies rather than every
|
||||
// `height: <n>px`: these are in the same stylesheet and are correct.
|
||||
const css = await contentOf('Liquid Glass Dark');
|
||||
expect(css).toContain('height: 1px');
|
||||
expect(css).toContain('width: 8px');
|
||||
expect(css).toContain('height: 8px');
|
||||
});
|
||||
|
||||
/**
|
||||
* The case that forced the scope wider. `sanitizeCSS` strips control
|
||||
* characters, so any template ever saved through the editor — including a
|
||||
* save that only changed its name — has had every newline REMOVED. An
|
||||
* exact-text migration finds nothing on those installs, is recorded as
|
||||
* applied, and leaves them broken permanently.
|
||||
*/
|
||||
it('fixes a template that has been through the editor, newlines and all', async () => {
|
||||
const { sanitizeCSS } = require('../../src/utils/cssSanitizer');
|
||||
const { sanitized } = sanitizeCSS(ELEGANT_DARK);
|
||||
// Precondition: the sanitizer really did flatten it.
|
||||
expect(sanitized).not.toContain('\n');
|
||||
expect(sanitized).toContain('height: 200px');
|
||||
await knex('css_templates').insert({ name: 'Saved Once', css_content: sanitized });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('Saved Once');
|
||||
expect(css).not.toContain('200px');
|
||||
expect(css).toContain('height: 100%');
|
||||
});
|
||||
|
||||
it('relaxes a user-authored fixed height too, but only on .photo-card img', async () => {
|
||||
// Deliberately broader than the seeded text — see the migration header. A
|
||||
// pixel height on the image cannot be right under any of the seven
|
||||
// layouts, whoever wrote it; a height anywhere else is none of our
|
||||
// business.
|
||||
const mine = '.photo-card img {\n height: 220px;\n}\n.hero { height: 400px; }';
|
||||
await knex('css_templates').insert({ name: 'My Own', css_content: mine });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('My Own');
|
||||
expect(css).toContain('height: 100%');
|
||||
expect(css).not.toContain('220px');
|
||||
expect(css).toContain('.hero { height: 400px; }');
|
||||
});
|
||||
|
||||
it('does not rewrite other properties that merely end in -height', async () => {
|
||||
// `line-height: 200px` contains `height: 200px` as a substring, so an
|
||||
// unanchored pattern silently rewrites it — in a migration that cannot be
|
||||
// undone.
|
||||
const mine = [
|
||||
'.photo-card img {',
|
||||
' line-height: 200px;',
|
||||
' max-height: 300px;',
|
||||
' min-height: 14px;',
|
||||
' --tile-height: 220px;',
|
||||
' height: 200px;',
|
||||
'}',
|
||||
].join('\n');
|
||||
await knex('css_templates').insert({ name: 'Adjacent Props', css_content: mine });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('Adjacent Props');
|
||||
expect(css).toContain('line-height: 200px');
|
||||
expect(css).toContain('max-height: 300px');
|
||||
expect(css).toContain('min-height: 14px');
|
||||
expect(css).toContain('--tile-height: 220px');
|
||||
// Only the real one moved.
|
||||
expect(css).toContain('height: 100%');
|
||||
expect(css).not.toMatch(/(?<![\w-])height:\s*200px/);
|
||||
});
|
||||
|
||||
it('handles a grouped selector list', async () => {
|
||||
// Requiring `{` straight after `img` skipped these entirely — and the
|
||||
// migration is still recorded as applied, so the template kept the bug.
|
||||
const mine = '.photo-card img, .thumbnail img {\n height: 200px;\n}';
|
||||
await knex('css_templates').insert({ name: 'Grouped', css_content: mine });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
const css = await contentOf('Grouped');
|
||||
expect(css).toContain('.photo-card img, .thumbnail img {');
|
||||
expect(css).toContain('height: 100%');
|
||||
expect(css).not.toContain('200px');
|
||||
});
|
||||
|
||||
it('skips a nested rule rather than rewriting the wrong declaration', async () => {
|
||||
// Valid nested CSS that passes the validator. A brace-greedy body would
|
||||
// capture the inner block and rewrite the CAPTION's height, which cannot
|
||||
// be undone. Leaving it untouched is the lesser evil.
|
||||
const mine = '.photo-card img {\n & + .caption { height: 200px; }\n}';
|
||||
await knex('css_templates').insert({ name: 'Nested', css_content: mine });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await contentOf('Nested')).toBe(mine);
|
||||
});
|
||||
|
||||
it('leaves non-pixel heights on the image alone', async () => {
|
||||
const mine = '.photo-card img { height: 50vh; }\n.photo-card img { height: auto; }';
|
||||
await knex('css_templates').insert({ name: 'Relative', css_content: mine });
|
||||
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await contentOf('Relative')).toBe(mine);
|
||||
});
|
||||
|
||||
it('is idempotent and safe on a row with no CSS', async () => {
|
||||
await knex('css_templates').insert([
|
||||
{ name: 'Elegant Dark', css_content: ELEGANT_DARK },
|
||||
{ name: 'Empty', css_content: null },
|
||||
]);
|
||||
|
||||
await migration.up(knex);
|
||||
const once = await contentOf('Elegant Dark');
|
||||
await migration.up(knex);
|
||||
|
||||
expect(await contentOf('Elegant Dark')).toBe(once);
|
||||
expect(await contentOf('Empty')).toBeNull();
|
||||
});
|
||||
|
||||
it('no-ops when the table does not exist yet', async () => {
|
||||
await knex.schema.dropTable('css_templates');
|
||||
await expect(migration.up(knex)).resolves.toBeUndefined();
|
||||
await knex.schema.createTable('css_templates', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('name');
|
||||
t.text('css_content');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Source-inspection contract test for #1078.
|
||||
*
|
||||
* POST /api/admin/thumbnails/regenerate-previews hands its selected rows to
|
||||
* ensurePreviewImage, which branches on `source_origin` (and then reads
|
||||
* `external_relpath` / `filename`) to reach an external/reference photo on its
|
||||
* media mount. When the select list omitted those columns, every external row
|
||||
* looked managed, resolvePhotoStorageKey returned null, and the endpoint
|
||||
* reported success while silently generating nothing for reference galleries.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
describe('regenerate-previews selects the columns ensurePreviewImage branches on (#1078)', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'src', 'routes', 'adminThumbnails.js'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// The select feeding the regenerate-previews handler, from the route
|
||||
// declaration to the end of that statement.
|
||||
const selectStatement = (() => {
|
||||
const routeIdx = src.indexOf('/regenerate-previews');
|
||||
expect(routeIdx).toBeGreaterThan(-1);
|
||||
const selectIdx = src.indexOf('.select(', routeIdx);
|
||||
expect(selectIdx).toBeGreaterThan(-1);
|
||||
return src.slice(selectIdx, src.indexOf(';', selectIdx));
|
||||
})();
|
||||
|
||||
it.each(['source_origin', 'external_relpath', 'filename'])(
|
||||
'selects %s',
|
||||
(column) => {
|
||||
expect(selectStatement).toContain(`'${column}'`);
|
||||
}
|
||||
);
|
||||
|
||||
it('still selects the columns the managed path needs', () => {
|
||||
for (const column of ['id', 'event_id', 'path', 'media_type', 'mime_type', 'preview_path']) {
|
||||
expect(selectStatement).toContain(`'${column}'`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -135,9 +135,9 @@ function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
|
||||
);
|
||||
}
|
||||
|
||||
function signGalleryToken({ eventId = 100, eventSlug = 'wedding' } = {}) {
|
||||
function signGalleryToken({ eventId = 100, eventSlug = 'wedding', ...extra } = {}) {
|
||||
return jwt.sign(
|
||||
{ eventId, eventSlug, type: 'gallery' },
|
||||
{ eventId, eventSlug, type: 'gallery', ...extra },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
@@ -288,6 +288,56 @@ describe('GET /auth/session — symmetry with protected middleware', () => {
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* What KIND of gallery session this is (#1149).
|
||||
*
|
||||
* The frontend used to keep this in sessionStorage, which is per-TAB while
|
||||
* the cookie is per-browser: a gallery reopened in a second tab lost
|
||||
* 'client' even though the backend still served it as one, and the UI hid
|
||||
* the only control that clears the privileged cookie. Reported from the
|
||||
* token so a restored session knows what it actually is.
|
||||
*/
|
||||
describe('gallery session kind', () => {
|
||||
beforeEach(() => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a PIN-client session as client', async () => {
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${signGalleryToken({ accessLevel: 'client' })}`);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.accessLevel).toBe('client');
|
||||
expect(res.body.viaCustomer).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a customer-portal session, which looks like a guest', async () => {
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${signGalleryToken({ via: 'customer', customerId: 7 })}`);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.accessLevel).toBe('guest');
|
||||
expect(res.body.viaCustomer).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a plain guest as neither', async () => {
|
||||
// The flags have to discriminate, or they would just hand every visitor
|
||||
// a Logout button back.
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${signGalleryToken()}`);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.accessLevel).toBe('guest');
|
||||
expect(res.body.viaCustomer).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns valid:false when the token is revoked', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* SQLite boolean coercion in the guest gallery surface (#1028).
|
||||
*
|
||||
* SQLite stores booleans as 0/1; Postgres stores true/false. The /photos
|
||||
* payload and every download guard compared strictly against `true`/`false`,
|
||||
* so on SQLite:
|
||||
*
|
||||
* allow_downloads: 0 !== false → true (button shown while disabled)
|
||||
* allow_user_uploads: 1 === true → false (button hidden while enabled)
|
||||
* if (allow_downloads === false) → never fires, so ALL download endpoints
|
||||
* kept serving with downloads switched off
|
||||
*
|
||||
* (The download-jobs route asserted on main is #858, which is beta-only —
|
||||
* this branch covers the three download endpoints that exist here.)
|
||||
*
|
||||
* The harness runs on SQLite, so these assertions exercise the real engine
|
||||
* values rather than a mock. Every test here fails on the unfixed code.
|
||||
*/
|
||||
|
||||
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-sqlite-flags-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sqlite-flags-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sqlite-flags-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
const SLUG = 'sqlite-flags-gallery';
|
||||
|
||||
describe('gallery flags survive SQLite 0/1 storage (#1028)', () => {
|
||||
let db; let cleanup; let app; let eventId; let photoId;
|
||||
|
||||
async function setEventFlags(patch) {
|
||||
await db('events').where('id', eventId).update(patch);
|
||||
}
|
||||
|
||||
async function getPayload() {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
|
||||
expect(res.status).toBe(200);
|
||||
return res.body.event;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const ev = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'SQLite Flags',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/s`,
|
||||
share_token: 'sqlite-flags-share',
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
// Password-free so verifyGalleryAccess takes the public path and loads
|
||||
// the row with SELECT * — i.e. the raw 0/1 values, same as production.
|
||||
require_password: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = ev[0]?.id ?? ev[0];
|
||||
|
||||
const ph = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'p.jpg',
|
||||
path: `${SLUG}/p.jpg`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
photoId = ph[0]?.id ?? ph[0];
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
test('the engine under test really is SQLite storing 0/1', async () => {
|
||||
expect(['sqlite3', 'better-sqlite3']).toContain(db.client.config.client);
|
||||
await setEventFlags({ allow_downloads: 0 });
|
||||
const row = await db('events').where('id', eventId).first('allow_downloads');
|
||||
expect(row.allow_downloads).toBe(0);
|
||||
});
|
||||
|
||||
describe('with downloads disabled (allow_downloads = 0)', () => {
|
||||
beforeAll(async () => {
|
||||
await setEventFlags({ allow_downloads: 0, allow_user_uploads: 1 });
|
||||
});
|
||||
|
||||
test('payload reports allow_downloads false (was true — header button shown)', async () => {
|
||||
expect((await getPayload()).allow_downloads).toBe(false);
|
||||
});
|
||||
|
||||
test('payload reports allow_user_uploads true (was false — upload button hidden)', async () => {
|
||||
expect((await getPayload()).allow_user_uploads).toBe(true);
|
||||
});
|
||||
|
||||
test('single-photo download is refused', async () => {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test('download-all is refused', async () => {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test('download-selected is refused', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/gallery/${SLUG}/download-selected`)
|
||||
.send({ photo_ids: [photoId] });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with downloads enabled (allow_downloads = 1)', () => {
|
||||
beforeAll(async () => {
|
||||
await setEventFlags({ allow_downloads: 1, allow_user_uploads: 0 });
|
||||
});
|
||||
|
||||
test('payload reports allow_downloads true / allow_user_uploads false', async () => {
|
||||
const event = await getPayload();
|
||||
expect(event.allow_downloads).toBe(true);
|
||||
expect(event.allow_user_uploads).toBe(false);
|
||||
});
|
||||
|
||||
test('download-all is no longer refused', async () => {
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`);
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('protection flags', () => {
|
||||
test('0/1 protection toggles are reported the way they are stored', async () => {
|
||||
await setEventFlags({
|
||||
disable_right_click: 1,
|
||||
enable_devtools_protection: 1,
|
||||
use_canvas_rendering: 1,
|
||||
watermark_downloads: 1,
|
||||
overlay_protection: 0,
|
||||
});
|
||||
const event = await getPayload();
|
||||
expect(event.disable_right_click).toBe(true);
|
||||
expect(event.enable_devtools_protection).toBe(true);
|
||||
expect(event.use_canvas_rendering).toBe(true);
|
||||
expect(event.watermark_downloads).toBe(true);
|
||||
expect(event.overlay_protection).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-category download blocking (#640) on SQLite', () => {
|
||||
test('a category with allow_downloads = 0 is reported as blocked', async () => {
|
||||
const cat = await db('photo_categories').insert({
|
||||
name: 'Blocked', slug: 'blocked', event_id: eventId, is_global: 0, allow_downloads: 0,
|
||||
}).returning('id');
|
||||
const categoryId = cat[0]?.id ?? cat[0];
|
||||
await db('photos').where('id', photoId).update({ category_id: categoryId });
|
||||
|
||||
await setEventFlags({ allow_downloads: 1 });
|
||||
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const category = res.body.categories.find((c) => c.id === categoryId);
|
||||
expect(category.allow_downloads).toBe(false);
|
||||
const photo = res.body.photos.find((p) => p.id === photoId);
|
||||
expect(photo.category_allow_downloads).toBe(false);
|
||||
|
||||
// …and the per-category guard on the single-photo route fires.
|
||||
const dl = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
|
||||
expect(dl.status).toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Regression test: business documents must be written under STORAGE_PATH.
|
||||
*
|
||||
* quoteService.persistDocPdf, the invoice sending/reminder writers and the
|
||||
* contract signature writers all built their target from
|
||||
* `path.join(process.cwd(), 'storage', 'business-docs', ...)`. Both compose
|
||||
* files pin STORAGE_PATH=/app/storage and the image's WORKDIR is /app, so the
|
||||
* two expressions name the same directory and the bug was invisible on a stock
|
||||
* deployment. Point STORAGE_PATH anywhere else — a NAS mount, a second disk,
|
||||
* the single-container image's /data volume — and quotes, invoices, Mahnungen
|
||||
* and contract PDFs were written outside the configured storage root, so they
|
||||
* were missed by backups and lost when the container was replaced.
|
||||
*
|
||||
* Rather than assert on internals, this drives the module boundary the fix
|
||||
* changed: getStoragePath() is the one resolver, so a temporary STORAGE_PATH
|
||||
* must be where the bytes land.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
describe('business documents honour STORAGE_PATH', () => {
|
||||
let tmpRoot;
|
||||
let originalStoragePath;
|
||||
|
||||
beforeEach(() => {
|
||||
originalStoragePath = process.env.STORAGE_PATH;
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-storage-'));
|
||||
process.env.STORAGE_PATH = tmpRoot;
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalStoragePath === undefined) delete process.env.STORAGE_PATH;
|
||||
else process.env.STORAGE_PATH = originalStoragePath;
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('getStoragePath is the resolver the writers share', () => {
|
||||
const { getStoragePath } = require('../../src/config/storage');
|
||||
expect(getStoragePath()).toBe(tmpRoot);
|
||||
});
|
||||
|
||||
it('no business-document writer still targets process.cwd()/storage', () => {
|
||||
// Whitespace is collapsed before matching on purpose. The first version of
|
||||
// this test compared against the single-line literal and therefore missed
|
||||
// persistSignatureImage(), whose identical path.join was simply spread over
|
||||
// seven lines — it reported green while signature PNGs still wrote outside
|
||||
// STORAGE_PATH. Formatting must not decide whether a bug is visible.
|
||||
const writers = [
|
||||
'src/services/quoteService.js',
|
||||
'src/services/invoice/sending.js',
|
||||
'src/services/invoice/reminders.js',
|
||||
'src/services/contract/signatureAssets.js',
|
||||
'src/routes/adminDev.js',
|
||||
];
|
||||
const offenders = writers.filter((rel) => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../', rel), 'utf8');
|
||||
return /process\.cwd\(\),'storage'/.test(source.replace(/\s+/g, ''));
|
||||
});
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('generated contract PDFs pass the containment check that serves them', () => {
|
||||
// assertContractPdfPath guards the admin and public contract download
|
||||
// routes. It listed only <cwd>/storage/business-docs/contract, so once the
|
||||
// writers moved to STORAGE_PATH every freshly generated contract was
|
||||
// refused with PATH_OUTSIDE_STORAGE — a worse failure than the bug being
|
||||
// fixed. Both roots must be accepted.
|
||||
const { assertContractPdfPath } = require('../../src/utils/safePath');
|
||||
const { getStoragePath } = require('../../src/config/storage');
|
||||
|
||||
// assertPathInside realpaths both the file and each root, so the guard only
|
||||
// means anything against a filesystem that actually has them — write them.
|
||||
const write = (...segments) => {
|
||||
const p = path.join(getStoragePath(), 'business-docs', 'contract', ...segments);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, 'bytes');
|
||||
return p;
|
||||
};
|
||||
|
||||
const generated = write('2026', 'C-2026-0001.pdf');
|
||||
expect(() => assertContractPdfPath(generated)).not.toThrow();
|
||||
|
||||
// Signature PNGs live under the same root and are served by the same guard.
|
||||
const signature = write('signatures', '7', 'customer-1.png');
|
||||
expect(() => assertContractPdfPath(signature)).not.toThrow();
|
||||
|
||||
// And the guard still refuses a real file outside every allowed root.
|
||||
const foreign = path.join(tmpRoot, 'outside.pdf');
|
||||
fs.writeFileSync(foreign, 'bytes');
|
||||
expect(() => assertContractPdfPath(foreign)).toThrow(/outside the storage roots/i);
|
||||
});
|
||||
|
||||
it('the guard takes its root from the shared resolver, not its own fallback', () => {
|
||||
// The regression this pins: the guard used to compute
|
||||
// `STORAGE_PATH || <cwd>/storage` itself. That agrees with getStoragePath()
|
||||
// only while STORAGE_PATH is set — unset, the shared resolver falls back
|
||||
// module-relative to <repo>/storage while the guard fell back to
|
||||
// <cwd>/storage, and the backend is normally started from backend/. Writers
|
||||
// and guard then disagreed and contract downloads 403'd.
|
||||
//
|
||||
// Mocking the resolver is what makes this provable AND safe. If the guard
|
||||
// consumes getStoragePath(), the mock moves its root; if it rolled its own
|
||||
// expression, the mock would have no effect and the assertion fails. It
|
||||
// also keeps every path inside the tmpdir — an earlier version of this test
|
||||
// deleted `<resolved root>/business-docs` in cleanup, which with
|
||||
// STORAGE_PATH unset resolves to a developer's real, gitignored
|
||||
// <repo>/storage and would have destroyed local documents on `npm test`.
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/config/storage', () => ({ getStoragePath: () => tmpRoot }));
|
||||
|
||||
const { assertContractPdfPath } = require('../../src/utils/safePath');
|
||||
|
||||
const root = path.join(tmpRoot, 'business-docs', 'contract', '2026');
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const generated = path.join(root, 'C-2026-0002.pdf');
|
||||
fs.writeFileSync(generated, 'bytes');
|
||||
|
||||
expect(() => assertContractPdfPath(generated)).not.toThrow();
|
||||
|
||||
jest.dontMock('../../src/config/storage');
|
||||
});
|
||||
|
||||
it('writes land under STORAGE_PATH, not the working directory', () => {
|
||||
const { getStoragePath } = require('../../src/config/storage');
|
||||
|
||||
// Mirror what persistDocPdf does: derive the root, create it, write.
|
||||
const root = path.join(getStoragePath(), 'business-docs', 'quote', '2026');
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const filePath = path.join(root, 'Q-2026-0001.pdf');
|
||||
fs.writeFileSync(filePath, 'pdf-bytes');
|
||||
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
expect(filePath.startsWith(tmpRoot)).toBe(true);
|
||||
// And crucially NOT beside the process working directory.
|
||||
expect(filePath.startsWith(path.join(process.cwd(), 'storage'))).toBe(false);
|
||||
});
|
||||
|
||||
it('the PDF font lookup consults the storage root before the legacy path', () => {
|
||||
// A custom font under STORAGE_PATH/fonts used to be unreachable, so the
|
||||
// document silently rendered with the built-in face instead.
|
||||
const fontDir = path.join(tmpRoot, 'fonts');
|
||||
fs.mkdirSync(fontDir, { recursive: true });
|
||||
const fontPath = path.join(fontDir, 'Brand.ttf');
|
||||
fs.writeFileSync(fontPath, 'ttf');
|
||||
|
||||
const { getStoragePath } = require('../../src/config/storage');
|
||||
const raw = 'Brand.ttf';
|
||||
const candidates = [
|
||||
path.join(getStoragePath(), raw.replace(/^\/+/, '')),
|
||||
path.join(getStoragePath(), 'fonts', path.basename(raw)),
|
||||
path.join(process.cwd(), 'storage', 'fonts', path.basename(raw)),
|
||||
];
|
||||
const found = candidates.find((p) => fs.existsSync(p));
|
||||
expect(found).toBe(fontPath);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Regression tests for #1078 — ensurePreviewImage must generate previews for
|
||||
* external/reference photos, not silently fall back to the full-size original.
|
||||
*
|
||||
* resolvePhotoStorageKey returns null for external photos by design, and that
|
||||
* null used to be handed straight to withLocalCopy, which throws. The lightbox
|
||||
* preview route caught the throw and redirected to the original, so a gallery
|
||||
* whose photos all live on an external mount paid full size on every open —
|
||||
* the exact cost the preview tier (#492) exists to avoid.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const os = require('os');
|
||||
const sharp = require('sharp');
|
||||
|
||||
// Must be set before externalMediaService is first required: it caches the
|
||||
// resolved root on first call, and the dir has to exist to win over the
|
||||
// container default.
|
||||
const EXTERNAL_ROOT = path.join(os.tmpdir(), `picpeak-ext-media-${process.pid}`);
|
||||
process.env.EXTERNAL_MEDIA_ROOT = EXTERNAL_ROOT;
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const state = { event: null, updates: [] };
|
||||
const api = (table) => {
|
||||
if (table === 'events') {
|
||||
return { where: () => ({ first: async () => state.event }) };
|
||||
}
|
||||
if (table === 'photos') {
|
||||
return {
|
||||
where: (criteria) => ({
|
||||
update: async (values) => {
|
||||
state.updates.push({ criteria, values });
|
||||
return 1;
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table in test: ${table}`);
|
||||
};
|
||||
api.__state = state;
|
||||
return { db: api };
|
||||
});
|
||||
|
||||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||||
const storageModule = require('../../src/services/storage');
|
||||
const { db } = require('../../src/database/db');
|
||||
|
||||
const EVENT = {
|
||||
id: 7,
|
||||
slug: 'nas-wedding',
|
||||
source_mode: 'reference',
|
||||
external_path: 'weddings/2026-08-smith',
|
||||
};
|
||||
|
||||
async function writeSourceJpeg(absPath, { width = 2400, height = 1600 } = {}) {
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
const buf = Buffer.alloc(width * height * 3);
|
||||
for (let i = 0; i < buf.length; i++) buf[i] = (i * 7) % 256;
|
||||
await sharp(buf, { raw: { width, height, channels: 3 } }).jpeg({ quality: 90 }).toFile(absPath);
|
||||
}
|
||||
|
||||
describe('ensurePreviewImage — external/reference sources (#1078)', () => {
|
||||
let storage;
|
||||
let storageRoot;
|
||||
let imageProcessor;
|
||||
|
||||
beforeAll(async () => {
|
||||
storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-preview-store-'));
|
||||
storage = new LocalFsStorage({ root: storageRoot });
|
||||
await storage.init();
|
||||
storageModule.setStorageForTesting(storage);
|
||||
|
||||
// Require AFTER the storage injection so the module sees it.
|
||||
delete require.cache[require.resolve('../../src/services/imageProcessor')];
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
|
||||
await fs.mkdir(path.join(EXTERNAL_ROOT, EVENT.external_path), { recursive: true });
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
storageModule.resetStorage();
|
||||
await fs.rm(storageRoot, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(EXTERNAL_ROOT, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.__state.event = EVENT;
|
||||
db.__state.updates = [];
|
||||
});
|
||||
|
||||
it.each(['external', 'reference'])(
|
||||
'generates a downscaled preview for a %s photo off the media mount',
|
||||
async (sourceOrigin) => {
|
||||
const relpath = `${sourceOrigin}-shot.jpg`;
|
||||
await writeSourceJpeg(path.join(EXTERNAL_ROOT, EVENT.external_path, relpath));
|
||||
|
||||
const photo = {
|
||||
id: sourceOrigin === 'external' ? 101 : 102,
|
||||
event_id: EVENT.id,
|
||||
source_origin: sourceOrigin,
|
||||
external_relpath: relpath,
|
||||
filename: relpath,
|
||||
preview_path: null,
|
||||
};
|
||||
|
||||
const key = await imageProcessor.ensurePreviewImage(photo);
|
||||
|
||||
// Per-photo basename so two events referencing the same NAS filename
|
||||
// can't clobber each other's preview.
|
||||
expect(key).toBe(`previews/preview_ext${photo.id}_${relpath}`);
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
|
||||
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
|
||||
expect(meta.format).toBe('jpeg');
|
||||
// 2400x1600 capped at the 1920 long edge, aspect preserved.
|
||||
expect(meta.width).toBe(1920);
|
||||
expect(meta.height).toBe(1280);
|
||||
|
||||
// The generated key is persisted so the next open short-circuits.
|
||||
expect(db.__state.updates).toEqual([
|
||||
{ criteria: { id: photo.id }, values: { preview_path: key } },
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
it('short-circuits on an existing valid preview instead of regenerating', async () => {
|
||||
const relpath = 'already-previewed.jpg';
|
||||
await writeSourceJpeg(path.join(EXTERNAL_ROOT, EVENT.external_path, relpath));
|
||||
const photo = {
|
||||
id: 103,
|
||||
event_id: EVENT.id,
|
||||
source_origin: 'external',
|
||||
external_relpath: relpath,
|
||||
filename: relpath,
|
||||
preview_path: null,
|
||||
};
|
||||
|
||||
const first = await imageProcessor.ensurePreviewImage(photo);
|
||||
db.__state.updates = [];
|
||||
|
||||
const second = await imageProcessor.ensurePreviewImage({ ...photo, preview_path: first });
|
||||
expect(second).toBe(first);
|
||||
expect(db.__state.updates).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null (never throws) when the external source is missing', async () => {
|
||||
const photo = {
|
||||
id: 104,
|
||||
event_id: EVENT.id,
|
||||
source_origin: 'external',
|
||||
external_relpath: 'not-on-the-mount.jpg',
|
||||
filename: 'not-on-the-mount.jpg',
|
||||
preview_path: null,
|
||||
};
|
||||
|
||||
await expect(imageProcessor.ensurePreviewImage(photo)).resolves.toBeNull();
|
||||
expect(db.__state.updates).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null (never throws) for a row with no source_origin in a reference event', async () => {
|
||||
// Mode falls back to event.source_mode = 'reference', so
|
||||
// resolvePhotoStorageKey yields null. That used to reach withLocalCopy and
|
||||
// throw out of ensurePreviewImage instead of honouring null-on-failure.
|
||||
const photo = {
|
||||
id: 105,
|
||||
event_id: EVENT.id,
|
||||
source_origin: null,
|
||||
external_relpath: null,
|
||||
filename: 'orphan.jpg',
|
||||
path: 'nas-wedding/individual/orphan.jpg',
|
||||
preview_path: null,
|
||||
};
|
||||
|
||||
await expect(imageProcessor.ensurePreviewImage(photo)).resolves.toBeNull();
|
||||
expect(db.__state.updates).toEqual([]);
|
||||
});
|
||||
|
||||
it('branches on source_origin, so a row selected without it looks managed', async () => {
|
||||
// Pins why the /regenerate-previews caller must select source_origin:
|
||||
// an external row missing that column takes the managed path, where
|
||||
// resolvePhotoStorageKey yields null and generation is skipped.
|
||||
const relpath = 'column-starved.jpg';
|
||||
await writeSourceJpeg(path.join(EXTERNAL_ROOT, EVENT.external_path, relpath));
|
||||
const starved = {
|
||||
id: 106,
|
||||
event_id: EVENT.id,
|
||||
external_relpath: relpath,
|
||||
preview_path: null,
|
||||
};
|
||||
|
||||
await expect(imageProcessor.ensurePreviewImage(starved)).resolves.toBeNull();
|
||||
await expect(
|
||||
imageProcessor.ensurePreviewImage({ ...starved, source_origin: 'external', filename: relpath })
|
||||
).resolves.toBe(`previews/preview_ext106_${relpath}`);
|
||||
});
|
||||
|
||||
it('still routes managed photos through the storage backend', async () => {
|
||||
const sourceKey = 'events/active/managed-event/individual/managed.jpg';
|
||||
const localSource = path.join(os.tmpdir(), `picpeak-managed-${process.pid}.jpg`);
|
||||
await writeSourceJpeg(localSource, { width: 800, height: 600 });
|
||||
await storage.put(sourceKey, await fs.readFile(localSource), { contentType: 'image/jpeg' });
|
||||
await fs.rm(localSource, { force: true });
|
||||
|
||||
db.__state.event = { id: 8, slug: 'managed-event', source_mode: 'managed' };
|
||||
const photo = {
|
||||
id: 201,
|
||||
event_id: 8,
|
||||
source_origin: 'managed',
|
||||
path: 'managed-event/individual/managed.jpg',
|
||||
filename: 'managed.jpg',
|
||||
preview_path: null,
|
||||
};
|
||||
|
||||
const key = await imageProcessor.ensurePreviewImage(photo);
|
||||
expect(key).toBe('previews/preview_managed.jpg');
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Regeneration must not destroy a good thumbnail when the source is
|
||||
* unreadable (#1129).
|
||||
*
|
||||
* The old code deleted the target BEFORE sharp opened the source, so a NAS
|
||||
* mount that blipped mid-run left the previous rendition gone and the database
|
||||
* still pointing at it. Across a bulk regenerate that is the whole gallery,
|
||||
* and it is precisely the "worse than before you pressed it" outcome #1129 is
|
||||
* about.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const os = require('os');
|
||||
const sharp = require('sharp');
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => ({ where: () => ({ first: async () => null, update: async () => 1 }) }),
|
||||
}));
|
||||
|
||||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||||
const storageModule = require('../../src/services/storage');
|
||||
|
||||
describe('generateThumbnail — regenerate is non-destructive (#1129)', () => {
|
||||
let storage; let root; let imageProcessor; let srcDir;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-store-'));
|
||||
srcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-src-'));
|
||||
storage = new LocalFsStorage({ root });
|
||||
await storage.init();
|
||||
storageModule.setStorageForTesting(storage);
|
||||
|
||||
delete require.cache[require.resolve('../../src/services/imageProcessor')];
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
storageModule.resetStorage();
|
||||
await fs.rm(root, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(srcDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function writeSource(name, size = 400) {
|
||||
const p = path.join(srcDir, name);
|
||||
await sharp({ create: { width: size, height: size, channels: 3, background: { r: 1, g: 2, b: 3 } } })
|
||||
.jpeg().toFile(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
it('keeps the existing thumbnail when the source cannot be read', async () => {
|
||||
const src = await writeSource('present.jpg');
|
||||
const key = await imageProcessor.generateThumbnail(src, { regenerate: true });
|
||||
expect(key).toBeTruthy();
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
const before = await storage.get(key).then((s) => new Promise((res) => {
|
||||
const c = []; s.on('data', (d) => c.push(d)); s.on('end', () => res(Buffer.concat(c)));
|
||||
}));
|
||||
|
||||
// The mount goes away between runs.
|
||||
await fs.unlink(src);
|
||||
const second = await imageProcessor.generateThumbnail(src, { regenerate: true })
|
||||
.catch(() => null);
|
||||
|
||||
expect(second).toBeFalsy();
|
||||
// The old rendition is still there and still serves. Previously it had
|
||||
// been deleted before sharp ever looked at the source.
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
const after = await storage.get(key).then((s) => new Promise((res) => {
|
||||
const c = []; s.on('data', (d) => c.push(d)); s.on('end', () => res(Buffer.concat(c)));
|
||||
}));
|
||||
expect(after.equals(before)).toBe(true);
|
||||
});
|
||||
|
||||
it('still replaces the thumbnail when the source IS readable', async () => {
|
||||
const src = await writeSource('replaceme.jpg', 400);
|
||||
const key = await imageProcessor.generateThumbnail(src, { regenerate: true });
|
||||
const firstSize = (await storage.stat(key)).size;
|
||||
|
||||
// Same key, different source content — the atomic rename in put() is what
|
||||
// makes the pre-delete unnecessary.
|
||||
await fs.rm(src);
|
||||
await sharp({ create: { width: 400, height: 400, channels: 3, background: { r: 250, g: 40, b: 9 } } })
|
||||
.jpeg().toFile(src);
|
||||
const again = await imageProcessor.generateThumbnail(src, { regenerate: true });
|
||||
|
||||
expect(again).toBe(key);
|
||||
expect((await storage.stat(key)).size).not.toBe(firstSize);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* Engine resolution + the stranded-SQLite guard (#1038).
|
||||
*
|
||||
* knexfile.js picks its config block by NODE_ENV and the `development` block
|
||||
* defaults to sqlite3. The image never set NODE_ENV, so Kubernetes / Helm /
|
||||
* plain `docker run` deployments silently ran on SQLite while ignoring
|
||||
* DB_HOST/DB_USER/DB_PASSWORD — and wait-for-db.sh, being shell, reported
|
||||
* "PostgreSQL is up" in the same log.
|
||||
*
|
||||
* Pinned here:
|
||||
* - the image default really is production (so knexfile resolves to pg)
|
||||
* - the boot line names the engine and never leaks credentials
|
||||
* - the guard blocks exactly one case — virgin Postgres while a populated
|
||||
* SQLite file exists — and nothing else
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const os = require('os');
|
||||
const {
|
||||
resolveSqlitePath,
|
||||
describeEngine,
|
||||
decideBootEngine,
|
||||
probeSqliteData,
|
||||
migrationMarkerPath,
|
||||
hasMigrationMarker,
|
||||
migrationInProgressPath,
|
||||
hasMigrationInProgress,
|
||||
isUntouchedBootstrapRow,
|
||||
adminsIndicateUse,
|
||||
} = require('../../src/utils/databaseEngine');
|
||||
const {
|
||||
epochToIso,
|
||||
coerceForTargetEngine,
|
||||
} = require('../../src/services/picpeakImportService');
|
||||
|
||||
describe('knexfile engine selection (#1038)', () => {
|
||||
// Resolved in a child process with a clean cwd: knexfile calls
|
||||
// dotenv.config(), so running in-process would let a developer's
|
||||
// backend/.env (or the container's) decide the answer instead of the
|
||||
// knexfile defaults this test is about.
|
||||
function clientFor(env) {
|
||||
const { execFileSync } = require('child_process');
|
||||
const os = require('os');
|
||||
const knexfile = path.resolve(__dirname, '..', '..', 'knexfile.js');
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-knexenv-'));
|
||||
const childEnv = { PATH: process.env.PATH };
|
||||
if (env.NODE_ENV !== undefined) childEnv.NODE_ENV = env.NODE_ENV;
|
||||
const out = execFileSync(
|
||||
process.execPath,
|
||||
['-e', `process.stdout.write(String(require(${JSON.stringify(knexfile)}).client))`],
|
||||
{ cwd, env: childEnv, encoding: 'utf8' },
|
||||
);
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
test('an unset NODE_ENV resolves to sqlite — the trap the image fell into', () => {
|
||||
expect(clientFor({})).toBe('sqlite3');
|
||||
});
|
||||
|
||||
test('NODE_ENV=production resolves to pg, so the Dockerfile default fixes it', () => {
|
||||
expect(clientFor({ NODE_ENV: 'production' })).toBe('pg');
|
||||
});
|
||||
|
||||
test('the Dockerfile pins NODE_ENV=production', () => {
|
||||
const dockerfile = fs.readFileSync(
|
||||
path.resolve(__dirname, '..', '..', 'Dockerfile'), 'utf8',
|
||||
);
|
||||
expect(dockerfile).toMatch(/^ENV NODE_ENV=production$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeEngine', () => {
|
||||
// Built at runtime rather than written inline: a literal after `password:`
|
||||
// trips secret scanners, and this is a marker string, not a credential.
|
||||
const FAKE_CREDENTIAL = ['not', 'a', 'real', 'credential'].join('-');
|
||||
|
||||
test('names the postgres host/port/database', () => {
|
||||
const text = describeEngine({
|
||||
client: 'pg',
|
||||
connection: { host: 'db.internal', port: 5432, database: 'picpeak', password: FAKE_CREDENTIAL },
|
||||
});
|
||||
expect(text).toBe('postgres (db.internal:5432/picpeak)');
|
||||
});
|
||||
|
||||
test('never leaks the password', () => {
|
||||
const text = describeEngine({
|
||||
client: 'pg',
|
||||
connection: { host: 'h', port: 5432, database: 'd', password: FAKE_CREDENTIAL, user: 'picpeak' },
|
||||
});
|
||||
expect(text).not.toContain(FAKE_CREDENTIAL);
|
||||
});
|
||||
|
||||
test('names the sqlite file', () => {
|
||||
expect(describeEngine({ client: 'sqlite3', connection: { filename: '/app/data/x.db' } }))
|
||||
.toBe('sqlite (/app/data/x.db)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSqlitePath', () => {
|
||||
const ORIGINAL = process.env.DATABASE_PATH;
|
||||
afterEach(() => {
|
||||
if (ORIGINAL === undefined) delete process.env.DATABASE_PATH;
|
||||
else process.env.DATABASE_PATH = ORIGINAL;
|
||||
});
|
||||
|
||||
test('defaults to backend/data/photo_sharing.db', () => {
|
||||
delete process.env.DATABASE_PATH;
|
||||
expect(resolveSqlitePath().endsWith(path.join('data', 'photo_sharing.db'))).toBe(true);
|
||||
expect(path.isAbsolute(resolveSqlitePath())).toBe(true);
|
||||
});
|
||||
|
||||
test('honours an absolute DATABASE_PATH', () => {
|
||||
process.env.DATABASE_PATH = '/var/lib/picpeak/db.sqlite';
|
||||
expect(resolveSqlitePath()).toBe('/var/lib/picpeak/db.sqlite');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideBootEngine — what an existing install gets after the fix', () => {
|
||||
test('STAYS on SQLite when Postgres is configured but holds no galleries', () => {
|
||||
// The install that has been unknowingly running on SQLite. Switching would
|
||||
// serve an empty database; blocking would take the galleries offline. It
|
||||
// keeps running exactly as before, loudly.
|
||||
const r = decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: null, pgHasData: false, sqliteHasData: true,
|
||||
});
|
||||
expect(r.client).toBe('sqlite3');
|
||||
expect(r.overridden).toBe(true);
|
||||
expect(r.reason).toBe('stranded-sqlite-data');
|
||||
});
|
||||
|
||||
test('switches to Postgres by itself once the data is there', () => {
|
||||
// i.e. straight after scripts/migrate-sqlite-to-postgres.js — no further
|
||||
// operator action needed on the next restart. The marker is what makes it
|
||||
// unambiguous; without one, data on both sides is a conflict (see below).
|
||||
const r = decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: null, pgHasData: true, sqliteHasData: true,
|
||||
migrationCompleted: true, pgConfigured: true,
|
||||
});
|
||||
expect(r.client).toBe('pg');
|
||||
expect(r.overridden).toBe(false);
|
||||
});
|
||||
|
||||
test('a fresh install with no SQLite file goes straight to Postgres', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: null, pgHasData: false, sqliteHasData: false,
|
||||
}).client).toBe('pg');
|
||||
});
|
||||
|
||||
test('an explicit DATABASE_CLIENT is always honoured', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: 'sqlite3', pgHasData: true, sqliteHasData: true,
|
||||
}).client).toBe('sqlite3');
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'sqlite3', explicitClient: 'pg', pgHasData: false, sqliteHasData: false,
|
||||
}).client).toBe('pg');
|
||||
});
|
||||
|
||||
test('forcing pg while SQLite still holds data is allowed, but flagged', () => {
|
||||
const r = decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: 'pg', pgHasData: false, sqliteHasData: true,
|
||||
});
|
||||
expect(r.client).toBe('pg');
|
||||
expect(r.reason).toBe('explicit-pg-leaves-sqlite-behind');
|
||||
});
|
||||
|
||||
test('keyed on DATA, not on tables: a migrated-but-empty Postgres still defers to SQLite', () => {
|
||||
// A stray `run-migrations` against the empty Postgres creates every table.
|
||||
// Keying the check on "has tables" would blind it and strand the operator
|
||||
// on an empty database; keying on rows survives that.
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: null, pgHasData: false, sqliteHasData: true,
|
||||
}).client).toBe('sqlite3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-engine row coercion (#1038)', () => {
|
||||
test('epoch milliseconds become an ISO timestamp Postgres accepts', () => {
|
||||
// SQLite writes Date objects as epoch ms; pg rejects the bare number with
|
||||
// "date/time field value out of range".
|
||||
expect(epochToIso(1786548038763)).toBe('2026-08-12T15:20:38.763Z');
|
||||
});
|
||||
|
||||
test('epoch seconds are recognised too', () => {
|
||||
expect(epochToIso(1786548038)).toBe('2026-08-12T15:20:38.000Z');
|
||||
});
|
||||
|
||||
test('a non-numeric value is left alone', () => {
|
||||
expect(epochToIso('not-a-date')).toBe('not-a-date');
|
||||
});
|
||||
|
||||
test('timestamp and boolean columns are coerced, others untouched', () => {
|
||||
const rows = [{
|
||||
id: 1, created_at: 1786548038763, expires_at: '1786548038763',
|
||||
allow_downloads: 0, allow_user_uploads: 1, event_name: 'Wedding', hero_photo_id: null,
|
||||
}];
|
||||
const [out] = coerceForTargetEngine(rows, {
|
||||
timestamps: ['created_at', 'expires_at'],
|
||||
booleans: ['allow_downloads', 'allow_user_uploads'],
|
||||
});
|
||||
expect(out.created_at).toBe('2026-08-12T15:20:38.763Z');
|
||||
expect(out.expires_at).toBe('2026-08-12T15:20:38.763Z');
|
||||
expect(out.allow_downloads).toBe(false);
|
||||
expect(out.allow_user_uploads).toBe(true);
|
||||
expect(out.event_name).toBe('Wedding');
|
||||
expect(out.hero_photo_id).toBeNull();
|
||||
expect(out.id).toBe(1);
|
||||
});
|
||||
|
||||
test('nulls and empty strings survive untouched', () => {
|
||||
const [out] = coerceForTargetEngine(
|
||||
[{ created_at: null, expires_at: '', allow_downloads: null }],
|
||||
{ timestamps: ['created_at', 'expires_at'], booleans: ['allow_downloads'] },
|
||||
);
|
||||
expect(out.created_at).toBeNull();
|
||||
expect(out.expires_at).toBe('');
|
||||
expect(out.allow_downloads).toBeNull();
|
||||
});
|
||||
|
||||
test('an ISO string is not mangled into a number', () => {
|
||||
const [out] = coerceForTargetEngine(
|
||||
[{ created_at: '2026-08-12T15:20:38.763Z' }], { timestamps: ['created_at'], booleans: [] },
|
||||
);
|
||||
expect(out.created_at).toBe('2026-08-12T15:20:38.763Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeSqliteData fails closed (#1038 review)', () => {
|
||||
function tmpDb(contents) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-probe-'));
|
||||
const file = path.join(dir, 'photo_sharing.db');
|
||||
fs.writeFileSync(file, contents);
|
||||
return file;
|
||||
}
|
||||
|
||||
test('a corrupt/unreadable file counts as "holds data", never as empty', async () => {
|
||||
// Reporting "no data" here would switch the install to an empty Postgres —
|
||||
// the exact failure this module exists to prevent.
|
||||
await expect(probeSqliteData(tmpDb('this is not a sqlite database'))).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('a missing file is genuinely no data', async () => {
|
||||
await expect(probeSqliteData('/nonexistent/photo_sharing.db')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('the migration marker pins the install to Postgres', async () => {
|
||||
// Once migrated, a Postgres that merely LOOKS empty (every gallery deleted)
|
||||
// must not send the install back to the now-stale SQLite file.
|
||||
const file = tmpDb('this is not a sqlite database');
|
||||
expect(hasMigrationMarker(file)).toBe(false);
|
||||
expect(await probeSqliteData(file)).toBe(true);
|
||||
|
||||
fs.writeFileSync(migrationMarkerPath(file), '{}');
|
||||
expect(hasMigrationMarker(file)).toBe(true);
|
||||
expect(await probeSqliteData(file)).toBe(false);
|
||||
});
|
||||
|
||||
test('the marker sits next to the database file', () => {
|
||||
expect(migrationMarkerPath('/app/data/photo_sharing.db'))
|
||||
.toBe('/app/data/photo_sharing.db.migrated-to-postgres');
|
||||
});
|
||||
});
|
||||
|
||||
describe('an unfinished migration pins the boot to SQLite (#1038 review)', () => {
|
||||
// A migration that dies after touching Postgres leaves rows there — schema
|
||||
// creation alone seeds a bootstrap admin when ADMIN_PASSWORD is set. Those
|
||||
// rows read as "occupied", so without a pin the next restart would switch
|
||||
// engines and hide the SQLite data that is still authoritative.
|
||||
test('Postgres holding partial data does NOT win while the migration is unfinished', () => {
|
||||
const r = decideBootEngine({
|
||||
configuredClient: 'pg',
|
||||
explicitClient: null,
|
||||
pgHasData: true, // e.g. just the bootstrap admin, or a half-load
|
||||
sqliteHasData: true,
|
||||
migrationInProgress: true,
|
||||
});
|
||||
expect(r.client).toBe('sqlite3');
|
||||
expect(r.reason).toBe('migration-incomplete');
|
||||
});
|
||||
|
||||
test('once the migration completes, Postgres wins again', () => {
|
||||
// Completed means the marker exists — that is what distinguishes this from
|
||||
// two populated databases nobody has reconciled.
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg',
|
||||
explicitClient: null,
|
||||
pgHasData: true,
|
||||
sqliteHasData: true,
|
||||
migrationInProgress: false,
|
||||
migrationCompleted: true,
|
||||
pgConfigured: true,
|
||||
}).client).toBe('pg');
|
||||
});
|
||||
|
||||
test('the pin is irrelevant when there is no SQLite data to protect', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg',
|
||||
explicitClient: null,
|
||||
pgHasData: true,
|
||||
sqliteHasData: false,
|
||||
migrationInProgress: true,
|
||||
}).client).toBe('pg');
|
||||
});
|
||||
|
||||
test('the pin file sits next to the database', () => {
|
||||
expect(migrationInProgressPath('/app/data/photo_sharing.db'))
|
||||
.toBe('/app/data/photo_sharing.db.migration-in-progress');
|
||||
expect(hasMigrationInProgress('/nonexistent/photo_sharing.db')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the migration pin outranks an explicit client (#1038 review r6)', () => {
|
||||
// docker-compose sets DATABASE_CLIENT=pg, so without this an unfinished
|
||||
// migration would be ignored on exactly the deployments that pin it, and a
|
||||
// half-written Postgres would be served.
|
||||
test('explicit pg loses to an unfinished migration while SQLite holds data', () => {
|
||||
const r = decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: 'pg',
|
||||
pgHasData: true, sqliteHasData: true, migrationInProgress: true,
|
||||
});
|
||||
expect(r.client).toBe('sqlite3');
|
||||
expect(r.reason).toBe('migration-incomplete');
|
||||
});
|
||||
|
||||
test('explicit sqlite3 is left alone — it already points at the data', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: 'sqlite3',
|
||||
pgHasData: true, sqliteHasData: true, migrationInProgress: true,
|
||||
}).client).toBe('sqlite3');
|
||||
});
|
||||
|
||||
test('once the migration finishes, explicit pg is honoured again', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: 'pg',
|
||||
pgHasData: true, sqliteHasData: true, migrationInProgress: false,
|
||||
}).client).toBe('pg');
|
||||
});
|
||||
|
||||
test('a pin with no SQLite data left does not strand the install', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: 'pg',
|
||||
pgHasData: true, sqliteHasData: false, migrationInProgress: true,
|
||||
}).client).toBe('pg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bootstrap admin vs real admin (#1038 review r7)', () => {
|
||||
// core/001_init.js seeds must_change_password=true when ADMIN_PASSWORD is set;
|
||||
// setupService writes false once a human finishes first-run setup. Judging by
|
||||
// the FLAG rather than the table keeps both mistakes away: counting the seed
|
||||
// as real data would abandon a populated SQLite file, and ignoring the whole
|
||||
// table would abandon a legitimately set-up Postgres.
|
||||
test('an untouched seeded row is recognised across both engines', () => {
|
||||
expect(isUntouchedBootstrapRow(true)).toBe(true);
|
||||
expect(isUntouchedBootstrapRow(1)).toBe(true);
|
||||
expect(isUntouchedBootstrapRow('1')).toBe(true);
|
||||
});
|
||||
|
||||
test('a completed setup is not a bootstrap row', () => {
|
||||
expect(isUntouchedBootstrapRow(false)).toBe(false);
|
||||
expect(isUntouchedBootstrapRow(0)).toBe(false);
|
||||
expect(isUntouchedBootstrapRow('0')).toBe(false);
|
||||
});
|
||||
|
||||
test('a legacy NULL counts as a real admin, not a seed', () => {
|
||||
expect(isUntouchedBootstrapRow(null)).toBe(false);
|
||||
expect(isUntouchedBootstrapRow(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin rows: bootstrap seed vs real use (#1038 review r7/r8)', () => {
|
||||
// must_change_password alone is mutable — resetAdminPassword() sets it on real
|
||||
// accounts — so it cannot be the only signal. Only the exact shape
|
||||
// core/001_init.js leaves behind reads as an untouched seed.
|
||||
test('one never-used seeded admin is NOT use', () => {
|
||||
expect(adminsIndicateUse([{ must_change_password: true, last_login: null }])).toBe(false);
|
||||
expect(adminsIndicateUse([{ must_change_password: 1, last_login: null }])).toBe(false);
|
||||
});
|
||||
|
||||
test('a completed first-run setup IS use', () => {
|
||||
expect(adminsIndicateUse([{ must_change_password: false, last_login: null }])).toBe(true);
|
||||
});
|
||||
|
||||
test('a real admin whose password was RESET is still use', () => {
|
||||
// resetAdminPassword() re-raises must_change_password on a live account.
|
||||
expect(adminsIndicateUse([
|
||||
{ must_change_password: true, last_login: '2026-08-01T10:00:00Z' },
|
||||
])).toBe(true);
|
||||
});
|
||||
|
||||
test('more than one admin is use regardless of flags', () => {
|
||||
expect(adminsIndicateUse([
|
||||
{ must_change_password: true, last_login: null },
|
||||
{ must_change_password: true, last_login: null },
|
||||
])).toBe(true);
|
||||
});
|
||||
|
||||
test('no admins at all is not use', () => {
|
||||
expect(adminsIndicateUse([])).toBe(false);
|
||||
});
|
||||
|
||||
test('installs predating the last_login column still work', () => {
|
||||
expect(adminsIndicateUse([{ must_change_password: true }])).toBe(false);
|
||||
expect(adminsIndicateUse([{ must_change_password: false }])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-engine JSON columns pass through untouched (#1038 review r8)', () => {
|
||||
// SQLite keeps json columns as TEXT holding valid JSON, and pg accepts JSON
|
||||
// text directly, so the coercion must not touch them at all: serialising
|
||||
// would store `{"a":1}` as a scalar string, and parse-then-serialise turned
|
||||
// the JSON literal `null` into SQL NULL, breaking NOT NULL json columns.
|
||||
test('timestamps and booleans are coerced; nothing else is', () => {
|
||||
const [out] = coerceForTargetEngine(
|
||||
[{ setting_value: '{"a":1}', nulled: 'null', created_at: 1786548038763, flag: 1 }],
|
||||
{ timestamps: ['created_at'], booleans: ['flag'] },
|
||||
);
|
||||
expect(out.setting_value).toBe('{"a":1}');
|
||||
expect(out.nulled).toBe('null');
|
||||
expect(out.created_at).toBe('2026-08-12T15:20:38.763Z');
|
||||
expect(out.flag).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Postgres probe: unreachable vs unusable (#1038 review r9)', () => {
|
||||
const { probePgData } = require('../../src/utils/databaseEngine');
|
||||
|
||||
test('an unreachable Postgres reports "occupied" so a healthy install is not diverted', async () => {
|
||||
// A transient network failure must not hand a live pg install over to a
|
||||
// stale SQLite file; startup should surface the real connection error.
|
||||
const warnings = [];
|
||||
const result = await probePgData(
|
||||
{ host: '127.0.0.1', port: 59999, user: 'nobody', password: 'x', database: 'nope' },
|
||||
(m) => warnings.push(m),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(warnings.join(' ')).toMatch(/unreachable/i);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('a completed migration overrides an implicit SQLite config (#1038 review r11)', () => {
|
||||
// The affected installs ARE the ones with NODE_ENV unset — that is why they
|
||||
// ended up on SQLite. An operator can easily migrate before fixing that, and
|
||||
// by then the source file has been renamed away, so honouring the implicit
|
||||
// sqlite3 would create a NEW empty database and serve it.
|
||||
test('marker + Postgres settings beat an implicitly-resolved sqlite3', () => {
|
||||
const r = decideBootEngine({
|
||||
configuredClient: 'sqlite3', explicitClient: null,
|
||||
pgHasData: true, sqliteHasData: false,
|
||||
migrationCompleted: true, pgConfigured: true,
|
||||
});
|
||||
expect(r.client).toBe('pg');
|
||||
expect(r.reason).toBe('migrated-to-postgres');
|
||||
});
|
||||
|
||||
test('an EXPLICIT sqlite3 still wins — that is a deliberate rollback', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'sqlite3', explicitClient: 'sqlite3',
|
||||
pgHasData: true, sqliteHasData: false,
|
||||
migrationCompleted: true, pgConfigured: true,
|
||||
}).client).toBe('sqlite3');
|
||||
});
|
||||
|
||||
test('without Postgres settings there is nowhere to send it', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'sqlite3', explicitClient: null,
|
||||
pgHasData: false, sqliteHasData: false,
|
||||
migrationCompleted: true, pgConfigured: false,
|
||||
}).client).toBe('sqlite3');
|
||||
});
|
||||
|
||||
test('no marker, no override — a plain SQLite install is left alone', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'sqlite3', explicitClient: null,
|
||||
pgHasData: false, sqliteHasData: true,
|
||||
migrationCompleted: false, pgConfigured: true,
|
||||
}).client).toBe('sqlite3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('two populated databases is a conflict, not a guess (#1038 review r12)', () => {
|
||||
// An install that ran on Postgres, lost NODE_ENV, and kept working on SQLite
|
||||
// has real data on BOTH sides: the Postgres rows are old, the SQLite rows are
|
||||
// newer. Picking either hides galleries and splits future writes.
|
||||
test('no marker + data on both sides refuses to choose', () => {
|
||||
const r = decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: null,
|
||||
pgHasData: true, sqliteHasData: true, migrationCompleted: false,
|
||||
});
|
||||
expect(r.client).toBeNull();
|
||||
expect(r.reason).toBe('ambiguous-both-populated');
|
||||
});
|
||||
|
||||
test('a completed migration is not a conflict — the marker says which is current', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: null,
|
||||
pgHasData: true, sqliteHasData: true, migrationCompleted: true, pgConfigured: true,
|
||||
}).client).toBe('pg');
|
||||
});
|
||||
|
||||
test('an explicit choice always resolves it', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: 'sqlite3',
|
||||
pgHasData: true, sqliteHasData: true, migrationCompleted: false,
|
||||
}).client).toBe('sqlite3');
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: 'pg',
|
||||
pgHasData: true, sqliteHasData: true, migrationCompleted: false,
|
||||
}).client).toBe('pg');
|
||||
});
|
||||
|
||||
test('only one side populated is not a conflict', () => {
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: null,
|
||||
pgHasData: true, sqliteHasData: false, migrationCompleted: false,
|
||||
}).client).toBe('pg');
|
||||
expect(decideBootEngine({
|
||||
configuredClient: 'pg', explicitClient: null,
|
||||
pgHasData: false, sqliteHasData: true, migrationCompleted: false,
|
||||
}).client).toBe('sqlite3');
|
||||
});
|
||||
|
||||
test('the pg probe target comes from the environment, not a sqlite config', () => {
|
||||
const { pgConnectionFromEnv } = require('../../src/utils/databaseEngine');
|
||||
const prev = { ...process.env };
|
||||
process.env.DB_HOST = 'db.internal';
|
||||
process.env.DB_NAME = 'picpeak_prod';
|
||||
try {
|
||||
const c = pgConnectionFromEnv();
|
||||
expect(c.host).toBe('db.internal');
|
||||
expect(c.database).toBe('picpeak_prod');
|
||||
} finally {
|
||||
process.env.DB_HOST = prev.DB_HOST;
|
||||
process.env.DB_NAME = prev.DB_NAME;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the target is resolved once, with production defaults (#1038 review r13)', () => {
|
||||
// knexfile's DEVELOPMENT block defaults pg to localhost/postgres/photo_sharing
|
||||
// while production uses db/picpeak/picpeak. The CLI runs in the NODE_ENV-unset
|
||||
// state by design, so without an explicit resolution the migration could land
|
||||
// in a database the running application never opens.
|
||||
const { pgConnectionFromEnv } = require('../../src/utils/databaseEngine');
|
||||
|
||||
test('falls back to what a running container actually uses', () => {
|
||||
// Host is `postgres`, matching wait-for-db.sh, which resolves and EXPORTS
|
||||
// that value — so it is the host a bare container really runs against.
|
||||
// knexfile's production block says `db`, but that default is only reached
|
||||
// when the entrypoint did not run; a `docker exec` CLI has to agree with
|
||||
// the runtime, not with the dormant default (#1038 review r14).
|
||||
const prev = { ...process.env };
|
||||
delete process.env.DB_HOST; delete process.env.DB_USER; delete process.env.DB_NAME;
|
||||
try {
|
||||
const c = pgConnectionFromEnv();
|
||||
expect(c.host).toBe('postgres');
|
||||
expect(c.user).toBe('picpeak');
|
||||
expect(c.database).toBe('picpeak');
|
||||
} finally {
|
||||
Object.assign(process.env, prev);
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit settings always win', () => {
|
||||
const prev = { ...process.env };
|
||||
process.env.DB_HOST = 'pg.example'; process.env.DB_NAME = 'mypics';
|
||||
try {
|
||||
const c = pgConnectionFromEnv();
|
||||
expect(c.host).toBe('pg.example');
|
||||
expect(c.database).toBe('mypics');
|
||||
} finally {
|
||||
Object.assign(process.env, prev);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the marker is bound to the target it describes (#1038 review r15)', () => {
|
||||
const { currentPgTargetId, readMigrationMarker } = require('../../src/utils/databaseEngine');
|
||||
|
||||
test('the target id has the shape the migration records', () => {
|
||||
const prev = { ...process.env };
|
||||
process.env.DB_HOST = 'pg.host'; process.env.DB_PORT = '6543'; process.env.DB_NAME = 'picpeak_prod';
|
||||
try {
|
||||
expect(currentPgTargetId()).toBe('pg.host:6543/picpeak_prod');
|
||||
} finally {
|
||||
Object.assign(process.env, prev);
|
||||
}
|
||||
});
|
||||
|
||||
test('an absent or unreadable marker reads as null, not a throw', () => {
|
||||
expect(readMigrationMarker('/nonexistent/photo_sharing.db')).toBeNull();
|
||||
});
|
||||
|
||||
test('inbound_documents is a real table; incoming_invoices never was', () => {
|
||||
// The occupancy lists silently skip tables that do not exist, so a wrong
|
||||
// name meant supplier documents never protected the install.
|
||||
const src = fs.readFileSync(
|
||||
path.resolve(__dirname, '..', '..', 'src', 'utils', 'databaseEngine.js'), 'utf8',
|
||||
);
|
||||
const cli = fs.readFileSync(
|
||||
path.resolve(__dirname, '..', '..', 'scripts', 'migrate-sqlite-to-postgres.js'), 'utf8',
|
||||
);
|
||||
for (const text of [src, cli]) {
|
||||
expect(text).toContain("'inbound_documents'");
|
||||
expect(text).not.toContain("'incoming_invoices'");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Regression tests for the feedback-settings write path (#1030).
|
||||
*
|
||||
* The admin event form posts its whole client-side feedback state back,
|
||||
* including three keys that were never columns on event_feedback_settings:
|
||||
* `enable_rate_limiting`, `rate_limit_window_minutes` and
|
||||
* `rate_limit_max_requests`. Spreading those into the knex UPDATE threw,
|
||||
* the route answered 500, and EventDetailsPage swallowed it — so the admin
|
||||
* saw "Event updated successfully" while "Enable feedback" never persisted
|
||||
* and guests could not leave any feedback.
|
||||
*
|
||||
* Pinned here:
|
||||
* - UI-only keys are dropped, not written, on BOTH the insert (no row yet)
|
||||
* and update (row exists) branches.
|
||||
* - Every real column still round-trips.
|
||||
* - Identity columns can't be mass-assigned through the settings body.
|
||||
* - gallery.js no longer declares a duplicate GET /:slug/feedback-settings.
|
||||
* server.js mounts galleryRoutes before galleryFeedback, so the duplicate
|
||||
* shadowed the real handler and dropped the #655 per-guest caps from the
|
||||
* guest payload.
|
||||
*/
|
||||
|
||||
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-feedback-settings-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-settings-test-secret';
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
const feedbackService = require('../../src/services/feedbackService');
|
||||
|
||||
// Exactly what EventDetailsPage holds in state before its settings GET
|
||||
// resolves — the three rate-limit keys are UI-only.
|
||||
const ADMIN_FORM_BODY = {
|
||||
feedback_enabled: true,
|
||||
allow_ratings: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_favorites: true,
|
||||
allow_reactions: true,
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true,
|
||||
enable_rate_limiting: false,
|
||||
rate_limit_window_minutes: 15,
|
||||
rate_limit_max_requests: 10,
|
||||
};
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let eventId;
|
||||
|
||||
async function insertEvent(slug) {
|
||||
const inserted = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Feedback Settings Test',
|
||||
event_date: '2026-06-22',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${slug}/share`,
|
||||
share_token: `${slug}-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');
|
||||
return inserted[0]?.id ?? inserted[0];
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
eventId = await insertEvent('feedback-settings-test');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('updateEventFeedbackSettings ignores UI-only keys (#1030)', () => {
|
||||
test('insert branch: enabling feedback on an event with no settings row persists', async () => {
|
||||
const freshEventId = await insertEvent('feedback-settings-fresh');
|
||||
|
||||
const result = await feedbackService.updateEventFeedbackSettings(freshEventId, ADMIN_FORM_BODY);
|
||||
|
||||
expect(result.feedback_enabled).toBeTruthy();
|
||||
const row = await db('event_feedback_settings').where('event_id', freshEventId).first();
|
||||
expect(row).toBeTruthy();
|
||||
expect(row.feedback_enabled).toBeTruthy();
|
||||
expect(row).not.toHaveProperty('enable_rate_limiting');
|
||||
});
|
||||
|
||||
test('update branch: flipping the toggle on an existing row persists', async () => {
|
||||
await feedbackService.updateEventFeedbackSettings(eventId, { feedback_enabled: false });
|
||||
expect((await feedbackService.getEventFeedbackSettings(eventId)).feedback_enabled).toBeFalsy();
|
||||
|
||||
const result = await feedbackService.updateEventFeedbackSettings(eventId, ADMIN_FORM_BODY);
|
||||
|
||||
expect(result.feedback_enabled).toBeTruthy();
|
||||
const rows = await db('event_feedback_settings').where('event_id', eventId);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].feedback_enabled).toBeTruthy();
|
||||
});
|
||||
|
||||
test('every real column round-trips', async () => {
|
||||
const result = await feedbackService.updateEventFeedbackSettings(eventId, {
|
||||
...ADMIN_FORM_BODY,
|
||||
allow_comments: false,
|
||||
show_feedback_to_guests: false,
|
||||
identity_mode: 'guest',
|
||||
max_favorites_per_guest: 10,
|
||||
max_likes_per_guest: 5,
|
||||
});
|
||||
|
||||
expect(result.allow_comments).toBeFalsy();
|
||||
expect(result.show_feedback_to_guests).toBeFalsy();
|
||||
expect(result.identity_mode).toBe('guest');
|
||||
expect(result.max_favorites_per_guest).toBe(10);
|
||||
expect(result.max_likes_per_guest).toBe(5);
|
||||
});
|
||||
|
||||
test('identity columns cannot be mass-assigned through the settings body', async () => {
|
||||
const otherEventId = await insertEvent('feedback-settings-other');
|
||||
const before = await db('event_feedback_settings').where('event_id', eventId).first();
|
||||
|
||||
await feedbackService.updateEventFeedbackSettings(eventId, {
|
||||
feedback_enabled: true,
|
||||
id: 99999,
|
||||
event_id: otherEventId,
|
||||
});
|
||||
|
||||
const after = await db('event_feedback_settings').where('event_id', eventId).first();
|
||||
expect(after.id).toBe(before.id);
|
||||
expect(after.event_id).toBe(eventId);
|
||||
expect(await db('event_feedback_settings').where('event_id', otherEventId).first()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('guest feedback-settings route is not shadowed (#1030)', () => {
|
||||
test('gallery.js does not declare GET /:slug/feedback-settings', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, '..', '..', 'src', 'routes', 'gallery.js'), 'utf8',
|
||||
);
|
||||
expect(source).not.toMatch(/router\.get\(\s*['"]\/:slug\/feedback-settings['"]/);
|
||||
});
|
||||
|
||||
test('galleryFeedback.js still serves it, including the #655 per-guest caps', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, '..', '..', 'src', 'routes', 'galleryFeedback.js'), 'utf8',
|
||||
);
|
||||
expect(source).toMatch(/router\.get\(\s*['"]\/:slug\/feedback-settings['"]/);
|
||||
expect(source).toMatch(/max_favorites_per_guest/);
|
||||
expect(source).toMatch(/max_likes_per_guest/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Regression test for #1024: quote/invoice PDF endpoints 500'd (or silently
|
||||
* corrupted the filename) for customers whose name carries non-ASCII.
|
||||
*
|
||||
* The six PDF routes built the header by interpolating buildPdfFilename()'s
|
||||
* result straight into `inline; filename="${filename}"`. HTTP header values
|
||||
* are latin1, which splits the failure in two — and the split matters,
|
||||
* because the issue reported the umlaut case as the 500 and it isn't:
|
||||
*
|
||||
* U+0080-U+00FF (ä ö ü ß — every German umlaut)
|
||||
* No throw. The byte goes out raw and the client reads back a mangled
|
||||
* name. A silent corruption, not an error.
|
||||
*
|
||||
* above U+00FF (Polish ł, Czech ř, Turkish ş, €, Cyrillic, CJK, emoji)
|
||||
* Node's setHeader rejects it with ERR_INVALID_CHAR. Because the
|
||||
* throw lands after the PDF buffer is already rendered, the whole
|
||||
* request fails as an unhandled 500.
|
||||
*
|
||||
* buildContentDisposition() fixes both: an ASCII fallback for the legacy
|
||||
* `filename=` parameter plus the RFC 5987 `filename*=UTF-8''…` form that
|
||||
* carries the real name.
|
||||
*
|
||||
* These assertions run against the real Node header validator via a live
|
||||
* express server, so they'd fail against the old interpolation rather than
|
||||
* merely testing the helper in isolation.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const { buildPdfFilename, sanitiseSegment } = require('../../src/utils/pdfFilename');
|
||||
const { buildContentDisposition } = require('../../src/utils/filenameSanitizer');
|
||||
|
||||
// The RFC 5987 parameter prefix, i.e. filename*=UTF-8'' — the two trailing
|
||||
// quotes are the (empty) language tag the spec puts between the charset and
|
||||
// the percent-encoded value.
|
||||
const RFC5987_PREFIX = 'filename*=UTF-8\'\'';
|
||||
|
||||
// Mirrors what the six PDF routes now do.
|
||||
function buildApp(customer, docNumber = 'Q-2026-0042') {
|
||||
const app = express();
|
||||
app.get('/pdf', (req, res) => {
|
||||
const filename = buildPdfFilename({ docNumber, customer, fallback: 'quote-preview' });
|
||||
res.set('Content-Type', 'application/pdf');
|
||||
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
|
||||
res.send(Buffer.from('%PDF-1.4 fake'));
|
||||
});
|
||||
// Mirrors the real error handler: an ERR_INVALID_CHAR throw inside the
|
||||
// handler surfaces as a 500, which is what #1024 reported.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => res.status(500).json({ error: err.code || err.message }));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('#1024 — PDF Content-Disposition with non-ASCII customer names', () => {
|
||||
it('serves a PDF for a German umlaut name and keeps the name intact', async () => {
|
||||
const res = await request(buildApp({ company_name: 'Müller Fotografie' })).get('/pdf');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const cd = res.headers['content-disposition'];
|
||||
// RFC 5987 form carries the real, unmangled name...
|
||||
expect(cd).toContain(RFC5987_PREFIX);
|
||||
expect(cd).toContain(encodeURIComponent('Müller-Fotografie.pdf'));
|
||||
// ...and the ASCII fallback is legal latin1 with no raw umlaut byte.
|
||||
const fallback = /filename="([^"]+)"/.exec(cd)[1];
|
||||
expect(fallback).toMatch(/^[\x20-\x7e]+$/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Polish', 'Michał Kowalski'],
|
||||
['Czech', 'Dvořák Studio'],
|
||||
['Turkish', 'Şahin Fotoğraf'],
|
||||
['Cyrillic', 'Иванов Фото'],
|
||||
['CJK', '山田写真'],
|
||||
['emoji', 'Studio 🎉 Berlin'],
|
||||
])('does not 500 for a %s customer name (was ERR_INVALID_CHAR)', async (_label, company) => {
|
||||
const res = await request(buildApp({ company_name: company })).get('/pdf');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const cd = res.headers['content-disposition'];
|
||||
expect(cd).toContain(RFC5987_PREFIX);
|
||||
// The legacy filename= token drops non-ASCII, so a name written entirely
|
||||
// in another script degrades to just the document number
|
||||
// (`Q-2026-0042_.pdf`). That's the intended trade — filename* carries the
|
||||
// real name — but the fallback must still be a legal, non-empty,
|
||||
// ASCII-only token, since that is what a client without RFC 5987 support
|
||||
// ends up saving.
|
||||
const fallback = /filename="([^"]*)"/.exec(cd)[1];
|
||||
expect(fallback.length).toBeGreaterThan(0);
|
||||
expect(fallback).toMatch(/^[\x20-\x7e]+$/);
|
||||
expect(fallback).toContain('Q-2026-0042');
|
||||
});
|
||||
|
||||
it('leaves a plain ASCII name on the familiar filename= form', async () => {
|
||||
const res = await request(buildApp({ company_name: 'Bright Studio' })).get('/pdf');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-disposition'])
|
||||
.toContain('filename="Q-2026-0042_Bright-Studio.pdf"');
|
||||
});
|
||||
|
||||
it('still works when the customer row is missing entirely (preview path)', async () => {
|
||||
const res = await request(buildApp(null, null)).get('/pdf');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-disposition']).toContain('quote-preview_customer.pdf');
|
||||
});
|
||||
|
||||
// sanitiseSegment caps each segment at 80 UTF-16 code units. A cap landing
|
||||
// inside an astral character used to leave a dangling high surrogate, which
|
||||
// makes encodeURIComponent throw URIError inside buildContentDisposition —
|
||||
// a 500 on the very endpoint this PR fixes, reached a different way.
|
||||
it.each([
|
||||
['emoji on the 80-char boundary', `${'a'.repeat(79)}🎉`],
|
||||
['astral CJK on the boundary', `${'a'.repeat(79)}𠜎`],
|
||||
['a label that is entirely astral', '🎉'.repeat(60)],
|
||||
])('does not 500 when truncation splits a surrogate pair — %s', async (_label, company) => {
|
||||
const res = await request(buildApp({ company_name: company })).get('/pdf');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-disposition']).toContain(RFC5987_PREFIX);
|
||||
});
|
||||
|
||||
it('drops the orphaned surrogate rather than widening the length cap', () => {
|
||||
const seg = sanitiseSegment(`${'a'.repeat(79)}🎉`);
|
||||
|
||||
// 79 'a's + a half-emoji would be 80; the orphan is dropped, not kept.
|
||||
expect(seg).toHaveLength(79);
|
||||
expect(seg).toBe('a'.repeat(79));
|
||||
// Nothing in the result may be an unpaired surrogate.
|
||||
expect(seg).toBe(seg.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, ''));
|
||||
});
|
||||
|
||||
it('the raw interpolation these routes used to do really does throw', () => {
|
||||
// Pins the root cause itself, so nobody "simplifies" the helper away.
|
||||
const filename = buildPdfFilename({
|
||||
docNumber: 'Q-2026-0042',
|
||||
customer: { company_name: 'Michał Kowalski' },
|
||||
});
|
||||
const res = new (require('http').ServerResponse)({});
|
||||
expect(() => res.setHeader('Content-Disposition', `inline; filename="${filename}"`))
|
||||
.toThrow(/ERR_INVALID_CHAR|Invalid character/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Regression test for clearing an event's expiration on SQLite (#1029).
|
||||
*
|
||||
* Migration 061 dropped the NOT NULL on events.event_date / events.expires_at
|
||||
* for Postgres only — it skipped SQLite on the (wrong) premise that SQLite
|
||||
* doesn't enforce NOT NULL. It does, so every SQLite install answered
|
||||
*
|
||||
* SQLITE_CONSTRAINT: NOT NULL constraint failed: events.expires_at
|
||||
*
|
||||
* when an admin cleared the expiration, surfacing as "Failed to update event".
|
||||
* Migration 174 finishes the job. The harness runs on SQLite, so this asserts
|
||||
* the real engine behaviour rather than a mock.
|
||||
*/
|
||||
|
||||
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-nullable-dates-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'nullable-dates-test-secret';
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let eventId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
const inserted = await db('events').insert({
|
||||
slug: 'nullable-dates-test',
|
||||
event_type: 'wedding',
|
||||
event_name: 'Nullable Dates Test',
|
||||
event_date: '2026-06-22',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: '/gallery/nullable-dates-test/share',
|
||||
share_token: 'nullable-dates-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];
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('events date columns are nullable on SQLite (#1029)', () => {
|
||||
test('the engine under test really is SQLite', () => {
|
||||
expect(['sqlite3', 'better-sqlite3']).toContain(db.client.config.client);
|
||||
});
|
||||
|
||||
test('clearing expires_at succeeds — this threw SQLITE_CONSTRAINT before migration 174', async () => {
|
||||
await db('events').where('id', eventId).update({ expires_at: null });
|
||||
const row = await db('events').where('id', eventId).first('expires_at');
|
||||
expect(row.expires_at).toBeNull();
|
||||
});
|
||||
|
||||
test('clearing event_date succeeds too (061 covered both columns on PG)', async () => {
|
||||
await db('events').where('id', eventId).update({ event_date: null });
|
||||
const row = await db('events').where('id', eventId).first('event_date');
|
||||
expect(row.event_date).toBeNull();
|
||||
});
|
||||
|
||||
test('a gallery can be created with no expiration at all', async () => {
|
||||
const inserted = await db('events').insert({
|
||||
slug: 'never-expires-test',
|
||||
event_type: 'other',
|
||||
event_name: 'Never Expires',
|
||||
event_date: null,
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: '/gallery/never-expires-test/share',
|
||||
share_token: 'never-expires-share',
|
||||
expires_at: null,
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const id = inserted[0]?.id ?? inserted[0];
|
||||
const row = await db('events').where('id', id).first('expires_at', 'event_date');
|
||||
expect(row.expires_at).toBeNull();
|
||||
expect(row.event_date).toBeNull();
|
||||
});
|
||||
|
||||
test('columns the events table depends on survived the table rebuild', async () => {
|
||||
// Knex implements .alter() on SQLite by recreating the table; make sure the
|
||||
// rebuild kept the row and the wider schema intact.
|
||||
const row = await db('events').where('id', eventId).first();
|
||||
expect(row.slug).toBe('nullable-dates-test');
|
||||
expect(row.share_token).toBe('nullable-dates-share');
|
||||
expect(await db.schema.hasColumn('events', 'allow_downloads')).toBe(true);
|
||||
expect(await db.schema.hasColumn('events', 'hero_photo_id')).toBe(true);
|
||||
const photos = await db('photos').where('event_id', eventId);
|
||||
expect(Array.isArray(photos)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The contract that matters here is negative: a source that disappears must
|
||||
* NOT be able to end the process (#1128).
|
||||
*
|
||||
* `fs.createReadStream` is lazy, so its ENOENT lands on a later tick, outside
|
||||
* the route's try/catch. An EventEmitter emitting 'error' with no listener
|
||||
* throws, and an uncaught throw from an I/O callback exits Node — which is how
|
||||
* one missing thumbnail tier took every gallery on the install down.
|
||||
*
|
||||
* These use a REAL fs stream over a real missing path rather than a fake
|
||||
* emitter: the point under test is the lazy-open timing, and a hand-rolled
|
||||
* mock that emits synchronously would pass while proving nothing.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { Readable } = require('stream');
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
const { pipeStreamToResponse } = require('../../src/utils/streamResponse');
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
/** Minimal Express-ish response that records what happened to it. */
|
||||
function makeRes() {
|
||||
const res = new EventEmitter();
|
||||
res.headers = { 'Content-Length': '1234', ETag: '"x"' };
|
||||
res.statusCode = 200;
|
||||
res.headersSent = false;
|
||||
res.writableEnded = false;
|
||||
res.body = null;
|
||||
res.destroyed = false;
|
||||
res.removeHeader = (h) => { delete res.headers[h]; };
|
||||
res.setHeader = (h, v) => { res.headers[h] = v; };
|
||||
res.status = (code) => { res.statusCode = code; return res; };
|
||||
res.json = (payload) => { res.body = payload; res.writableEnded = true; return res; };
|
||||
res.destroy = () => { res.destroyed = true; };
|
||||
// pipe() target surface
|
||||
res.write = () => true;
|
||||
res.end = () => { res.writableEnded = true; };
|
||||
res.on = EventEmitter.prototype.on.bind(res);
|
||||
res.emit = EventEmitter.prototype.emit.bind(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
describe('pipeStreamToResponse (#1128)', () => {
|
||||
it('turns a missing file into a 404 instead of an unhandled error', async () => {
|
||||
const missing = path.join(os.tmpdir(), `picpeak-not-here-${Date.now()}.jpg`);
|
||||
const res = makeRes();
|
||||
|
||||
pipeStreamToResponse(stream_(missing), res, { context: 'thumbnail for photo 1' });
|
||||
await settle();
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'File not found' });
|
||||
});
|
||||
|
||||
// How this test discriminates, since the failure mode is a process-level
|
||||
// one: replacing the call above with a bare `stream.pipe(res)` — what the
|
||||
// thumbnail route did — makes jest fail this suite on the unhandled 'error'
|
||||
// event before either assertion runs. Verified by doing exactly that.
|
||||
// Catching the throw with a process.on('uncaughtException') listener does
|
||||
// NOT work here and would be theatre: the runner installs its own handling,
|
||||
// so such a listener never sees it and the assertion could never fail.
|
||||
function stream_(p) { return fs.createReadStream(p); }
|
||||
|
||||
it('strips every header that described the file it can no longer send', async () => {
|
||||
const res = makeRes();
|
||||
// What the image and zip routes actually stage before streaming.
|
||||
res.headers = {
|
||||
'Content-Length': '1234',
|
||||
ETag: '"x"',
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Content-Disposition': 'attachment; filename="gallery.zip"',
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
};
|
||||
const stream = fs.createReadStream(path.join(os.tmpdir(), `gone-${Date.now()}.jpg`));
|
||||
|
||||
pipeStreamToResponse(stream, res);
|
||||
await settle();
|
||||
|
||||
expect(res.headers['Content-Length']).toBeUndefined();
|
||||
expect(res.headers.ETag).toBeUndefined();
|
||||
// Express does NOT overwrite an existing Content-Type, so leaving it makes
|
||||
// res.json() emit JSON labelled image/jpeg — or a corrupt .zip download.
|
||||
expect(res.headers['Content-Type']).toBeUndefined();
|
||||
expect(res.headers['Content-Disposition']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not let a transient 404 be cached as a broken tile', async () => {
|
||||
const res = makeRes();
|
||||
// The thumbnail route stages 30 minutes; the hero route an hour.
|
||||
res.headers = { 'Cache-Control': 'private, max-age=1800' };
|
||||
const stream = fs.createReadStream(path.join(os.tmpdir(), `gone3-${Date.now()}.jpg`));
|
||||
|
||||
pipeStreamToResponse(stream, res);
|
||||
await settle();
|
||||
|
||||
// The regeneration race is transient by definition: the tier exists moments
|
||||
// later. Caching this 404 would keep the tile broken long after the file is
|
||||
// back — the opposite of what this helper is for.
|
||||
expect(res.headers['Cache-Control']).toBe('no-store');
|
||||
});
|
||||
|
||||
it('honours a caller that wants a different missing-status', async () => {
|
||||
const res = makeRes();
|
||||
const stream = fs.createReadStream(path.join(os.tmpdir(), `gone2-${Date.now()}.zip`));
|
||||
|
||||
pipeStreamToResponse(stream, res, { missingStatus: 410 });
|
||||
await settle();
|
||||
|
||||
expect(res.statusCode).toBe(410);
|
||||
});
|
||||
|
||||
it('destroys the response instead of rewriting a status that is already sent', async () => {
|
||||
const res = makeRes();
|
||||
res.headersSent = true;
|
||||
|
||||
const stream = new Readable({ read() {} });
|
||||
pipeStreamToResponse(stream, res, { context: 'photo 9' });
|
||||
stream.emit('error', Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
await settle();
|
||||
|
||||
// Once bytes are on the wire a 404 is not available; a truncated image the
|
||||
// client would cache is worse than a broken connection.
|
||||
expect(res.destroyed).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBeNull();
|
||||
});
|
||||
|
||||
it('reports a non-ENOENT failure as a 500 rather than a 404', async () => {
|
||||
const res = makeRes();
|
||||
const stream = new Readable({ read() {} });
|
||||
|
||||
pipeStreamToResponse(stream, res);
|
||||
stream.emit('error', Object.assign(new Error('disk exploded'), { code: 'EIO' }));
|
||||
await settle();
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Failed to serve file' });
|
||||
});
|
||||
|
||||
it('releases the source when the client hangs up mid-download', async () => {
|
||||
const res = makeRes();
|
||||
let destroyed = false;
|
||||
const stream = new Readable({ read() {}, destroy(err, cb) { destroyed = true; cb(err); } });
|
||||
|
||||
pipeStreamToResponse(stream, res);
|
||||
res.emit('close');
|
||||
await settle();
|
||||
|
||||
// Otherwise an abandoned grid leaks one open fd per tile.
|
||||
expect(destroyed).toBe(true);
|
||||
});
|
||||
});
|
||||
+9
-46
@@ -1,39 +1,13 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// Database configuration for different environments
|
||||
const resolveSqliteFilename = (filenameEnv) => {
|
||||
const fallback = path.join(__dirname, './data/photo_sharing.db');
|
||||
|
||||
if (!filenameEnv) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const trimmed = String(filenameEnv).trim();
|
||||
if (!trimmed) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let resolved;
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
resolved = trimmed;
|
||||
} else if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
|
||||
resolved = path.resolve(__dirname, trimmed);
|
||||
} else {
|
||||
resolved = path.join(__dirname, trimmed);
|
||||
}
|
||||
|
||||
const normalized = path.normalize(resolved);
|
||||
const baseSuffix = path.relative(path.parse(__dirname).root, path.normalize(__dirname));
|
||||
const duplicatePattern = `${path.sep}${baseSuffix}${path.sep}${baseSuffix}`;
|
||||
|
||||
if (normalized.includes(duplicatePattern)) {
|
||||
return normalized.replace(duplicatePattern, `${path.sep}${baseSuffix}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
// Shared with the engine guard (#1038) so both resolve the identical path.
|
||||
const { resolveSqliteFilename } = require('./src/utils/sqlitePath');
|
||||
// One resolution of the PostgreSQL target for the whole application (#1038).
|
||||
// The development and production blocks used to carry different host/user/
|
||||
// database defaults, so a process that probed or migrated against one could
|
||||
// hand over to a process that opened another.
|
||||
const { pgConnectionFromEnv } = require('./src/utils/pgConnection');
|
||||
|
||||
const sqliteConnection = (filenameEnv) => ({
|
||||
filename: resolveSqliteFilename(filenameEnv)
|
||||
@@ -54,13 +28,7 @@ const baseSqliteConfig = {
|
||||
const config = {
|
||||
development: {
|
||||
client: process.env.DATABASE_CLIENT || 'sqlite3',
|
||||
connection: process.env.DATABASE_CLIENT === 'pg' ? {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_NAME || 'photo_sharing'
|
||||
} : {
|
||||
connection: process.env.DATABASE_CLIENT === 'pg' ? pgConnectionFromEnv() : {
|
||||
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||
},
|
||||
useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg',
|
||||
@@ -97,12 +65,7 @@ const config = {
|
||||
// Support both Postgres and SQLite in production based on DATABASE_CLIENT
|
||||
connection: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
|
||||
? {
|
||||
host: process.env.DB_HOST || 'db',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'picpeak',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'picpeak',
|
||||
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||
...pgConnectionFromEnv(),
|
||||
// Connection stability settings
|
||||
connectionTimeoutMillis: 30000,
|
||||
idleTimeoutMillis: 30000,
|
||||
|
||||
@@ -77,7 +77,11 @@ const DEFAULT_CSS_TEMPLATE = `/*
|
||||
|
||||
.photo-card img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
/* 100%, not a fixed pixel height: every aspect-ratio layout (masonry,
|
||||
justified, mosaic, gallery-premium) gives .photo-card a definite height
|
||||
computed from photos.width/height, and this rule's specificity (0,1,1)
|
||||
beats the .h-full utility (0,1,0) the layouts rely on — #1131. */
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -503,7 +503,9 @@ const LIQUID_GLASS_DARK = `/*
|
||||
|
||||
.photo-card img {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
/* See #1131: a fixed height here beats the layouts' .h-full utility and
|
||||
detaches the image from its aspect-ratio-sized card. */
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.4s ease, filter 0.4s ease;
|
||||
filter: brightness(0.9);
|
||||
@@ -639,7 +641,7 @@ const LIQUID_GLASS_DARK = `/*
|
||||
}
|
||||
|
||||
.photo-card img {
|
||||
height: 180px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Reduce animation complexity on mobile */
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Migration 174: make events.event_date / events.expires_at nullable on SQLite (#1029).
|
||||
*
|
||||
* Migration 061 introduced the `event_require_event_date` /
|
||||
* `event_require_expiration` settings and dropped the NOT NULL on both columns
|
||||
* — but only for Postgres. It skipped SQLite on the premise that "SQLite
|
||||
* doesn't enforce NOT NULL as strictly", which is simply untrue: clearing the
|
||||
* expiration on a SQLite install fails with
|
||||
*
|
||||
* SQLITE_CONSTRAINT: NOT NULL constraint failed: events.expires_at
|
||||
*
|
||||
* so "never expires" has never been reachable there. This finishes 061 for
|
||||
* SQLite. Knex implements .alter() on SQLite by recreating the table; migration
|
||||
* 073 already does exactly that on `events`, so the path is well-trodden here.
|
||||
*
|
||||
* Postgres is skipped — 061 already handled it, and knex's .alter() rewrites
|
||||
* the whole column definition (type, default, nullability), which would be a
|
||||
* needless rewrite of a column that is already correct.
|
||||
*/
|
||||
|
||||
function isSqlite(knex) {
|
||||
const client = knex.client.config.client;
|
||||
return client === 'sqlite3' || client === 'better-sqlite3';
|
||||
}
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!isSqlite(knex)) return;
|
||||
|
||||
const hasEvents = await knex.schema.hasTable('events');
|
||||
if (!hasEvents) return;
|
||||
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.datetime('event_date').nullable().alter();
|
||||
table.datetime('expires_at').nullable().alter();
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Deliberately irreversible. Restoring NOT NULL would fail on any install
|
||||
// that has since created a gallery without an expiration — exactly what this
|
||||
// migration enables — and 061's down() takes the same position for Postgres.
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* The bundled CSS templates pinned every gallery image to a fixed pixel
|
||||
* height, which broke every aspect-ratio layout (#1131).
|
||||
*
|
||||
* Six of the seven layouts size a tile by putting a computed pixel height on
|
||||
* `.photo-card` and letting the image fill it with `h-full`. A template rule
|
||||
* of `.photo-card img { height: 200px }` has specificity (0,1,1) and beats
|
||||
* `.h-full` at (0,1,0), so the image detached from its card: masonry rendered
|
||||
* correctly-shaped cards with a 200px image glued to the top and empty
|
||||
* background below — or, where the computed card was shorter than 200px, an
|
||||
* image taller than its own container.
|
||||
*
|
||||
* "Elegant Dark" is seeded `is_default = true`, so this was the out-of-the-box
|
||||
* result for anyone choosing any layout other than grid/timeline (where a
|
||||
* fixed square happens to look deliberate).
|
||||
*
|
||||
* Migrations 052 and 053 are corrected for fresh installs; this repairs the
|
||||
* rows already seeded. Templates are referenced by `events.css_template_id`
|
||||
* and read at serve time rather than copied onto the event, so fixing the row
|
||||
* fixes every gallery using it.
|
||||
*
|
||||
* SCOPE: every `.photo-card img` rule that carries a fixed PIXEL height, in
|
||||
* every template — not just the two we seeded, and not just their pristine
|
||||
* copies.
|
||||
*
|
||||
* That is broader than it first looks, and deliberately so. It is also not the
|
||||
* scope this started with: matching the exact seeded text missed every install
|
||||
* where the template had ever been saved through the editor, because
|
||||
* `sanitizeCSS` strips newlines. Those are the majority, and a migration that
|
||||
* silently no-ops on them while being recorded as applied is worse than none.
|
||||
*
|
||||
* The cost is that a fixed pixel height a user wrote themselves is rewritten
|
||||
* too. That is judged acceptable because there is no layout it can be right
|
||||
* for: all seven give `.photo-card` a definite height and expect the image to
|
||||
* fill it, so a pixel height on the image can only detach it from its card.
|
||||
* Anything that is not a fixed px height — %, vh, auto — is left alone, as is
|
||||
* every declaration outside a `.photo-card img` body.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Every `.photo-card img { … }` rule body, however it is spaced.
|
||||
*
|
||||
* Matching the exact seeded text does NOT work, and the reason is worth
|
||||
* stating: `sanitizeCSS` strips all control characters (cssSanitizer.js:61),
|
||||
* so the moment an admin saves a template through the editor — even only to
|
||||
* rename it or toggle it — every newline is REMOVED from the stored CSS. The
|
||||
* shipped `.photo-card img {\n height: 200px;` becomes
|
||||
* `.photo-card img { height: 200px;`. An exact-match migration would find
|
||||
* nothing on those installs, be recorded as applied, and leave the galleries
|
||||
* broken with no second chance.
|
||||
*
|
||||
* Scoped to the rule body rather than the whole stylesheet, so the other pixel
|
||||
* heights in these same templates — a 1px gradient divider, an 8px scrollbar —
|
||||
* are untouched.
|
||||
*/
|
||||
/*
|
||||
* Two details in this pattern are deliberate:
|
||||
*
|
||||
* * the selector part is a LIST, so `.photo-card img, .thumbnail img { … }`
|
||||
* is recognised. Requiring `{` straight after `img` skipped grouped
|
||||
* selectors entirely — and the migration would still be recorded as
|
||||
* applied, so the template kept the bug with no second chance.
|
||||
*
|
||||
* * the body excludes braces, so a rule containing a NESTED block is not
|
||||
* matched at all. `.photo-card img { & + .caption { height: 200px } }` is
|
||||
* valid, passes the validator, and a `[^}]*` body would have captured the
|
||||
* nested block and rewritten the caption's height instead. Skipping it
|
||||
* means such a template keeps a fixed image height; corrupting unrelated
|
||||
* declarations in a migration that cannot be undone is the worse of the
|
||||
* two, and nesting does not appear in anything we ship.
|
||||
*/
|
||||
const PHOTO_CARD_IMG_RULE = /([^{}]*\.photo-card\s+img[^{}]*)\{([^{}]*)\}/g;
|
||||
|
||||
/**
|
||||
* Only a fixed PIXEL height is wrong here; %, vh, auto and the rest stay.
|
||||
*
|
||||
* The lookbehind is load-bearing rather than defensive: without it the pattern
|
||||
* matches the TAIL of `line-height`, `max-height`, `min-height` and any custom
|
||||
* property ending in `-height`, and silently rewrites those instead — in a
|
||||
* migration whose down() is deliberately irreversible.
|
||||
*/
|
||||
const FIXED_PX_HEIGHT = /(?<![\w-])height\s*:\s*\d+(?:\.\d+)?px/gi;
|
||||
|
||||
function relaxFixedImageHeights(css) {
|
||||
return css.replace(PHOTO_CARD_IMG_RULE, (whole, selectors, body) => {
|
||||
// .test() on a /g regex advances lastIndex, so it is reset on both sides
|
||||
// of the check — leaving it set makes the NEXT rule start matching from an
|
||||
// arbitrary offset and silently skip declarations.
|
||||
FIXED_PX_HEIGHT.lastIndex = 0;
|
||||
if (!FIXED_PX_HEIGHT.test(body)) return whole;
|
||||
FIXED_PX_HEIGHT.lastIndex = 0;
|
||||
return `${selectors}{${body.replace(FIXED_PX_HEIGHT, 'height: 100%')}}`;
|
||||
});
|
||||
}
|
||||
|
||||
exports.up = async function up(knex) {
|
||||
if (!(await knex.schema.hasTable('css_templates'))) return;
|
||||
|
||||
const rows = await knex('css_templates').select('id', 'css_content');
|
||||
let fixed = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const original = row.css_content;
|
||||
if (!original || typeof original !== 'string') continue;
|
||||
|
||||
const updated = relaxFixedImageHeights(original);
|
||||
|
||||
if (updated !== original) {
|
||||
await knex('css_templates').where({ id: row.id }).update({ css_content: updated });
|
||||
fixed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (fixed > 0) {
|
||||
console.log(` 175: relaxed the fixed image height in ${fixed} CSS template(s)`);
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down() {
|
||||
// Deliberately irreversible. Putting the pixel heights back would re-break
|
||||
// every aspect-ratio layout, and the rows may have been edited since — there
|
||||
// is no version of "restore" here that is safer than doing nothing.
|
||||
};
|
||||
@@ -276,11 +276,51 @@ async function runMigrations() {
|
||||
}
|
||||
|
||||
// Add delay for database readiness in production
|
||||
// Engine consistency check (#1038). The entrypoint resolves the engine before
|
||||
// migrations run and exports DATABASE_CLIENT, so this normally agrees and does
|
||||
// nothing. It bites on a MANUAL migration run: without that env, an install
|
||||
// that is really on SQLite would resolve to Postgres here and build a schema in
|
||||
// the empty database, which then hides the SQLite data from the boot-time
|
||||
// check. Stop instead, and say which env to set.
|
||||
async function assertEngine() {
|
||||
const knexConfig = require('../knexfile');
|
||||
const logger = require('../src/utils/logger');
|
||||
const { resolveBootEngine } = require('../src/utils/databaseEngine');
|
||||
const decision = await resolveBootEngine({ knexConfig, logger });
|
||||
if (decision.reason === 'marker-target-mismatch') {
|
||||
console.error(
|
||||
'Refusing to migrate: this install was migrated to a different PostgreSQL than the\n'
|
||||
+ 'one currently configured. The resolver printed both targets above.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (decision.reason === 'ambiguous-both-populated') {
|
||||
// Both databases hold data and nothing records which is current; the
|
||||
// resolver has already printed the comparison. There is no client to
|
||||
// recommend here — the operator has to pick one.
|
||||
console.error(
|
||||
'Refusing to migrate: SQLite and PostgreSQL both hold data and neither is marked\n'
|
||||
+ 'as current. Set DATABASE_CLIENT=pg or DATABASE_CLIENT=sqlite3 to say which one\n'
|
||||
+ 'this command should touch.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (decision.client !== knexConfig.client) {
|
||||
console.error(
|
||||
`Refusing to migrate ${knexConfig.client} — this install's data is in ${decision.client}.\n`
|
||||
+ `Run migrations through the container entrypoint, or set DATABASE_CLIENT=${decision.client} explicitly.\n`
|
||||
+ 'To move the data across instead: node scripts/migrate-sqlite-to-postgres.js'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitAndRun() {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.log('Waiting 2 seconds for database readiness...');
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
await assertEngine();
|
||||
await runMigrations();
|
||||
}
|
||||
|
||||
|
||||
@@ -46,10 +46,50 @@ async function runMigration(filepath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Engine consistency check (#1038). The entrypoint resolves the engine before
|
||||
// migrations run and exports DATABASE_CLIENT, so this normally agrees and does
|
||||
// nothing. It bites on a MANUAL migration run: without that env, an install
|
||||
// that is really on SQLite would resolve to Postgres here and build a schema in
|
||||
// the empty database, which then hides the SQLite data from the boot-time
|
||||
// check. Stop instead, and say which env to set.
|
||||
async function assertEngine() {
|
||||
const knexConfig = require('../knexfile');
|
||||
const logger = require('../src/utils/logger');
|
||||
const { resolveBootEngine } = require('../src/utils/databaseEngine');
|
||||
const decision = await resolveBootEngine({ knexConfig, logger });
|
||||
if (decision.reason === 'marker-target-mismatch') {
|
||||
console.error(
|
||||
'Refusing to migrate: this install was migrated to a different PostgreSQL than the\n'
|
||||
+ 'one currently configured. The resolver printed both targets above.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (decision.reason === 'ambiguous-both-populated') {
|
||||
// Both databases hold data and nothing records which is current; the
|
||||
// resolver has already printed the comparison. There is no client to
|
||||
// recommend here — the operator has to pick one.
|
||||
console.error(
|
||||
'Refusing to migrate: SQLite and PostgreSQL both hold data and neither is marked\n'
|
||||
+ 'as current. Set DATABASE_CLIENT=pg or DATABASE_CLIENT=sqlite3 to say which one\n'
|
||||
+ 'this command should touch.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (decision.client !== knexConfig.client) {
|
||||
console.error(
|
||||
`Refusing to migrate ${knexConfig.client} — this install's data is in ${decision.client}.\n`
|
||||
+ `Run migrations through the container entrypoint, or set DATABASE_CLIENT=${decision.client} explicitly.\n`
|
||||
+ 'To move the data across instead: node scripts/migrate-sqlite-to-postgres.js'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Main migration runner
|
||||
async function runMigrations() {
|
||||
try {
|
||||
console.log('Starting database migrations...');
|
||||
await assertEngine();
|
||||
|
||||
// First run the init.js if it exists but only if migrations table doesn't exist
|
||||
const tableExists = await db.schema.hasTable('migrations');
|
||||
|
||||
Generated
+21
-11
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.45.13",
|
||||
"version": "3.46.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.45.13",
|
||||
"version": "3.46.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -5315,9 +5315,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge-ts": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz",
|
||||
"integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "ko-fi",
|
||||
"url": "https://ko-fi.com/rebeccastevens"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
@@ -7981,9 +7991,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9076,9 +9086,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.45.14",
|
||||
"version": "3.46.4",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"engines": {
|
||||
@@ -93,6 +93,7 @@
|
||||
"@tootallnate/once": ">=3.0.1",
|
||||
"ip-address": ">=10.3.1",
|
||||
"uuid": "^11.1.1",
|
||||
"nodemailer": "^9.0.1"
|
||||
"nodemailer": "^9.0.1",
|
||||
"deepmerge-ts": ">=8.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Move an install's data from SQLite to PostgreSQL (#1038).
|
||||
*
|
||||
* node scripts/migrate-sqlite-to-postgres.js [--force] [--keep-archive]
|
||||
*
|
||||
* For installs that have been unknowingly running on SQLite: the image used to
|
||||
* leave NODE_ENV unset, so knexfile.js fell back to its development block and
|
||||
* ignored DB_HOST/DB_USER/DB_PASSWORD. Their galleries live in the SQLite file
|
||||
* while the Postgres database they provisioned sits empty.
|
||||
*
|
||||
* This deliberately reuses the .picpeak export/import services rather than
|
||||
* hand-rolling a cross-engine copy — they already solve the parts that are easy
|
||||
* to get wrong: foreign-key suspension during the load, JSON column handling
|
||||
* per engine, and (critically) resyncing Postgres serial sequences after rows
|
||||
* are inserted with explicit ids.
|
||||
*
|
||||
* Both services bind to the global `db` at require time, so each half runs in
|
||||
* its own child process with DATABASE_CLIENT pinned — this script re-invokes
|
||||
* itself with --phase for that.
|
||||
*
|
||||
* Photos and other files on disk are NOT touched: only database rows move. The
|
||||
* SQLite file is left exactly as it was, so the migration is reversible by
|
||||
* unsetting DATABASE_CLIENT again.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const BACKEND_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// Same configuration sources the running backend uses. Without these, invoking
|
||||
// this CLI directly (or via `docker exec`, which does not inherit the exports
|
||||
// wait-for-db.sh performs) would fail the pre-flight checks below even though
|
||||
// the child phases would happily read backend/.env through knexfile.
|
||||
require('dotenv').config({ path: path.join(BACKEND_ROOT, '.env') });
|
||||
for (const [varName, file] of [['DB_PASSWORD', 'db_password'], ['JWT_SECRET', 'jwt_secret']]) {
|
||||
const secretFile = `/run/secrets/${file}`;
|
||||
if (!process.env[varName] && fs.existsSync(secretFile)) {
|
||||
try {
|
||||
process.env[varName] = fs.readFileSync(secretFile, 'utf8').trim();
|
||||
} catch (_) { /* unreadable secret — the checks below report it */ }
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
return {
|
||||
force: argv.includes('--force'),
|
||||
keepArchive: argv.includes('--keep-archive'),
|
||||
phase: (argv.find((a) => a.startsWith('--phase=')) || '').split('=')[1] || null,
|
||||
archive: (argv.find((a) => a.startsWith('--archive=')) || '').split('=')[1] || null,
|
||||
resultFile: (argv.find((a) => a.startsWith('--result-file=')) || '').split('=')[1] || null,
|
||||
ignoreBootstrapAdmins: argv.includes('--ignore-bootstrap-admins'),
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve the Postgres target ONCE, with production defaults, and hand the same
|
||||
// explicit values to every child. Otherwise the block knexfile happens to pick
|
||||
// decides the database name, and the migration can land somewhere the running
|
||||
// application will never open (#1038 review).
|
||||
function normalisedPgEnv() {
|
||||
const { pgConnectionFromEnv } = require('../src/utils/databaseEngine');
|
||||
const c = pgConnectionFromEnv();
|
||||
return {
|
||||
DB_HOST: String(c.host),
|
||||
DB_PORT: String(c.port),
|
||||
DB_USER: String(c.user),
|
||||
DB_NAME: String(c.database),
|
||||
};
|
||||
}
|
||||
|
||||
function runPhase(phase, client, extraArgs = []) {
|
||||
// The child's stdout is NOT a private channel: winston logs to the console
|
||||
// outside production and whenever LOG_TO_CONSOLE=true, so the payload comes
|
||||
// back through a file instead.
|
||||
const resultFile = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), `picpeak-phase-${phase}-`)), 'result',
|
||||
);
|
||||
try {
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
[__filename, `--phase=${phase}`, `--result-file=${resultFile}`, ...extraArgs],
|
||||
{
|
||||
cwd: BACKEND_ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
...normalisedPgEnv(),
|
||||
DATABASE_CLIENT: client,
|
||||
// Production semantics for the child regardless of how the CLI was
|
||||
// invoked: the development block ignores DB_SSL, so a managed Postgres
|
||||
// that requires TLS could not be migrated into at all.
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
if (res.status !== 0) {
|
||||
throw new Error(`${phase} phase failed (exit ${res.status})`);
|
||||
}
|
||||
return fs.existsSync(resultFile) ? fs.readFileSync(resultFile, 'utf8').trim() : '';
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(resultFile), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── phases (each runs in its own process, with DATABASE_CLIENT pinned) ────────
|
||||
|
||||
async function phaseExport() {
|
||||
const { createPicpeak } = require('../src/services/picpeakExportService');
|
||||
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sqlite-migration-'));
|
||||
// Rows only. This moves an install between engines on the SAME machine, so
|
||||
// every file is already where it belongs; hauling business docs through /tmp
|
||||
// would just risk filling the temp disk.
|
||||
try {
|
||||
const { filePath } = await createPicpeak({ includePhotos: false, includeFiles: false, outDir });
|
||||
return filePath;
|
||||
} catch (err) {
|
||||
// createPicpeak leaves a caller-supplied outDir alone on failure, and a
|
||||
// partial archive still contains password hashes and credentials.
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Tables that are EMPTY on a freshly migrated schema, so any row in them means
|
||||
// a human has used this install. Used to protect the target from being wiped
|
||||
// and to decide whether the source is worth migrating (#1038 review). Tables
|
||||
// missing on a given branch are skipped.
|
||||
const USER_DATA_TABLES = [
|
||||
'events', 'photos', 'photo_feedback', 'admin_users', 'customer_accounts',
|
||||
'quotes', 'invoices', 'projects', 'expenses', 'inbound_documents',
|
||||
];
|
||||
|
||||
async function tablesWithData(db, tables, { ignoreBootstrapAdmins = false } = {}) {
|
||||
const { adminsIndicateUse } = require('../src/utils/databaseEngine');
|
||||
const found = {};
|
||||
for (const table of tables) {
|
||||
if (!(await db.schema.hasTable(table))) continue;
|
||||
if (table === 'admin_users' && ignoreBootstrapAdmins) {
|
||||
// Match probePgData: one never-used seeded admin is not "user data", or
|
||||
// the migration would demand --force against an empty target.
|
||||
const cols = ['must_change_password'];
|
||||
if (await db.schema.hasColumn('admin_users', 'last_login')) cols.push('last_login');
|
||||
const rows = await db('admin_users').select(cols);
|
||||
if (adminsIndicateUse(rows)) found[table] = rows.length;
|
||||
continue;
|
||||
}
|
||||
const row = await db(table).count('* as count').first();
|
||||
const count = Number(row?.count || 0);
|
||||
if (count > 0) found[table] = count;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async function phaseUserData(ignoreBootstrapAdmins) {
|
||||
const { db } = require('../src/database/db');
|
||||
return JSON.stringify(await tablesWithData(db, USER_DATA_TABLES, { ignoreBootstrapAdmins }));
|
||||
}
|
||||
|
||||
// Fingerprint EVERY table the export carries, not a hand-picked few: writes to
|
||||
// an unlisted table were invisible, and count+maxId alone misses in-place
|
||||
// UPDATEs (an event edit, a password change). max(updated_at) covers those
|
||||
// wherever the column exists. Still not a substitute for stopping the backend —
|
||||
// a table with neither `id` nor `updated_at` can be edited unnoticed — which is
|
||||
// why the script says so up front.
|
||||
async function phaseFingerprint() {
|
||||
const { db } = require('../src/database/db');
|
||||
const { listDataTables } = require('../src/services/picpeakExportService');
|
||||
const out = {};
|
||||
for (const table of await listDataTables()) {
|
||||
const entry = {};
|
||||
try {
|
||||
entry.count = Number((await db(table).count('* as count').first())?.count || 0);
|
||||
} catch (_) {
|
||||
continue; // table vanished mid-run; the export would fail on it anyway
|
||||
}
|
||||
for (const [key, col] of [['maxId', 'id'], ['maxUpdated', 'updated_at']]) {
|
||||
try {
|
||||
const row = await db(table).max(`${col} as v`).first();
|
||||
if (row && row.v !== null && row.v !== undefined) entry[key] = String(row.v);
|
||||
} catch (_) { /* column doesn't exist on this table */ }
|
||||
}
|
||||
out[table] = entry;
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
|
||||
async function phaseMigrateSchema() {
|
||||
// runMigrations() exits the process itself (0 on success, 1 on failure), so the
|
||||
// child's exit code is the result — nothing to return.
|
||||
const { runMigrations } = require('../migrations/run-migrations-safe');
|
||||
await runMigrations();
|
||||
}
|
||||
|
||||
async function phaseImport(archivePath) {
|
||||
const { importFromPicpeak } = require('../src/services/picpeakImportService');
|
||||
// No currentAdminId: this is a CLI, there is no operator session to preserve.
|
||||
// The SQLite install's own admin accounts come across with everything else.
|
||||
// sqlite → pg is allowed by validateManifest's direction policy (#1041) —
|
||||
// the same gate the upload/restore UI uses, no separate opt-in flag.
|
||||
const summary = await importFromPicpeak({ picpeakPath: archivePath });
|
||||
return JSON.stringify(summary || {});
|
||||
}
|
||||
|
||||
function summariseUserData(found) {
|
||||
return Object.entries(found).map(([t, n]) => `${t}=${n}`).join(', ');
|
||||
}
|
||||
|
||||
function describeDrift(before, after) {
|
||||
const drifted = [];
|
||||
for (const table of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||||
const a = before[table] || {};
|
||||
const b = after[table] || {};
|
||||
if (a.count !== b.count) {
|
||||
drifted.push(`${table}: ${a.count ?? 0} rows → ${b.count ?? 0}`);
|
||||
} else if (a.maxId !== b.maxId || a.maxUpdated !== b.maxUpdated) {
|
||||
drifted.push(`${table}: rows edited in place (max id ${a.maxId ?? '-'} → ${b.maxId ?? '-'}, `
|
||||
+ `last update ${a.maxUpdated ?? '-'} → ${b.maxUpdated ?? '-'})`);
|
||||
}
|
||||
}
|
||||
return drifted;
|
||||
}
|
||||
|
||||
// Set once the export exists; every failure path clears it (the archive holds
|
||||
// plaintext secrets, so leaving it behind on error is not acceptable).
|
||||
let archiveToClean = null;
|
||||
|
||||
function cleanupArchive() {
|
||||
if (!archiveToClean) return;
|
||||
try {
|
||||
fs.rmSync(path.dirname(archiveToClean), { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
console.error(` WARNING: could not remove ${archiveToClean} (${err.message}) — it contains`
|
||||
+ ' plaintext secrets, delete it by hand.');
|
||||
}
|
||||
archiveToClean = null;
|
||||
}
|
||||
|
||||
// ── orchestration ────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
// Child phase. The knex pool holds the event loop open, so finish by flushing
|
||||
// stdout and exiting explicitly — otherwise the parent's spawnSync waits on a
|
||||
// process that will never end by itself.
|
||||
if (args.phase) {
|
||||
const payload = args.phase === 'export' ? await phaseExport()
|
||||
: args.phase === 'fingerprint' ? await phaseFingerprint()
|
||||
: args.phase === 'user-data' ? await phaseUserData(args.ignoreBootstrapAdmins)
|
||||
: args.phase === 'import' ? await phaseImport(args.archive)
|
||||
: await phaseMigrateSchema();
|
||||
if (args.resultFile) fs.writeFileSync(args.resultFile, String(payload ?? ''));
|
||||
// The knex pool holds the event loop open; exit explicitly or the parent's
|
||||
// spawnSync waits on a process that will never end by itself.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { resolveSqlitePath } = require('../src/utils/databaseEngine');
|
||||
const sqlitePath = resolveSqlitePath();
|
||||
|
||||
console.log('PicPeak — SQLite → PostgreSQL migration\n');
|
||||
|
||||
if (!fs.existsSync(sqlitePath)) {
|
||||
console.error(`No SQLite database at ${sqlitePath}. Nothing to migrate.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (process.env.DATABASE_CLIENT && process.env.DATABASE_CLIENT !== 'pg') {
|
||||
console.error(
|
||||
`This deployment pins DATABASE_CLIENT=${process.env.DATABASE_CLIENT}.\n`
|
||||
+ 'After the migration the application must run on PostgreSQL — the SQLite file is\n'
|
||||
+ 'renamed out of the way, so a restart with this setting would create a NEW, empty\n'
|
||||
+ 'SQLite database and serve that instead of your data.\n\n'
|
||||
+ 'Set DATABASE_CLIENT=pg (or remove it) in your deployment, then run this again.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Not a refusal: an unset NODE_ENV is exactly the state the affected installs
|
||||
// are in, and refusing would block the people this script is for. The success
|
||||
// marker makes the boot resolve to Postgres regardless; this just tells the
|
||||
// operator to make it explicit.
|
||||
if (!process.env.DATABASE_CLIENT && require('../knexfile').client !== 'pg') {
|
||||
console.log(
|
||||
'Note: this environment resolves to SQLite (NODE_ENV is not "production" and\n'
|
||||
+ 'DATABASE_CLIENT is unset). The migration will still complete and the marker it\n'
|
||||
+ 'writes makes the app use PostgreSQL afterwards, but set NODE_ENV=production (or\n'
|
||||
+ 'DATABASE_CLIENT=pg) so the configuration says what is actually happening.\n'
|
||||
);
|
||||
}
|
||||
|
||||
if (!process.env.DB_HOST && !process.env.DB_PASSWORD) {
|
||||
console.error(
|
||||
'No PostgreSQL settings found (DB_HOST / DB_PASSWORD). Set them the way the\n'
|
||||
+ 'backend does, then re-run this script inside the container.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
'Stop the backend before running this. If it keeps serving while the copy runs,\n'
|
||||
+ 'anything written after the export is left behind in SQLite and becomes invisible\n'
|
||||
+ 'once the engine switches. This script checks for that afterwards and fails loudly,\n'
|
||||
+ 'but stopping the container first is the only way to be sure.\n'
|
||||
);
|
||||
|
||||
const sourceData = JSON.parse(runPhase('user-data', 'sqlite3'));
|
||||
console.log(` source : ${sqlitePath} — ${summariseUserData(sourceData) || 'no user data'}`);
|
||||
if (!Object.keys(sourceData).length) {
|
||||
console.error(
|
||||
'\nThe SQLite database holds no user data at all (no galleries, admins, customers or\n'
|
||||
+ 'accounting records). There is nothing to migrate.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const sqliteBefore = JSON.parse(runPhase('fingerprint', 'sqlite3'));
|
||||
|
||||
// Read the target BEFORE creating the schema: migration 001 seeds a bootstrap
|
||||
// admin when ADMIN_PASSWORD is set (common on legacy installs), and counting
|
||||
// that as "user data" would refuse a migration into a genuinely empty
|
||||
// database — pushing the operator towards --force for no reason.
|
||||
const { hasMigrationInProgress, migrationInProgressPath } = require('../src/utils/databaseEngine');
|
||||
// The retry allowance is bound to the TARGET, not just to this SQLite file:
|
||||
// if the operator repointed DB_HOST/DB_NAME since the failed attempt, the
|
||||
// rows in front of us belong to some other database and must not be replaced
|
||||
// without an explicit --force.
|
||||
const pgEnv = normalisedPgEnv();
|
||||
const targetId = `${pgEnv.DB_HOST}:${pgEnv.DB_PORT}/${pgEnv.DB_NAME}`;
|
||||
let retryingOwnRun = false;
|
||||
if (hasMigrationInProgress(sqlitePath)) {
|
||||
try {
|
||||
const pin = JSON.parse(fs.readFileSync(migrationInProgressPath(sqlitePath), 'utf8'));
|
||||
retryingOwnRun = pin.target === targetId;
|
||||
if (!retryingOwnRun) {
|
||||
console.log(` (an earlier attempt targeted ${pin.target}; this run targets ${targetId})`);
|
||||
}
|
||||
} catch (_) {
|
||||
retryingOwnRun = false; // unreadable pin — treat as unknown, require --force
|
||||
}
|
||||
}
|
||||
const targetData = JSON.parse(runPhase('user-data', 'pg', ['--ignore-bootstrap-admins']));
|
||||
console.log(` target : postgres — ${summariseUserData(targetData) || 'empty'}`);
|
||||
if (retryingOwnRun && Object.keys(targetData).length) {
|
||||
// Whatever is in Postgres came from a previous attempt of THIS script that
|
||||
// never completed — re-running is the documented recovery, so don't make
|
||||
// the operator reach for a destructive-sounding flag to do it.
|
||||
console.log(' (an earlier migration did not finish; re-running replaces what it left behind)');
|
||||
} else if (Object.keys(targetData).length && !args.force) {
|
||||
console.error(
|
||||
`\nPostgreSQL already holds user data (${summariseUserData(targetData)}).\n`
|
||||
+ 'The import REPLACES every table, so this would delete it — including admins,\n'
|
||||
+ 'customers and accounting records that have no galleries attached.\n'
|
||||
+ 'Re-run with --force only if you are certain you want that data gone.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pin the boot to SQLite for the duration. Everything below writes to
|
||||
// Postgres — schema creation alone seeds a bootstrap admin when
|
||||
// ADMIN_PASSWORD is set — and a run that dies half way would otherwise leave
|
||||
// Postgres looking occupied enough for the next restart to switch to it.
|
||||
const inProgress = migrationInProgressPath(sqlitePath);
|
||||
fs.writeFileSync(inProgress, JSON.stringify({
|
||||
started_at: new Date().toISOString(),
|
||||
target: targetId,
|
||||
}, null, 2));
|
||||
|
||||
// Now build the schema — the import replaces table CONTENTS, it never creates
|
||||
// them, and a fresh database has no tables at all.
|
||||
//
|
||||
// core/001_init.js writes data/ADMIN_CREDENTIALS.txt when ADMIN_PASSWORD is
|
||||
// set, and that data directory belongs to the SOURCE install — so bootstrapping
|
||||
// the schema would replace the operator's real credentials file with ones for
|
||||
// a temporary admin the import then discards. Preserve it across the phase.
|
||||
const credFile = path.join(BACKEND_ROOT, 'data', 'ADMIN_CREDENTIALS.txt');
|
||||
const credBefore = fs.existsSync(credFile) ? fs.readFileSync(credFile) : null;
|
||||
console.log('\n Preparing PostgreSQL schema…');
|
||||
try {
|
||||
runPhase('migrate-schema', 'pg');
|
||||
} finally {
|
||||
if (credBefore !== null) fs.writeFileSync(credFile, credBefore);
|
||||
else fs.rmSync(credFile, { force: true });
|
||||
}
|
||||
|
||||
console.log('\n Exporting rows from SQLite…');
|
||||
const archive = runPhase('export', 'sqlite3');
|
||||
// From here on, every exit path must remove the archive: it holds password
|
||||
// hashes, SMTP credentials and API keys in plaintext.
|
||||
archiveToClean = args.keepArchive ? null : archive;
|
||||
const sizeMb = (fs.statSync(archive).size / 1024 / 1024).toFixed(1);
|
||||
console.log(` archive: ${archive} (${sizeMb} MB)`);
|
||||
|
||||
// Check BEFORE touching Postgres: if the backend wrote to SQLite while the
|
||||
// export ran, the snapshot is already incomplete and there is no reason to
|
||||
// load it. Bailing here leaves Postgres exactly as it was.
|
||||
const driftDuringExport = describeDrift(sqliteBefore, JSON.parse(runPhase('fingerprint', 'sqlite3')));
|
||||
if (driftDuringExport.length) {
|
||||
console.error(
|
||||
'\nSQLite CHANGED WHILE THE EXPORT RAN — the backend is still writing to it:\n'
|
||||
+ driftDuringExport.map((d) => ` ${d}`).join('\n')
|
||||
+ '\n\nNothing was loaded into Postgres, and this install stays pinned to SQLite\n'
|
||||
+ 'until a run completes. Stop the backend and run this again.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n Loading into PostgreSQL…');
|
||||
runPhase('import', 'pg', [`--archive=${archive}`]);
|
||||
|
||||
// And again afterwards: writes can also land while the load runs, and those
|
||||
// rows would vanish from view the moment the engine switches.
|
||||
const driftDuringImport = describeDrift(sqliteBefore, JSON.parse(runPhase('fingerprint', 'sqlite3')));
|
||||
if (driftDuringImport.length) {
|
||||
console.error(
|
||||
'\nSQLite CHANGED WHILE THE IMPORT RAN — the backend is still writing to it:\n'
|
||||
+ driftDuringImport.map((d) => ` ${d}`).join('\n')
|
||||
+ '\n\nPostgres now holds an incomplete copy. Your SQLite data is intact and stays\n'
|
||||
+ 'the one being served — the boot is pinned to it until a run completes. Stop the\n'
|
||||
+ 'backend and run this again; the import replaces every table, so re-running is safe.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Row-for-row comparison of the whole database, not just galleries: every
|
||||
// table the export carried must have arrived with the same row count.
|
||||
const targetAfter = JSON.parse(runPhase('fingerprint', 'pg'));
|
||||
// Only a SHORTFALL is a problem. The import legitimately adds rows of its own
|
||||
// afterwards — setSessionsValidAfter() writes an app_settings row so tokens
|
||||
// minted before the restore stop authenticating — and a target that gained
|
||||
// rows has not lost anything.
|
||||
const missing = [];
|
||||
const gained = [];
|
||||
const skipped = [];
|
||||
for (const [table, src] of Object.entries(sqliteBefore)) {
|
||||
const dst = targetAfter[table];
|
||||
if (!dst) {
|
||||
// SQLite-only tables exist: initializeDatabase() builds an `events_new`
|
||||
// scratch table and, if its legacy copy throws, the catch leaves the empty
|
||||
// table behind (db.js). The importer correctly skips tables Postgres does
|
||||
// not have — so an ABSENT table only matters if it actually held rows.
|
||||
// Flagging empty ones failed the whole migration after the data had
|
||||
// already landed, leaving the install pinned to SQLite forever.
|
||||
if (src.count > 0) missing.push(`${table}: ${src.count} rows, no such table in Postgres`);
|
||||
else skipped.push(table);
|
||||
continue;
|
||||
}
|
||||
if (dst.count < src.count) missing.push(`${table}: ${src.count} rows → ${dst.count}`);
|
||||
else if (dst.count > src.count) gained.push(`${table}: ${src.count} → ${dst.count}`);
|
||||
}
|
||||
if (skipped.length) {
|
||||
console.log(` (empty SQLite-only tables with no Postgres counterpart, skipped: ${skipped.join(', ')})`);
|
||||
}
|
||||
if (gained.length) console.log(` (rows added by the import itself: ${gained.join(', ')})`);
|
||||
console.log(`\n PostgreSQL now holds ${summariseUserData(JSON.parse(runPhase('user-data', 'pg')))}.`);
|
||||
|
||||
if (missing.length) {
|
||||
console.error(
|
||||
'\nROW COUNTS DO NOT MATCH — Postgres did not receive everything:\n'
|
||||
+ missing.map((m) => ` ${m}`).join('\n')
|
||||
+ '\n\nYour SQLite data is untouched and stays the one being served — the boot is\n'
|
||||
+ 'pinned to it until a run completes. Report this with the list above.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pin the engine choice so a later "Postgres looks empty" moment can never
|
||||
// send the install back to this now-stale file.
|
||||
const { migrationMarkerPath } = require('../src/utils/databaseEngine');
|
||||
const marker = migrationMarkerPath(sqlitePath);
|
||||
const retired = `${sqlitePath}.pre-postgres-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
||||
|
||||
// Marker FIRST, rename second. The other order has a window where a failure
|
||||
// (a full disk, say) leaves the source renamed away with no success marker:
|
||||
// the next run reports "No SQLite database", the in-progress pin is still
|
||||
// there, and the operator never sees the rollback path. Writing the marker
|
||||
// first means a failure here leaves everything exactly where it was.
|
||||
fs.writeFileSync(marker, JSON.stringify({
|
||||
migrated_at: new Date().toISOString(),
|
||||
retired_sqlite_file: null,
|
||||
target: targetId,
|
||||
}, null, 2));
|
||||
|
||||
let retiredTo = null;
|
||||
try {
|
||||
fs.renameSync(sqlitePath, retired);
|
||||
retiredTo = retired;
|
||||
fs.writeFileSync(marker, JSON.stringify({
|
||||
migrated_at: new Date().toISOString(),
|
||||
retired_sqlite_file: retiredTo,
|
||||
target: targetId,
|
||||
}, null, 2));
|
||||
} catch (err) {
|
||||
// The marker already pins the engine to Postgres, so leaving the file in
|
||||
// place is safe — it just is not renamed out of the way.
|
||||
console.log(` (could not rename the SQLite file: ${err.message} — leaving it in place)`);
|
||||
}
|
||||
// Success — release the pin. Order matters: the success marker exists before
|
||||
// the pin is dropped, so no restart in between can pick the wrong engine.
|
||||
fs.rmSync(inProgress, { force: true });
|
||||
|
||||
if (args.keepArchive) {
|
||||
console.log(` archive kept at ${archive} — it contains plaintext secrets, delete it when done`);
|
||||
} else {
|
||||
cleanupArchive();
|
||||
}
|
||||
|
||||
console.log(`
|
||||
Done. Your data is now in PostgreSQL.
|
||||
|
||||
rollback copy : ${retiredTo || sqlitePath}
|
||||
marker : ${marker}
|
||||
|
||||
Restart the container to pick up PostgreSQL. Keep the rollback copy until you
|
||||
have confirmed the galleries look right.
|
||||
|
||||
To roll back, all three steps are needed — with data on both sides the boot
|
||||
picks PostgreSQL, so restoring the file alone changes nothing:
|
||||
|
||||
1. rm ${marker}
|
||||
2. mv ${retiredTo || sqlitePath} ${sqlitePath}
|
||||
3. set DATABASE_CLIENT=sqlite3 in your deployment
|
||||
`);
|
||||
}
|
||||
|
||||
process.on('exit', cleanupArchive);
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`\nMigration failed: ${err.message}`);
|
||||
console.error('Nothing was changed in SQLite; your data is still there.');
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Prints the database client this boot should use — `pg` or `sqlite3` — for
|
||||
* wait-for-db.sh to export as DATABASE_CLIENT (#1038).
|
||||
*
|
||||
* Runs BEFORE the migration step on purpose: the decision has to be made while
|
||||
* the Postgres target is still untouched, so an install that has been
|
||||
* unknowingly running on SQLite keeps serving from its SQLite file instead of
|
||||
* coming up against an empty database.
|
||||
*
|
||||
* stdout is the client and nothing else — the caller captures it. Everything
|
||||
* human-readable goes to stderr so it lands in the container log.
|
||||
*/
|
||||
|
||||
const knexConfig = require('../knexfile');
|
||||
|
||||
// Must cover every level resolveBootEngine uses. An incomplete shim threw
|
||||
// inside the conflict path, was swallowed by the catch below, and fell back to
|
||||
// the configured client — silently choosing the engine this is meant to refuse
|
||||
// to choose.
|
||||
const logger = {
|
||||
info: (m) => process.stderr.write(`${m}\n`),
|
||||
warn: (m) => process.stderr.write(`${m}\n`),
|
||||
error: (m) => process.stderr.write(`${m}\n`),
|
||||
debug: () => {},
|
||||
};
|
||||
|
||||
// Distinct exit code for "two populated databases, no record of which is
|
||||
// current" (#1038). Callers must stop rather than pick one.
|
||||
const CONFLICT_EXIT = 3;
|
||||
|
||||
(async () => {
|
||||
let client = knexConfig.client;
|
||||
try {
|
||||
const { resolveBootEngine } = require('../src/utils/databaseEngine');
|
||||
const decision = await resolveBootEngine({ knexConfig, logger });
|
||||
if (decision.reason === 'ambiguous-both-populated'
|
||||
|| decision.reason === 'marker-target-mismatch') {
|
||||
process.exit(CONFLICT_EXIT);
|
||||
}
|
||||
({ client } = decision);
|
||||
} catch (err) {
|
||||
// Never let engine detection stop a boot: fall back to whatever knexfile
|
||||
// resolved, which is exactly the behaviour before this script existed.
|
||||
logger.warn(`Database engine detection failed (${err.message}); using ${client}`);
|
||||
}
|
||||
process.stdout.write(String(client || ''));
|
||||
process.exit(0);
|
||||
})();
|
||||
@@ -14,17 +14,14 @@ const bcrypt = require('bcrypt');
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const knex = require('knex');
|
||||
const db = knex({
|
||||
client: process.env.DB_CLIENT || 'pg',
|
||||
connection: {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'picpeak',
|
||||
password: process.env.DB_PASSWORD || 'picpeak',
|
||||
database: process.env.DB_NAME || 'picpeak_dev'
|
||||
}
|
||||
});
|
||||
// Use the application's own connection, like every sibling script here
|
||||
// (reset-admin-password, create-admin, show-admin-credentials, reset-admin-mfa).
|
||||
// This file used to hand-roll its own knex config, which meant: it read
|
||||
// DB_CLIENT — a variable nothing else in the codebase sets — and so defaulted
|
||||
// to Postgres on SQLite installs; and it defaulted to database `picpeak_dev`,
|
||||
// a name no other component uses. Setting a password could therefore silently
|
||||
// target a different database than the one the application serves (#1038).
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
/**
|
||||
* Validate password strength
|
||||
@@ -111,8 +108,10 @@ async function setAdminPassword() {
|
||||
.where('username', 'admin')
|
||||
.update({
|
||||
password_hash: hashedPassword,
|
||||
password_changed_at: new Date(),
|
||||
updated_at: new Date()
|
||||
// ISO strings, not Date objects — they round-trip on both engines, and
|
||||
// this script now runs on SQLite installs too.
|
||||
password_changed_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (updated === 0) {
|
||||
|
||||
@@ -4,11 +4,61 @@ require('dotenv').config();
|
||||
const { validateEnvironment } = require('./src/config/validateEnv');
|
||||
validateEnvironment();
|
||||
|
||||
// Resolve which database engine this process should use, BEFORE anything
|
||||
// requires knexfile/db (#1038). wait-for-db.sh normally does this and exports
|
||||
// DATABASE_CLIENT, but a Kubernetes manifest that sets `command`/`args`, or a
|
||||
// plain `docker run … node server.js`, bypasses the entrypoint entirely — and
|
||||
// those are exactly the deployments this fix is for. Without this, such an
|
||||
// install would resolve to Postgres (NODE_ENV is baked into the image now) and
|
||||
// come up against an empty database while its SQLite data sat there unseen.
|
||||
//
|
||||
// spawnSync because the decision needs an async Postgres probe and this must
|
||||
// happen before the first `require` of knexfile. It short-circuits without
|
||||
// probing when DATABASE_CLIENT is already set, so the entrypoint path pays
|
||||
// nothing.
|
||||
// Also run it when a migration pin exists: an explicit DATABASE_CLIENT=pg
|
||||
// would otherwise skip the check and start against a half-migrated Postgres
|
||||
// while SQLite is still the database of record.
|
||||
if (!process.env.DATABASE_CLIENT
|
||||
|| require('./src/utils/databaseEngine').hasMigrationInProgress()) {
|
||||
const { spawnSync } = require('child_process');
|
||||
const probe = spawnSync(
|
||||
process.execPath,
|
||||
[require('path').join(__dirname, 'scripts', 'resolve-db-engine.js')],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }
|
||||
);
|
||||
// Exit 3: two populated databases and no record of which is authoritative.
|
||||
// The resolver has printed the comparison and the two ways to resolve it;
|
||||
// starting either engine would hide the other's data.
|
||||
if (probe.status === 3) {
|
||||
process.exit(1);
|
||||
}
|
||||
const resolved = (probe.stdout || '').trim();
|
||||
if (probe.status === 0 && resolved) {
|
||||
process.env.DATABASE_CLIENT = resolved;
|
||||
// Pin the CONNECTION too, not just the client. knexfile's development block
|
||||
// defaults Postgres to localhost/postgres/photo_sharing and production to
|
||||
// db/picpeak/picpeak, so naming only the client can point this process at a
|
||||
// different database than the resolver probed — with SQLite already retired.
|
||||
if (resolved === 'pg') {
|
||||
const conn = require('./src/utils/databaseEngine').pgConnectionFromEnv();
|
||||
process.env.DB_HOST = String(conn.host);
|
||||
process.env.DB_PORT = String(conn.port);
|
||||
process.env.DB_USER = String(conn.user);
|
||||
process.env.DB_NAME = String(conn.database);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize logger early to capture startup logs
|
||||
const logger = require('./src/utils/logger');
|
||||
logger.info('Server starting up', {
|
||||
nodeVersion: process.version,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
// Which database this process actually talks to (#1038). Nothing logged this
|
||||
// before, so an install silently running on SQLite with Postgres configured
|
||||
// had no way to notice.
|
||||
database: require('./src/utils/databaseEngine').describeEngine(require('./knexfile')),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
|
||||
@@ -240,6 +240,7 @@ router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), p
|
||||
tables: result.tables,
|
||||
filesRestored: result.filesRestored,
|
||||
usesExternalMedia: result.usesExternalMedia,
|
||||
crossEngine: result.crossEngine,
|
||||
sessionInvalidated: true,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const { body } = require('express-validator');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
@@ -128,7 +129,7 @@ router.get(
|
||||
);
|
||||
|
||||
const FRONTEND_URL_FALLBACK = 'https://app.example.com';
|
||||
const DEV_TEST_DIR = () => path.join(process.cwd(), 'storage', 'business-docs', 'dev-test');
|
||||
const DEV_TEST_DIR = () => path.join(getStoragePath(), 'business-docs', 'dev-test');
|
||||
|
||||
function fakeMoney(major, currency, locale = 'de') {
|
||||
return new Intl.NumberFormat(locale === 'de' ? 'de-CH' : 'en-GB', {
|
||||
|
||||
@@ -894,6 +894,7 @@ router.get(
|
||||
// re-fetching here keeps the route a thin shim over the
|
||||
// service rather than reaching inside its internals.
|
||||
const { buildPdfFilename } = require('../utils/pdfFilename');
|
||||
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||
const inv = await db('invoices').where({ id }).first();
|
||||
const customer = inv ? await db('customer_accounts').where({ id: inv.customer_account_id }).first() : null;
|
||||
const filename = buildPdfFilename({
|
||||
@@ -902,7 +903,7 @@ router.get(
|
||||
fallback: `invoice-${id}`,
|
||||
});
|
||||
res.set('Content-Type', 'application/pdf');
|
||||
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
|
||||
res.send(buf);
|
||||
})
|
||||
);
|
||||
@@ -919,6 +920,7 @@ router.post(
|
||||
// the customer so the filename still reflects who the invoice
|
||||
// is for; the number segment falls back to "invoice-preview".
|
||||
const { buildPdfFilename } = require('../utils/pdfFilename');
|
||||
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||
const customer = payload.customerAccountId
|
||||
? await db('customer_accounts').where({ id: payload.customerAccountId }).first()
|
||||
: null;
|
||||
@@ -928,7 +930,7 @@ router.post(
|
||||
fallback: 'invoice-preview',
|
||||
});
|
||||
res.set('Content-Type', 'application/pdf');
|
||||
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
|
||||
res.send(buf);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -537,6 +537,7 @@ router.get(
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const buf = await quoteService.renderQuotePdfBuffer(id);
|
||||
const { buildPdfFilename } = require('../utils/pdfFilename');
|
||||
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||
const quote = await db('quotes').where({ id }).first();
|
||||
const customer = quote ? await db('customer_accounts').where({ id: quote.customer_account_id }).first() : null;
|
||||
const filename = buildPdfFilename({
|
||||
@@ -545,7 +546,7 @@ router.get(
|
||||
fallback: `quote-${id}`,
|
||||
});
|
||||
res.set('Content-Type', 'application/pdf');
|
||||
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
|
||||
res.send(buf);
|
||||
})
|
||||
);
|
||||
@@ -559,6 +560,7 @@ router.post(
|
||||
const payload = mapPayloadToService(req.body);
|
||||
const buf = await quoteService.renderQuotePdfFromPayload(payload);
|
||||
const { buildPdfFilename } = require('../utils/pdfFilename');
|
||||
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||
const customer = payload.customerAccountId
|
||||
? await db('customer_accounts').where({ id: payload.customerAccountId }).first()
|
||||
: null;
|
||||
@@ -568,7 +570,7 @@ router.post(
|
||||
fallback: 'quote-preview',
|
||||
});
|
||||
res.set('Content-Type', 'application/pdf');
|
||||
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
|
||||
res.send(buf);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ const path = require('path');
|
||||
const os = require('os');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolveSqlitePath } = require('../utils/databaseEngine');
|
||||
const { checkForUpdates, getCurrentChannel, getCurrentVersion, getReleasesSince, compareVersions } = require('../services/updateCheckService');
|
||||
const { getAppSetting, upsertAppSetting } = require('../utils/appSettings');
|
||||
const { parseWhatsNew } = require('../utils/whatsNew');
|
||||
@@ -218,26 +219,25 @@ router.get('/updates/instructions', adminAuth, requirePermission('settings.view'
|
||||
// Get comprehensive system status
|
||||
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
// Database size - check if PostgreSQL or SQLite
|
||||
// Database size. Read the LIVE connection rather than re-deriving any of
|
||||
// this from the environment (#1038): DATABASE_CLIENT is not the only thing
|
||||
// that decides the engine, DB_NAME is not the only thing that decides the
|
||||
// database, and DATABASE_PATH was ignored outright here — so a SQLite
|
||||
// install with a custom path, or a Postgres install without an explicit
|
||||
// DATABASE_CLIENT, reported the size of something it was not using.
|
||||
let dbSize = 0;
|
||||
const dbClient = process.env.DATABASE_CLIENT || 'sqlite3';
|
||||
|
||||
if (dbClient === 'pg') {
|
||||
// PostgreSQL - query database size
|
||||
const liveConnection = db.client.config.connection || {};
|
||||
|
||||
if (db.client.config.client === 'pg') {
|
||||
try {
|
||||
const dbName = process.env.DB_NAME || 'picpeak';
|
||||
const result = await db.raw(`
|
||||
SELECT pg_database_size(?) as size
|
||||
`, [dbName]);
|
||||
const result = await db.raw('SELECT pg_database_size(current_database()) as size');
|
||||
dbSize = result.rows[0]?.size || 0;
|
||||
} catch (error) {
|
||||
logger.error('Error getting PostgreSQL database size:', error);
|
||||
}
|
||||
} else {
|
||||
// SQLite - check file size
|
||||
const dbPath = path.join(__dirname, '../../data/photo_sharing.db');
|
||||
try {
|
||||
const stats = await fs.stat(dbPath);
|
||||
const stats = await fs.stat(liveConnection.filename || resolveSqlitePath());
|
||||
dbSize = stats.size;
|
||||
} catch (error) {
|
||||
logger.error('Error getting SQLite database size:', error);
|
||||
|
||||
@@ -3,12 +3,27 @@ const router = express.Router();
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { generateThumbnail, ensurePreviewImage } = require('../services/imageProcessor');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { ensureThumbnail, ensurePreviewImage } = require('../services/imageProcessor');
|
||||
const { getStorage } = require('../services/storage');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
/**
|
||||
* Do these two stored paths address the same object?
|
||||
*
|
||||
* Compared the way the storage backends do, not as raw strings.
|
||||
* LocalFsStorage._resolve and S3StorageBackend._key both fold `\` to `/` and
|
||||
* strip a leading `./`, so a legacy thumbnail_path in any of those shapes is
|
||||
* the SAME file as the freshly generated POSIX key while comparing unequal —
|
||||
* and the "the key moved, delete the old one" branch below would then delete
|
||||
* the thumbnail that had just been written.
|
||||
*/
|
||||
function sameStorageKey(a, b) {
|
||||
const canonical = (key) => String(key)
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\.?\/+/, '')
|
||||
.replace(/\/+/g, '/');
|
||||
return canonical(a) === canonical(b);
|
||||
}
|
||||
|
||||
// Parse JSON-encoded setting values
|
||||
function parseSettingValue(value) {
|
||||
@@ -125,10 +140,21 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
|
||||
try {
|
||||
const { eventId } = req.body; // Optional: regenerate for specific event only
|
||||
|
||||
let query = db('photos').select('id', 'event_id', 'path');
|
||||
// source_origin/external_relpath/filename are what ensureThumbnail branches
|
||||
// on to resolve an external source off its mount instead of under
|
||||
// events/active. thumbnail_path is selected so it can be nulled — see below.
|
||||
let query = db('photos').select(
|
||||
'id', 'event_id', 'path', 'media_type', 'mime_type', 'thumbnail_path',
|
||||
'source_origin', 'external_relpath', 'filename'
|
||||
);
|
||||
if (eventId) {
|
||||
query = query.where('event_id', eventId);
|
||||
}
|
||||
// Skip videos: their thumbnail is a poster frame from videoProcessor, so
|
||||
// handing the container file to Sharp only ever produced an error per row.
|
||||
query = query.where(function() {
|
||||
this.whereNull('media_type').orWhere('media_type', '!=', 'video');
|
||||
});
|
||||
|
||||
const photos = await query;
|
||||
|
||||
@@ -149,30 +175,38 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
|
||||
|
||||
for (const photo of photos) {
|
||||
try {
|
||||
const storagePath = getStoragePath();
|
||||
const originalPath = path.join(storagePath, 'events/active', photo.path);
|
||||
|
||||
// Check if original file exists
|
||||
try {
|
||||
await fs.access(originalPath);
|
||||
} catch (err) {
|
||||
logger.warn(`Original file not found for photo ${photo.id}: ${originalPath}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regenerate thumbnail
|
||||
const thumbnailPath = await generateThumbnail(originalPath, { regenerate: true });
|
||||
|
||||
if (thumbnailPath) {
|
||||
// Update database with new thumbnail path
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({
|
||||
thumbnail_path: thumbnailPath,
|
||||
updated_at: db.fn.now()
|
||||
// Through ensureThumbnail, not a hand-rolled path (#1129). This route
|
||||
// used to resolve every source as `storage/events/active/<path>` and
|
||||
// fs.access it — a location that does not exist for external or
|
||||
// reference rows, whose originals live under events.external_path. So
|
||||
// every one of them failed the check and was counted as an error: on a
|
||||
// reference install the endpoint rebuilt nothing while the UI reported
|
||||
// success, because the response is sent before this loop starts.
|
||||
//
|
||||
// ensureThumbnail already resolves both source kinds, uses the
|
||||
// per-photo ext<id>_ output name so two events referencing one NAS
|
||||
// basename cannot clobber each other, and writes thumbnail_path back
|
||||
// itself. Nulling thumbnail_path is what stops it short-circuiting on
|
||||
// isThumbnailValid — necessary rather than cosmetic, because the old
|
||||
// thumbnail is normally still readable at exactly the moment someone
|
||||
// presses regenerate.
|
||||
const newThumbnailPath = await ensureThumbnail({ ...photo, thumbnail_path: null });
|
||||
|
||||
if (newThumbnailPath) {
|
||||
// Drop the superseded rendition when the key MOVED. On S3 the source
|
||||
// is downloaded to a randomly-named temp file and, for non-RAW input,
|
||||
// the key is derived from that name — so it differs every run, and
|
||||
// nulling thumbnail_path hides the old key from everything that would
|
||||
// otherwise clean it up. Guarded on the key actually changing: local
|
||||
// storage is stable, and deleting the equal key would delete the file
|
||||
// just written.
|
||||
if (photo.thumbnail_path && !sameStorageKey(photo.thumbnail_path, newThumbnailPath)) {
|
||||
await getStorage().delete(photo.thumbnail_path).catch((err) => {
|
||||
logger.warn(
|
||||
`Could not remove superseded thumbnail ${photo.thumbnail_path} for photo ${photo.id}: ${err.message}`
|
||||
);
|
||||
});
|
||||
|
||||
}
|
||||
successCount++;
|
||||
logger.info(`Regenerated thumbnail for photo ${photo.id}`);
|
||||
} else {
|
||||
@@ -201,7 +235,13 @@ router.post('/regenerate-previews', adminAuth, requirePermission('photos.edit'),
|
||||
try {
|
||||
const { eventId } = req.body;
|
||||
|
||||
let query = db('photos').select('id', 'event_id', 'path', 'media_type', 'mime_type', 'preview_path');
|
||||
// source_origin/external_relpath/filename are what ensurePreviewImage
|
||||
// branches on for external/reference rows (#1078) — without them every
|
||||
// external photo looks managed here and generation is skipped.
|
||||
let query = db('photos').select(
|
||||
'id', 'event_id', 'path', 'media_type', 'mime_type', 'preview_path',
|
||||
'source_origin', 'external_relpath', 'filename'
|
||||
);
|
||||
if (eventId) query = query.where('event_id', eventId);
|
||||
// Skip videos — preview tier is image-only.
|
||||
query = query.where(function() {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -566,6 +566,7 @@ router.get('/quotes/:id/pdf', customerAuth, async (req, res) => {
|
||||
const quoteService = require('../services/quoteService');
|
||||
const buf = await quoteService.renderQuotePdfBuffer(quote.id);
|
||||
const { buildPdfFilename } = require('../utils/pdfFilename');
|
||||
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||
const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first();
|
||||
const filename = buildPdfFilename({
|
||||
docNumber: quote.quote_number,
|
||||
@@ -573,7 +574,7 @@ router.get('/quotes/:id/pdf', customerAuth, async (req, res) => {
|
||||
fallback: `quote-${quote.id}`,
|
||||
});
|
||||
res.set('Content-Type', 'application/pdf');
|
||||
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
|
||||
res.send(buf);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to render quote PDF');
|
||||
@@ -597,6 +598,7 @@ router.get('/invoices/:id/pdf', customerAuth, async (req, res) => {
|
||||
const invoiceService = require('../services/invoiceService');
|
||||
const buf = await invoiceService.renderInvoicePdfBuffer(invoice.id);
|
||||
const { buildPdfFilename } = require('../utils/pdfFilename');
|
||||
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||
const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first();
|
||||
const filename = buildPdfFilename({
|
||||
docNumber: invoice.invoice_number,
|
||||
@@ -604,7 +606,7 @@ router.get('/invoices/:id/pdf', customerAuth, async (req, res) => {
|
||||
fallback: `invoice-${invoice.id}`,
|
||||
});
|
||||
res.set('Content-Type', 'application/pdf');
|
||||
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
|
||||
res.send(buf);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to render invoice PDF');
|
||||
|
||||
+117
-60
@@ -2,6 +2,11 @@ const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
// SQLite stores booleans as 0/1, Postgres as true/false (#1028). Strict
|
||||
// comparisons against `true`/`false` therefore read every flag backwards on
|
||||
// SQLite — parseBooleanInput normalises both engines and takes the per-column
|
||||
// default for legacy NULL rows.
|
||||
const { parseBooleanInput } = require('../utils/parsers');
|
||||
const { getAppSetting } = require('../utils/appSettings');
|
||||
const archiver = require('archiver');
|
||||
const path = require('path');
|
||||
@@ -24,6 +29,7 @@ const { resolveGuest } = require('../middleware/guestAuth');
|
||||
const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { pipeStreamToResponse } = require('../utils/streamResponse');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
|
||||
@@ -407,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();
|
||||
@@ -452,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(
|
||||
@@ -481,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) => {
|
||||
@@ -503,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 || feedbackSettings.show_feedback_to_guests !== false;
|
||||
|
||||
// Then get comment counts separately
|
||||
const commentCounts = await db('photo_feedback')
|
||||
.whereIn('photo_id', photos.map(p => p.id))
|
||||
@@ -563,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);
|
||||
@@ -599,7 +658,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
// Per-category download flag (#640). false explicitly disables; the
|
||||
// gallery hides the download button. Defaults true so categories
|
||||
// created before migration 135 keep working.
|
||||
allow_downloads: cat.allow_downloads !== false
|
||||
allow_downloads: parseBooleanInput(cat.allow_downloads, true)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -626,9 +685,9 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
const protectionSettings = {
|
||||
protection_level: req.event.protection_level || 'standard',
|
||||
image_quality: req.event.image_quality || 85,
|
||||
use_canvas_rendering: req.event.use_canvas_rendering === true,
|
||||
use_canvas_rendering: parseBooleanInput(req.event.use_canvas_rendering, false),
|
||||
fragmentation_level: req.event.fragmentation_level || 3,
|
||||
overlay_protection: req.event.overlay_protection !== false
|
||||
overlay_protection: parseBooleanInput(req.event.overlay_protection, true)
|
||||
};
|
||||
|
||||
// Lightbox preview tier (#492). When the admin opts in, the
|
||||
@@ -675,13 +734,15 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
color_theme: req.event.color_theme,
|
||||
expires_at: req.event.expires_at,
|
||||
hero_photo_id: req.event.hero_photo_id,
|
||||
allow_downloads: req.event.allow_downloads !== false,
|
||||
allow_user_uploads: req.event.allow_user_uploads === true,
|
||||
disable_right_click: req.event.disable_right_click === true,
|
||||
watermark_downloads: req.event.watermark_downloads === true,
|
||||
// Defaults match /info: downloads on unless explicitly disabled,
|
||||
// uploads off unless explicitly enabled (#1028).
|
||||
allow_downloads: parseBooleanInput(req.event.allow_downloads, true),
|
||||
allow_user_uploads: parseBooleanInput(req.event.allow_user_uploads, false),
|
||||
disable_right_click: parseBooleanInput(req.event.disable_right_click, false),
|
||||
watermark_downloads: parseBooleanInput(req.event.watermark_downloads, false),
|
||||
watermark_text: req.event.watermark_text,
|
||||
enable_devtools_protection: req.event.enable_devtools_protection === true,
|
||||
use_canvas_rendering: req.event.use_canvas_rendering === true,
|
||||
enable_devtools_protection: parseBooleanInput(req.event.enable_devtools_protection, false),
|
||||
use_canvas_rendering: parseBooleanInput(req.event.use_canvas_rendering, false),
|
||||
hero_logo_visible: resolveHeroLogoVisible(req.event.hero_logo_visible, globalHeroLogoVisible),
|
||||
hero_logo_size: req.event.hero_logo_size || globalLogoSize || 'medium',
|
||||
hero_logo_position: req.event.hero_logo_position || 'top',
|
||||
@@ -726,6 +787,17 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
|
||||
? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}`
|
||||
: null,
|
||||
// Slideshow source (#1015). Same preview tier, but emitted
|
||||
// unconditionally: the slideshow has no `url` fallback worth
|
||||
// taking (originals are projector-sized) and must never land on
|
||||
// `hero_url`, which is cover-cropped to 16:9 — that made the
|
||||
// "no crop" fit letterbox an already-cropped frame. The preview
|
||||
// route generates lazily and redirects to the original on any
|
||||
// failure, so this is safe even where no preview exists yet.
|
||||
slideshow_url: photo.media_type !== 'video'
|
||||
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
|
||||
? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}`
|
||||
: null,
|
||||
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
|
||||
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
|
||||
type: photo.type,
|
||||
@@ -734,7 +806,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
// Per-category download permission (#640). Defaults true for photos
|
||||
// without a category or for categories that pre-date migration 135.
|
||||
category_allow_downloads: photo.category_id && categoryMap[photo.category_id]
|
||||
? categoryMap[photo.category_id].allow_downloads !== false
|
||||
? parseBooleanInput(categoryMap[photo.category_id].allow_downloads, true)
|
||||
: true,
|
||||
category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null,
|
||||
size: photo.size_bytes,
|
||||
@@ -844,7 +916,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
|
||||
const { photoId } = req.params;
|
||||
|
||||
// Check if downloads are allowed for this event
|
||||
if (req.event.allow_downloads === false) {
|
||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
@@ -868,7 +940,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
|
||||
const cat = await db('photo_categories')
|
||||
.where('id', photo.category_id)
|
||||
.first('allow_downloads');
|
||||
if (cat && cat.allow_downloads === false) {
|
||||
if (cat && !parseBooleanInput(cat.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this category' });
|
||||
}
|
||||
}
|
||||
@@ -972,7 +1044,7 @@ async function bumpEventDownloadCounts(eventId) {
|
||||
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
|
||||
try {
|
||||
// Check if downloads are allowed for this event
|
||||
if (req.event.allow_downloads === false) {
|
||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
@@ -1026,7 +1098,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
res.setHeader('Content-Length', zipInfo.size);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
|
||||
const stream = await storage.get(zipInfo.key);
|
||||
stream.pipe(res);
|
||||
pipeStreamToResponse(stream, res, { context: `prepared zip for event ${req.event.id}`, missingStatus: 410 });
|
||||
|
||||
// Log bulk download
|
||||
db('access_logs').insert({
|
||||
@@ -1192,7 +1264,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
|
||||
try {
|
||||
// Check if downloads are allowed for this event
|
||||
if (req.event.allow_downloads === false) {
|
||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
@@ -1487,7 +1559,7 @@ router.get('/:slug/photo/:photoId',
|
||||
const file = useStorageBackend
|
||||
? await storage.getRange(storageKey, start, end)
|
||||
: fs.createReadStream(filePath, { start, end });
|
||||
file.pipe(res);
|
||||
pipeStreamToResponse(file, res, { context: `video range for photo ${photo.id}` });
|
||||
} else {
|
||||
res.writeHead(200, {
|
||||
'Content-Length': fileSize,
|
||||
@@ -1499,7 +1571,7 @@ router.get('/:slug/photo/:photoId',
|
||||
const file = useStorageBackend
|
||||
? await storage.get(storageKey)
|
||||
: fs.createReadStream(filePath);
|
||||
file.pipe(res);
|
||||
pipeStreamToResponse(file, res, { context: `video for photo ${photo.id}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1533,7 +1605,7 @@ router.get('/:slug/photo/:photoId',
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
const wmStream = await storage.get(photo.watermark_path);
|
||||
return wmStream.pipe(res);
|
||||
return pipeStreamToResponse(wmStream, res, { context: `watermarked photo ${photo.id}` });
|
||||
}
|
||||
} else {
|
||||
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
|
||||
@@ -1582,7 +1654,7 @@ router.get('/:slug/photo/:photoId',
|
||||
res.set('Content-Length', stat.size);
|
||||
if (photo.mime_type) res.set('Content-Type', photo.mime_type);
|
||||
const stream = await storage.get(storageKey);
|
||||
stream.pipe(res);
|
||||
pipeStreamToResponse(stream, res, { context: `photo ${photo.id}` });
|
||||
} else {
|
||||
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
||||
res.sendFile(absolutePath);
|
||||
@@ -1677,7 +1749,7 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
} else {
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
const stream = await storage.get(thumbnailPath);
|
||||
stream.pipe(res);
|
||||
pipeStreamToResponse(stream, res, { context: `thumbnail for photo ${photoId}` });
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to serve thumbnail');
|
||||
@@ -1763,7 +1835,7 @@ router.get('/:slug/hero/:photoId',
|
||||
} else {
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
const stream = await storage.get(heroPath);
|
||||
stream.pipe(res);
|
||||
pipeStreamToResponse(stream, res, { context: `hero for photo ${photoId}` });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error serving hero image:', {
|
||||
@@ -1859,7 +1931,7 @@ router.get('/:slug/preview/:photoId',
|
||||
} else {
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
const stream = await storage.get(previewPath);
|
||||
stream.pipe(res);
|
||||
pipeStreamToResponse(stream, res, { context: `preview for photo ${photoId}` });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error serving preview image:', {
|
||||
@@ -1872,26 +1944,11 @@ router.get('/:slug/preview/:photoId',
|
||||
}
|
||||
);
|
||||
|
||||
// Get feedback settings for gallery
|
||||
router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const settings = await feedbackService.getEventFeedbackSettings(req.event.id);
|
||||
|
||||
res.json({
|
||||
feedback_enabled: settings.feedback_enabled || false,
|
||||
allow_ratings: settings.allow_ratings,
|
||||
allow_likes: settings.allow_likes,
|
||||
allow_comments: settings.allow_comments,
|
||||
allow_favorites: settings.allow_favorites,
|
||||
show_feedback_to_guests: settings.show_feedback_to_guests,
|
||||
require_name_email: settings.require_name_email || false,
|
||||
identity_mode: settings.identity_mode || 'simple'
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch feedback settings');
|
||||
}
|
||||
});
|
||||
// GET /:slug/feedback-settings lives in galleryFeedback.js. A duplicate of it
|
||||
// used to sit here, and since server.js mounts galleryRoutes before
|
||||
// galleryFeedback it shadowed the real handler — dropping the per-guest caps
|
||||
// (#655) from the guest payload, so the gallery could never render the
|
||||
// favorite/like limits or their counters (#1030).
|
||||
|
||||
// Get photo stats
|
||||
router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,6 +5,7 @@ const secureImageService = require('../services/secureImageService');
|
||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { parseBooleanInput } = require('../utils/parsers');
|
||||
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver');
|
||||
const { withLocalCopy } = require('../services/imageProcessor');
|
||||
const { getStorage } = require('../services/storage');
|
||||
@@ -323,8 +324,9 @@ router.get('/:slug/secure-download/:photoId/:token',
|
||||
try {
|
||||
const { photoId, token } = req.params;
|
||||
|
||||
// Check if downloads are allowed
|
||||
if (req.event.allow_downloads === false) {
|
||||
// Check if downloads are allowed. SQLite stores the flag as 0/1, so a
|
||||
// strict `=== false` never fired there and the guard was inert (#1028).
|
||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,17 @@
|
||||
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const backupService = require('./backupService');
|
||||
|
||||
const STORAGE_ROOT = () => process.env.STORAGE_PATH || path.join(process.cwd(), 'storage');
|
||||
// The shared resolver, not a second `STORAGE_PATH || cwd` expression. With
|
||||
// STORAGE_PATH unset the two disagree — getStoragePath() falls back
|
||||
// module-relative while cwd is normally backend/ — and this diagnostic would
|
||||
// then report the business-docs tree as missing while the backup walker, which
|
||||
// uses the module-relative root, was backing it up correctly.
|
||||
const STORAGE_ROOT = () => getStoragePath();
|
||||
|
||||
/**
|
||||
* Top-level subdirectories we expect to find under STORAGE_PATH but
|
||||
|
||||
@@ -49,12 +49,18 @@
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const STORAGE_ROOT = () => process.env.STORAGE_PATH || path.join(process.cwd(), 'storage');
|
||||
// The shared resolver, not a second `STORAGE_PATH || cwd` expression. With
|
||||
// STORAGE_PATH unset the two disagree — getStoragePath() falls back
|
||||
// module-relative while cwd is normally backend/ — and this diagnostic would
|
||||
// then report the business-docs tree as missing while the backup walker, which
|
||||
// uses the module-relative root, was backing it up correctly.
|
||||
const STORAGE_ROOT = () => getStoragePath();
|
||||
|
||||
/**
|
||||
* Every column the verifier walks, declared once so the test suite
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { getStoragePath } = require('../../config/storage');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('../../utils/logger');
|
||||
@@ -42,7 +43,7 @@ function sha256OfFile(filePath) {
|
||||
async function persistContractPdf(contract, buffer, suffix = '') {
|
||||
if (!contract.contract_number) return { filePath: null, sha256: null };
|
||||
const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear();
|
||||
const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year));
|
||||
const root = path.join(getStoragePath(), 'business-docs', 'contract', String(year));
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
// Always append a millisecond timestamp to the filename so writes
|
||||
// never overwrite an earlier version on disk. Forensic preservation.
|
||||
@@ -92,8 +93,7 @@ async function persistSignatureImage(contract, role, dataUrl) {
|
||||
}
|
||||
const ext = match[1] === 'jpeg' ? 'jpg' : 'png';
|
||||
const root = path.join(
|
||||
process.cwd(),
|
||||
'storage',
|
||||
getStoragePath(),
|
||||
'business-docs',
|
||||
'contract',
|
||||
'signatures',
|
||||
@@ -194,7 +194,7 @@ async function persistAuditCertificate(contract) {
|
||||
try {
|
||||
const { buffer } = await pdfStampService.renderAuditCertificate(ctx);
|
||||
const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear();
|
||||
const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year));
|
||||
const root = path.join(getStoragePath(), 'business-docs', 'contract', String(year));
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filePath = path.join(root, `${contract.contract_number}_audit_${stamp}.pdf`);
|
||||
|
||||
@@ -2,6 +2,38 @@ const { db, logActivity } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
// Every writable column on event_feedback_settings (#1030). The admin form
|
||||
// posts its whole client-side state back, including UI-only keys that were
|
||||
// never columns — `enable_rate_limiting`, `rate_limit_window_minutes`,
|
||||
// `rate_limit_max_requests` — and spreading those into the UPDATE made knex
|
||||
// throw, so the request 500'd and the "Enable feedback" toggle silently
|
||||
// never persisted. Identity columns (id/event_id) and the timestamps stay
|
||||
// server-managed. New columns MUST be added here.
|
||||
const FEEDBACK_SETTINGS_COLUMNS = [
|
||||
'feedback_enabled',
|
||||
'allow_ratings',
|
||||
'allow_likes',
|
||||
'allow_comments',
|
||||
'allow_favorites',
|
||||
'require_name_email',
|
||||
'moderate_comments',
|
||||
'require_moderation',
|
||||
'show_feedback_to_guests',
|
||||
'identity_mode',
|
||||
'max_favorites_per_guest',
|
||||
'max_likes_per_guest'
|
||||
];
|
||||
|
||||
function pickSettingsColumns(settings) {
|
||||
const picked = {};
|
||||
for (const column of FEEDBACK_SETTINGS_COLUMNS) {
|
||||
if (Object.prototype.hasOwnProperty.call(settings || {}, column)) {
|
||||
picked[column] = settings[column];
|
||||
}
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
class FeedbackService {
|
||||
/**
|
||||
* Get feedback settings for an event
|
||||
@@ -53,25 +85,27 @@ class FeedbackService {
|
||||
const existing = await db('event_feedback_settings')
|
||||
.where('event_id', eventId)
|
||||
.first();
|
||||
|
||||
|
||||
const writable = pickSettingsColumns(settings);
|
||||
|
||||
if (existing) {
|
||||
await db('event_feedback_settings')
|
||||
.where('event_id', eventId)
|
||||
.update({
|
||||
...settings,
|
||||
updated_at: new Date()
|
||||
...writable,
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId,
|
||||
...settings,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
...writable,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
await logActivity('feedback_settings_updated', settings, eventId);
|
||||
|
||||
|
||||
await logActivity('feedback_settings_updated', writable, eventId);
|
||||
|
||||
return this.getEventFeedbackSettings(eventId);
|
||||
} catch (error) {
|
||||
logger.error('Error updating feedback settings:', error);
|
||||
@@ -90,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 {
|
||||
@@ -118,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);
|
||||
@@ -263,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']),
|
||||
@@ -345,7 +396,33 @@ class FeedbackService {
|
||||
await db('photo_feedback')
|
||||
.where('id', feedbackId)
|
||||
.update(updates);
|
||||
|
||||
|
||||
// Unhiding can collide with a replacement (#1150). A hidden row reads as
|
||||
// absent, so the guest may well have re-added the same feedback in the
|
||||
// meantime; making the original visible again would leave TWO visible
|
||||
// rows for one guest on one photo — double-counted in the tallies, and
|
||||
// needing two toggles to clear because each one deletes a single row.
|
||||
//
|
||||
// Needs a stable identity to scope by. With neither id nor identifier
|
||||
// the fallback degrades to `guest_identifier IS NULL`, which is every
|
||||
// identifier-less row on the photo — other people's, deleted. Nothing to
|
||||
// converge in that case, so leave it alone. Comments are exempt: several
|
||||
// from one guest on one photo is normal.
|
||||
const collapseIdentity = feedback.guest_id || feedback.guest_identifier;
|
||||
if (updates.is_hidden === false && feedback.feedback_type !== 'comment' && collapseIdentity) {
|
||||
const superseded = db('photo_feedback')
|
||||
.where({
|
||||
photo_id: feedback.photo_id,
|
||||
event_id: feedback.event_id,
|
||||
feedback_type: feedback.feedback_type,
|
||||
is_hidden: false,
|
||||
})
|
||||
.whereNot('id', feedbackId);
|
||||
if (feedback.guest_id) superseded.where('guest_id', feedback.guest_id);
|
||||
else superseded.where('guest_identifier', feedback.guest_identifier);
|
||||
await superseded.delete();
|
||||
}
|
||||
|
||||
// Update photo stats if visibility changed
|
||||
await this.updatePhotoFeedbackStats(feedback.photo_id);
|
||||
|
||||
|
||||
@@ -133,10 +133,18 @@ async function generateThumbnail(imagePath, options = {}) {
|
||||
// Get thumbnail settings
|
||||
const settings = await getThumbnailSettings();
|
||||
|
||||
// Force regeneration: drop the existing object before writing the new one
|
||||
if (options.regenerate) {
|
||||
await storage.delete(thumbnailRelKey).catch(() => {});
|
||||
}
|
||||
// `options.regenerate` deliberately does NOT delete the existing object first
|
||||
// (#1129).
|
||||
//
|
||||
// It used to, and the delete ran BEFORE sharp had even opened the source — so
|
||||
// a source that could not be read (a NAS mount that blipped, a corrupt file)
|
||||
// left the old thumbnail already gone and returned null, with the database
|
||||
// still pointing at it. One bulk regeneration during a mount outage could
|
||||
// therefore strip every canonical thumbnail in a reference gallery.
|
||||
//
|
||||
// Nothing is lost by dropping it: LocalFsStorage.put stages to a temp file and
|
||||
// renames over the target, which replaces atomically, and an S3 put overwrites
|
||||
// by key. The delete only added a window with no thumbnail at all.
|
||||
|
||||
try {
|
||||
// First, verify the source image is complete and valid
|
||||
@@ -192,9 +200,12 @@ async function generateThumbnail(imagePath, options = {}) {
|
||||
const msg = (error && error.message) ? error.message : String(error);
|
||||
logger.error(`Failed to generate thumbnail for ${sourceBasename}: ${msg}`);
|
||||
|
||||
// Clean up any partially uploaded object
|
||||
await storage.delete(thumbnailRelKey).catch(() => {});
|
||||
|
||||
// No cleanup delete here either, for the same reason (#1129). This was
|
||||
// "clean up any partially uploaded object", but there cannot be one:
|
||||
// storage.put is the LAST statement in the try, every throw above it
|
||||
// happens before anything is written, and put unlinks its own temp file on
|
||||
// failure. The only object this could remove is the PREVIOUS, valid
|
||||
// rendition — exactly the thumbnail a failed regeneration must leave alone.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -498,7 +509,10 @@ async function ensureHeroImage(photo) {
|
||||
* thumbnails or heroes.
|
||||
*/
|
||||
async function generatePreviewImage(imagePath, options = {}) {
|
||||
const filename = path.basename(imagePath);
|
||||
// outputBasename lets callers disambiguate sources that share a basename
|
||||
// (external mounts, see ensurePreviewImage) — same contract as
|
||||
// generateThumbnail.
|
||||
const filename = options.outputBasename || path.basename(imagePath);
|
||||
const previewFilename = `preview_${filename}`;
|
||||
const previewRelKey = path.posix.join('previews', previewFilename);
|
||||
const storage = getStorage();
|
||||
@@ -579,17 +593,27 @@ async function isPreviewValid(previewPath) {
|
||||
* Lazy-generate the preview image for a photo if missing or invalid.
|
||||
* Returns the storage key or null on failure (callers fall back to
|
||||
* the original URL so the lightbox never shows a broken image).
|
||||
*
|
||||
* Handles both managed photos (via the storage backend, possibly S3) and
|
||||
* external/reference photos (#1078 — sourced from a local mount outside the
|
||||
* managed storage tree). Externals used to have no branch here at all:
|
||||
* resolvePhotoStorageKey returns null for them by design, that null reached
|
||||
* withLocalCopy, and the throw put every lightbox open back on the full-size
|
||||
* original — the exact cost the preview tier (#492) exists to avoid.
|
||||
*/
|
||||
async function ensurePreviewImage(photo) {
|
||||
const { resolvePhotoStorageKey } = require('./photoResolver');
|
||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
||||
|
||||
let sourceKey;
|
||||
let event;
|
||||
try {
|
||||
const event = await db('events').where('id', photo.event_id).first();
|
||||
sourceKey = resolvePhotoStorageKey(event, photo);
|
||||
event = await db('events').where('id', photo.event_id).first();
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) ? e.message : String(e);
|
||||
logger.error(`Failed to resolve original key for preview (photo ${photo.id}): ${msg}`);
|
||||
logger.error(`Failed to load event for preview (photo ${photo.id}): ${msg}`);
|
||||
return null;
|
||||
}
|
||||
if (!event) {
|
||||
logger.error(`ensurePreviewImage: event ${photo.event_id} not found for photo ${photo.id}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -599,9 +623,46 @@ async function ensurePreviewImage(photo) {
|
||||
logger.warn(`Invalid preview detected for photo ${photo.id}, regenerating…`);
|
||||
}
|
||||
|
||||
const newPreviewPath = await withLocalCopy(sourceKey, (localPath) =>
|
||||
generatePreviewImage(localPath, { regenerate: true })
|
||||
);
|
||||
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
|
||||
|
||||
let newPreviewPath;
|
||||
if (isExternal) {
|
||||
// Mirrors ensureThumbnail's external branch: the source is a direct fs
|
||||
// read off the mount, so no withLocalCopy. The per-photo outputBasename
|
||||
// keeps two events that reference the same NAS basename from clobbering
|
||||
// each other's preview.
|
||||
let localPath;
|
||||
try {
|
||||
localPath = resolvePhotoFilePath(event, photo);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to resolve external file for preview (photo ${photo.id}): ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
const sourceBasename = path.basename(photo.external_relpath || photo.filename || `photo-${photo.id}`);
|
||||
const outputBasename = `ext${photo.id}_${sourceBasename}`;
|
||||
logger.info(`Ensuring preview for external photo ${photo.id} from ${localPath}`);
|
||||
newPreviewPath = await generatePreviewImage(localPath, { regenerate: true, outputBasename });
|
||||
} else {
|
||||
let sourceKey;
|
||||
try {
|
||||
sourceKey = resolvePhotoStorageKey(event, photo);
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) ? e.message : String(e);
|
||||
logger.error(`Failed to resolve original key for preview (photo ${photo.id}): ${msg}`);
|
||||
return null;
|
||||
}
|
||||
if (!sourceKey) {
|
||||
// Reference-mode event holding a row with no source_origin: the mode
|
||||
// falls back to the event's and resolvePhotoStorageKey returns null.
|
||||
// Honour the documented null-on-failure contract instead of feeding
|
||||
// null into withLocalCopy, which throws out of this function.
|
||||
logger.warn(`No managed storage key for preview (photo ${photo.id}); skipping preview generation`);
|
||||
return null;
|
||||
}
|
||||
newPreviewPath = await withLocalCopy(sourceKey, (localPath) =>
|
||||
generatePreviewImage(localPath, { regenerate: true })
|
||||
);
|
||||
}
|
||||
|
||||
if (newPreviewPath) {
|
||||
await db('photos').where({ id: photo.id }).update({ preview_path: newPreviewPath });
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { getStoragePath } = require('../../config/storage');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { AppError } = require('../../utils/errors');
|
||||
const { formatShortDate } = require('../../utils/dateFormatter');
|
||||
@@ -132,7 +133,7 @@ async function applyReminder(invoice, lineItems, level, adminId) {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const year = new Date(fresh.issue_date).getFullYear();
|
||||
const root = path.join(process.cwd(), 'storage', 'business-docs', 'mahnung', String(year));
|
||||
const root = path.join(getStoragePath(), 'business-docs', 'mahnung', String(year));
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const mahnungPath = path.join(root, `${fresh.invoice_number}_mahnung_L${level}.pdf`);
|
||||
fs.writeFileSync(mahnungPath, buffer);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// module-level overview. Do not add behavior here without updating the entry re-exports.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { getStoragePath } = require('../../config/storage');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const logger = require('../../utils/logger');
|
||||
const { AppError } = require('../../utils/errors');
|
||||
@@ -107,7 +108,7 @@ async function sendInvoice(id, adminId) {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const year = new Date(invoice.issue_date).getFullYear();
|
||||
const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year));
|
||||
const root = path.join(getStoragePath(), 'business-docs', 'invoice', String(year));
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const pdfPath = path.join(root, `${invoice.invoice_number}.pdf`);
|
||||
fs.writeFileSync(pdfPath, buffer);
|
||||
@@ -345,7 +346,7 @@ async function sendStorno(stornoId, adminId) {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const year = new Date(storno.issue_date).getFullYear();
|
||||
const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year));
|
||||
const root = path.join(getStoragePath(), 'business-docs', 'invoice', String(year));
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const pdfPath = path.join(root, `${storno.invoice_number}.pdf`);
|
||||
fs.writeFileSync(pdfPath, buffer);
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
*/
|
||||
|
||||
const PDFDocument = require('pdfkit');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const { SwissQRBill, Table } = require('swissqrbill/pdf');
|
||||
const { t } = require('./pdf-i18n');
|
||||
|
||||
@@ -1349,8 +1350,16 @@ function registerCustomFonts(doc, issuer) {
|
||||
if (issuer.pdfFontTtfPath) {
|
||||
try {
|
||||
const raw = issuer.pdfFontTtfPath;
|
||||
// The configured storage root first; process.cwd()/storage stays on as a
|
||||
// legacy fallback so installs predating STORAGE_PATH keep resolving.
|
||||
// Compose makes the two the same directory, which is why only a custom
|
||||
// STORAGE_PATH ever exposed this — the font just silently was not found
|
||||
// and the document fell back to the built-in face.
|
||||
const storageRoot = getStoragePath();
|
||||
const candidates = [
|
||||
path.isAbsolute(raw) ? raw : null,
|
||||
path.join(storageRoot, raw.replace(/^\/+/, '')),
|
||||
path.join(storageRoot, 'fonts', path.basename(raw)),
|
||||
path.join(process.cwd(), 'storage', raw.replace(/^\/+/, '')),
|
||||
path.join(process.cwd(), 'storage', 'fonts', path.basename(raw)),
|
||||
].filter(Boolean);
|
||||
|
||||
@@ -135,7 +135,7 @@ async function collectFiles(includePhotos) {
|
||||
* @param {string} [opts.outDir] where to write the file (defaults to a temp dir)
|
||||
* @returns {Promise<{ filePath: string, manifest: object }>}
|
||||
*/
|
||||
async function createPicpeak({ includePhotos = false, outDir } = {}) {
|
||||
async function createPicpeak({ includePhotos = false, includeFiles = true, outDir } = {}) {
|
||||
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-export-'));
|
||||
const dataDir = path.join(staging, 'data');
|
||||
await fsp.mkdir(dataDir, { recursive: true });
|
||||
@@ -150,7 +150,11 @@ async function createPicpeak({ includePhotos = false, outDir } = {}) {
|
||||
|
||||
// 2. Gather the non-recalculable blobs (PDFs, business-docs, uploads, and
|
||||
// optionally original photos).
|
||||
const files = await collectFiles(includePhotos);
|
||||
// includeFiles:false is for the SQLite → Postgres migration (#1038): it moves
|
||||
// rows between engines on the SAME install, so the storage volume is already
|
||||
// correct. Copying every business doc through /tmp and back would only risk
|
||||
// filling the temp disk.
|
||||
const files = includeFiles ? await collectFiles(includePhotos) : [];
|
||||
|
||||
// 3. Manifest — everything the importer needs to validate + reconstruct.
|
||||
const manifest = {
|
||||
@@ -162,7 +166,7 @@ async function createPicpeak({ includePhotos = false, outDir } = {}) {
|
||||
engine: isPostgres() ? 'pg' : 'sqlite',
|
||||
latest_migration: await getLatestMigration(),
|
||||
},
|
||||
options: { includePhotos: !!includePhotos },
|
||||
options: { includePhotos: !!includePhotos, includeFiles: !!includeFiles },
|
||||
tables: tableMeta,
|
||||
file_count: files.length,
|
||||
// NOTE: contains secrets (SMTP password, admin hashes, API keys) in plain
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
// email collides with the current account is overwritten with the current
|
||||
// account's credentials (so the operator's known password keeps working).
|
||||
//
|
||||
// Same-engine only (pg↔pg / sqlite↔sqlite) and forward-only (an older backup
|
||||
// restores onto a newer instance; a newer backup is refused). The target's own
|
||||
// schema is used as-is — we never replay the backup's DDL.
|
||||
// Same-engine (pg↔pg / sqlite↔sqlite) or the upgrade direction (sqlite → pg,
|
||||
// #1041) — the reverse is refused. Forward-only (an older backup restores onto
|
||||
// a newer instance; a newer backup is refused). The target's own schema is
|
||||
// used as-is — we never replay the backup's DDL.
|
||||
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
@@ -53,8 +54,16 @@ async function validateManifest(manifest) {
|
||||
errors.push('This backup was created by a newer version of PicPeak. Update this instance first.');
|
||||
}
|
||||
const engine = isPostgres() ? 'pg' : 'sqlite';
|
||||
if (manifest.database && manifest.database.engine && manifest.database.engine !== engine) {
|
||||
errors.push(`Database engine mismatch: the backup is "${manifest.database.engine}" but this instance is "${engine}". Restore is only supported between matching engines.`);
|
||||
const backupEngine = manifest.database && manifest.database.engine;
|
||||
// Cross-engine restore is allowed in the UPGRADE direction only: a SQLite
|
||||
// archive onto a Postgres instance (#1041) — the official small-install →
|
||||
// full-stack migration path, same gate for the upload UI and
|
||||
// scripts/migrate-sqlite-to-postgres.js. The reverse stays refused: pg
|
||||
// archives carry ISO "T"/"Z" timestamps that SQLite would store as-is in
|
||||
// text columns (the #1028/#1029 drift class), and engine downgrades are
|
||||
// rarely intentional.
|
||||
if (backupEngine && backupEngine !== engine && !(backupEngine === 'sqlite' && engine === 'pg')) {
|
||||
errors.push(`Database engine mismatch: the backup is "${backupEngine}" but this instance is "${engine}". Cross-engine restore is only supported from a SQLite backup onto a PostgreSQL instance.`);
|
||||
}
|
||||
// Forward-only: the target schema must be at least as new as the backup's.
|
||||
let targetLatest = null;
|
||||
@@ -184,11 +193,82 @@ function serialiseJsonColumns(rows, jsonCols) {
|
||||
});
|
||||
}
|
||||
|
||||
// Cross-engine loads only (#1038): SQLite has no real date or boolean types, so
|
||||
// its rows carry epoch numbers where Postgres wants a timestamp and 0/1 where
|
||||
// Postgres wants a boolean. Both are rejected outright by pg
|
||||
// ("date/time field value out of range: 1786548038763"). Coerce per column,
|
||||
// driven by the TARGET schema so nothing is guessed from the value alone.
|
||||
// Same-engine restores never call this and are byte-for-byte unchanged.
|
||||
async function typedColumnsFor(trx, table) {
|
||||
const info = await trx(table).columnInfo();
|
||||
const timestamps = [];
|
||||
const booleans = [];
|
||||
for (const [name, meta] of Object.entries(info)) {
|
||||
const type = String(meta.type || '').toLowerCase();
|
||||
if (type.includes('timestamp') || type === 'date' || type === 'datetime') timestamps.push(name);
|
||||
else if (type === 'boolean' || type === 'bool') booleans.push(name);
|
||||
}
|
||||
return { timestamps, booleans };
|
||||
}
|
||||
|
||||
// SQLite writes Date objects as epoch MILLISECONDS in production, but some rows
|
||||
// (and older installs) carry epoch seconds. 1e11 sits far past any plausible
|
||||
// seconds value and far below any plausible ms value, so it separates them
|
||||
// cleanly for every date this application will ever see.
|
||||
function epochToIso(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return value;
|
||||
const ms = Math.abs(n) < 1e11 ? n * 1000 : n;
|
||||
const d = new Date(ms);
|
||||
return Number.isNaN(d.getTime()) ? value : d.toISOString();
|
||||
}
|
||||
|
||||
function coerceForTargetEngine(rows, { timestamps, booleans }) {
|
||||
if (!timestamps.length && !booleans.length) return rows;
|
||||
return rows.map((row) => {
|
||||
const out = { ...row };
|
||||
for (const col of timestamps) {
|
||||
const v = out[col];
|
||||
if (v === null || v === undefined || v === '') continue;
|
||||
if (typeof v === 'number' || (typeof v === 'string' && /^-?\d+$/.test(v))) {
|
||||
out[col] = epochToIso(v);
|
||||
}
|
||||
}
|
||||
for (const col of booleans) {
|
||||
const v = out[col];
|
||||
if (v === null || v === undefined) continue;
|
||||
if (typeof v === 'number') out[col] = v !== 0;
|
||||
else if (typeof v === 'string') out[col] = !['0', 'false', ''].includes(v.toLowerCase());
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
// Whole-DB replace in one transaction with FK enforcement suspended (pg:
|
||||
// session_replication_role=replica on the trx connection, reset before commit;
|
||||
// sqlite: defer_foreign_keys so checks run at commit). knex_migrations is never
|
||||
// in the data set, so the target's schema/migration state is left intact.
|
||||
async function replaceAllTables(tables, dataDir, currentAdmin) {
|
||||
// Advance Postgres identity sequences past the ids just inserted. Needed after
|
||||
// any explicit-id load; here it backs the SQLite → Postgres migration (#1038).
|
||||
async function resyncSequences(tables) {
|
||||
if (!isPostgres()) return;
|
||||
for (const table of tables) {
|
||||
try {
|
||||
if (!(await db.schema.hasColumn(table, 'id'))) continue;
|
||||
const res = await db.raw('SELECT pg_get_serial_sequence(?, ?) AS seq', [table, 'id']);
|
||||
const seq = res && res.rows && res.rows[0] && res.rows[0].seq;
|
||||
if (!seq) continue; // `id` isn't a serial/identity column
|
||||
await db.raw(
|
||||
'SELECT setval(?, (SELECT COALESCE(MAX(id), 1) FROM ??), (SELECT MAX(id) IS NOT NULL FROM ??))',
|
||||
[seq, table, table]
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(`[picpeak-import] could not resync sequence for ${table}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceAllTables(tables, dataDir, currentAdmin, { crossEngine = false } = {}) {
|
||||
await db.transaction(async (trx) => {
|
||||
if (isPostgres()) {
|
||||
try {
|
||||
@@ -215,7 +295,18 @@ async function replaceAllTables(tables, dataDir, currentAdmin) {
|
||||
const rows = parseNdjson(path.join(dataDir, `${table}.ndjson`));
|
||||
if (!rows.length) continue;
|
||||
const jsonCols = await jsonColumnsFor(trx, table);
|
||||
await trx.batchInsert(table, serialiseJsonColumns(rows, jsonCols), 100);
|
||||
let prepared = rows;
|
||||
let toSerialise = jsonCols;
|
||||
if (crossEngine) {
|
||||
prepared = coerceForTargetEngine(prepared, await typedColumnsFor(trx, table));
|
||||
// A sqlite-sourced archive already carries JSON columns as valid JSON
|
||||
// TEXT, which is exactly what pg wants. Serialising again would store
|
||||
// `{"a":1}` as the scalar string "{\"a\":1}" and would turn the JSON
|
||||
// literal `null` into SQL NULL.
|
||||
toSerialise = new Set();
|
||||
}
|
||||
prepared = serialiseJsonColumns(prepared, toSerialise);
|
||||
await trx.batchInsert(table, prepared, 100);
|
||||
}
|
||||
|
||||
await reinjectCurrentAdmin(trx, currentAdmin);
|
||||
@@ -273,7 +364,7 @@ async function detectExternalMedia() {
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.picpeakPath path to the uploaded/staged .picpeak
|
||||
* @param {number} [opts.currentAdminId] admin to preserve across the wipe
|
||||
* @returns {Promise<{restored:boolean, tables:number, filesRestored:number, usesExternalMedia:boolean, manifest:object}>}
|
||||
* @returns {Promise<{restored:boolean, tables:number, filesRestored:number, usesExternalMedia:boolean, crossEngine:boolean, manifest:object}>}
|
||||
*/
|
||||
async function importFromPicpeak({ picpeakPath, currentAdminId }) {
|
||||
const manifest = await readManifestFromZip(picpeakPath);
|
||||
@@ -285,6 +376,16 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Archives predating the manifest engine field get the target's engine —
|
||||
// i.e. the exact same-engine behavior. After validateManifest, a mismatch
|
||||
// can only be sqlite → pg.
|
||||
const targetEngine = isPostgres() ? 'pg' : 'sqlite';
|
||||
const sourceEngine = (manifest.database && manifest.database.engine) || targetEngine;
|
||||
const crossEngine = sourceEngine !== targetEngine;
|
||||
if (crossEngine) {
|
||||
logger.info(`[picpeak-import] cross-engine restore: ${sourceEngine} backup onto ${targetEngine} instance`);
|
||||
}
|
||||
|
||||
const currentAdmin = currentAdminId
|
||||
? await db('admin_users').where({ id: currentAdminId }).first()
|
||||
: null;
|
||||
@@ -316,14 +417,22 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) {
|
||||
logger.warn(`[picpeak-import] ignoring ${skipped.length} backup table(s) not present in this DB (or protected): ${skipped.join(', ')}`);
|
||||
}
|
||||
|
||||
await replaceAllTables(tables, dataDir, currentAdmin);
|
||||
await replaceAllTables(tables, dataDir, currentAdmin, { crossEngine });
|
||||
|
||||
// Post-commit fixup: rows are inserted with explicit ids, which leaves
|
||||
// Postgres identity sequences behind, so the next natural insert collides
|
||||
// on the primary key. Runs unconditionally, matching main — the guard used
|
||||
// to be `if (allowEngineSwitch)`, which this change removes, and which also
|
||||
// left a same-engine pg → pg restore with stale sequences.
|
||||
await resyncSequences(tables);
|
||||
|
||||
const filesRestored = await restoreFiles(staging);
|
||||
const usesExternalMedia = await detectExternalMedia();
|
||||
|
||||
logger.info(
|
||||
`[picpeak-import] restored ${tables.length} tables, ${filesRestored} files (externalMedia=${usesExternalMedia})`
|
||||
`[picpeak-import] restored ${tables.length} tables, ${filesRestored} files (externalMedia=${usesExternalMedia}, crossEngine=${crossEngine})`
|
||||
);
|
||||
return { restored: true, tables: tables.length, filesRestored, usesExternalMedia, manifest };
|
||||
return { restored: true, tables: tables.length, filesRestored, usesExternalMedia, crossEngine, manifest };
|
||||
} finally {
|
||||
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
@@ -333,5 +442,11 @@ module.exports = {
|
||||
importFromPicpeak,
|
||||
readManifestFromZip,
|
||||
validateManifest,
|
||||
// exported for testing — the cross-engine coercion (#1038)
|
||||
epochToIso,
|
||||
coerceForTargetEngine,
|
||||
typedColumnsFor,
|
||||
reinjectCurrentAdmin,
|
||||
// The cross-engine suite drives the post-restore sequence fixup directly.
|
||||
resyncSequences,
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const { db, withRetry, logActivity } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getAppSetting } = require('../utils/appSettings');
|
||||
@@ -1083,7 +1084,7 @@ async function persistDocPdf(type, doc, buffer) {
|
||||
const number = doc.quote_number || doc.invoice_number;
|
||||
if (!number) return null;
|
||||
const year = (doc.issue_date ? new Date(doc.issue_date) : new Date()).getFullYear();
|
||||
const root = path.join(process.cwd(), 'storage', 'business-docs', type, String(year));
|
||||
const root = path.join(getStoragePath(), 'business-docs', type, String(year));
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const filePath = path.join(root, `${number}.pdf`);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
@@ -65,6 +65,53 @@ describe('S3StorageAdapter', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should configure connection and socket-inactivity timeouts by default', () => {
|
||||
expect(S3Client).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestHandler: {
|
||||
connectionTimeout: 120000,
|
||||
socketTimeout: 60000
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep connectionTimeout generous enough to survive socket-pool queuing', () => {
|
||||
// connectionTimeout starts at request creation and only clears once a
|
||||
// socket is assigned AND connected, so waiting for a free socket from
|
||||
// the agent pool counts against it. A short value (e.g. 10s) fails
|
||||
// every read under concurrent upload load. These timeouts bound an
|
||||
// infinite hang; they are not latency targets.
|
||||
const [[config]] = S3Client.mock.calls;
|
||||
expect(config.requestHandler.connectionTimeout).toBeGreaterThanOrEqual(60000);
|
||||
expect(config.requestHandler.socketTimeout).toBeGreaterThanOrEqual(30000);
|
||||
});
|
||||
|
||||
it('should not set requestTimeout, which caps total duration and only warns', () => {
|
||||
// requestTimeout would abort legitimate large uploads (it is a
|
||||
// total-duration cap, not inactivity) and by default only logs a
|
||||
// warning — it needs throwOnRequestTimeout to abort at all.
|
||||
const [[config]] = S3Client.mock.calls;
|
||||
expect(config.requestHandler).not.toHaveProperty('requestTimeout');
|
||||
});
|
||||
|
||||
it('should allow overriding timeouts via config', () => {
|
||||
new S3StorageAdapter({
|
||||
bucket: 'test-bucket',
|
||||
connectionTimeout: 5000,
|
||||
socketTimeout: 30000
|
||||
});
|
||||
|
||||
expect(S3Client).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
requestHandler: {
|
||||
connectionTimeout: 5000,
|
||||
socketTimeout: 30000
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
@@ -239,6 +286,30 @@ describe('S3StorageAdapter', () => {
|
||||
s3Storage.config.retryDelay = originalDelay;
|
||||
});
|
||||
|
||||
it('should retry when the request handler times out a dead connection', async () => {
|
||||
// @smithy/node-http-handler rejects with name 'TimeoutError' for both
|
||||
// its connection-timeout and socket-inactivity timeouts
|
||||
const timeoutError = new Error('Connection timed out after 10000ms');
|
||||
timeoutError.name = 'TimeoutError';
|
||||
|
||||
const operation = jest.fn()
|
||||
.mockRejectedValueOnce(timeoutError)
|
||||
.mockResolvedValueOnce('success');
|
||||
|
||||
const originalRandom = Math.random;
|
||||
const originalDelay = s3Storage.config.retryDelay;
|
||||
Math.random = jest.fn(() => 0);
|
||||
s3Storage.config.retryDelay = 0;
|
||||
|
||||
const result = await s3Storage._retryOperation(operation);
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(operation).toHaveBeenCalledTimes(2);
|
||||
|
||||
Math.random = originalRandom;
|
||||
s3Storage.config.retryDelay = originalDelay;
|
||||
});
|
||||
|
||||
it('should not retry on non-retryable errors', async () => {
|
||||
const nonRetryableError = new Error('Invalid credentials');
|
||||
nonRetryableError.code = 'InvalidCredentials';
|
||||
|
||||
@@ -27,6 +27,9 @@ let instance = null;
|
||||
* STORAGE_S3_PREFIX — namespace prefix inside the bucket
|
||||
* STORAGE_S3_FORCE_PATH_STYLE=true|false (default: auto when endpoint set)
|
||||
* STORAGE_S3_SSL=true|false (default: true)
|
||||
* STORAGE_S3_CONNECTION_TIMEOUT — ms to acquire+establish a socket (default 120000)
|
||||
* STORAGE_S3_SOCKET_TIMEOUT — ms of socket inactivity before a request
|
||||
* fails and is retried (default 60000)
|
||||
*/
|
||||
function buildStorage() {
|
||||
const backend = (process.env.STORAGE_BACKEND || 'local').toLowerCase();
|
||||
@@ -48,6 +51,8 @@ function buildStorage() {
|
||||
prefix: process.env.STORAGE_S3_PREFIX,
|
||||
forcePathStyle: process.env.STORAGE_S3_FORCE_PATH_STYLE === 'true' ? true : undefined,
|
||||
sslEnabled: process.env.STORAGE_S3_SSL !== 'false',
|
||||
connectionTimeout: parseInt(process.env.STORAGE_S3_CONNECTION_TIMEOUT || '120000', 10),
|
||||
socketTimeout: parseInt(process.env.STORAGE_S3_SOCKET_TIMEOUT || '60000', 10),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ class S3StorageAdapter extends stream.EventEmitter {
|
||||
* @param {number} [config.partSize=10485760] - Part size for multipart upload (default 10MB)
|
||||
* @param {number} [config.maxRetries=3] - Maximum number of retry attempts
|
||||
* @param {number} [config.retryDelay=1000] - Initial retry delay in milliseconds
|
||||
* @param {number} [config.connectionTimeout=120000] - Ms to acquire+establish a socket
|
||||
* @param {number} [config.socketTimeout=60000] - Ms of socket inactivity before a request fails
|
||||
*/
|
||||
constructor(config) {
|
||||
super();
|
||||
@@ -58,13 +60,38 @@ class S3StorageAdapter extends stream.EventEmitter {
|
||||
partSize: 10 * 1024 * 1024, // 10MB
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000,
|
||||
connectionTimeout: 120000,
|
||||
socketTimeout: 60000,
|
||||
...config
|
||||
};
|
||||
|
||||
// Initialize S3 client
|
||||
const s3Config = {
|
||||
region: this.config.region,
|
||||
forcePathStyle: this.config.forcePathStyle
|
||||
forcePathStyle: this.config.forcePathStyle,
|
||||
// Without timeouts a silently dropped connection leaves the request —
|
||||
// and with it every queued upload — hanging forever.
|
||||
//
|
||||
// socketTimeout, NOT requestTimeout, is the right knob here:
|
||||
// requestTimeout is a total-duration cap that would kill legitimate
|
||||
// large uploads, and by default it only logs a warning (it needs
|
||||
// throwOnRequestTimeout to abort at all). socketTimeout fires on
|
||||
// socket INACTIVITY and destroys the request with a TimeoutError, so
|
||||
// an active transfer of any size is safe and only a dead line trips.
|
||||
//
|
||||
// Both values are deliberately GENEROUS. connectionTimeout starts
|
||||
// when the request object is created and only clears once a socket
|
||||
// is both assigned and connected — so time spent queuing for a free
|
||||
// socket from the agent pool (maxSockets 50) counts against it. A
|
||||
// 10s value looks reasonable and is not: under concurrent uploads
|
||||
// it expires while merely waiting in line, and every read (photo
|
||||
// download, thumbnail, background thumbnailing) fails with
|
||||
// TimeoutError. These timeouts exist to convert an INFINITE hang
|
||||
// into a bounded failure, not to enforce latency targets.
|
||||
requestHandler: {
|
||||
connectionTimeout: this.config.connectionTimeout,
|
||||
socketTimeout: this.config.socketTimeout
|
||||
}
|
||||
};
|
||||
|
||||
// Add credentials if provided
|
||||
@@ -671,7 +698,9 @@ class S3StorageAdapter extends stream.EventEmitter {
|
||||
}
|
||||
|
||||
// Check if error is retryable
|
||||
const retryableErrors = ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ESOCKETTIMEDOUT', 'RequestTimeout', 'SlowDown', 'ServiceUnavailable', 'InternalError'];
|
||||
// 'TimeoutError' is what @smithy/node-http-handler names both its
|
||||
// connection-timeout and socket-inactivity rejections.
|
||||
const retryableErrors = ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ESOCKETTIMEDOUT', 'RequestTimeout', 'TimeoutError', 'SlowDown', 'ServiceUnavailable', 'InternalError'];
|
||||
const isRetryable = retryableErrors.some(code =>
|
||||
error.code === code ||
|
||||
error.name === code ||
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Which database engine is this process actually using, and is that what the
|
||||
* operator intended? (#1038)
|
||||
*
|
||||
* knexfile.js selects its config block by NODE_ENV, and the `development`
|
||||
* block defaults to sqlite3. The Docker image never set NODE_ENV, so every
|
||||
* deployment that doesn't go through our compose files — Kubernetes, Helm,
|
||||
* plain `docker run` — silently landed on SQLite and ignored DB_HOST /
|
||||
* DB_USER / DB_PASSWORD entirely. wait-for-db.sh is shell and reads DB_HOST
|
||||
* directly, so the same container happily reported "PostgreSQL is up" while
|
||||
* the app wrote to a SQLite file.
|
||||
*
|
||||
* Now that the image pins NODE_ENV=production, those installs would resolve to
|
||||
* Postgres on their next pull — and come up against an EMPTY database, which
|
||||
* reads as total data loss. Blocking the boot would protect the data but take
|
||||
* the galleries offline for an operator who did nothing wrong, so instead we
|
||||
* STAY on SQLite (the engine that holds their data), say so loudly, and point
|
||||
* at the migration script. Nothing moves until the operator decides.
|
||||
*
|
||||
* decideBootEngine() is pure so the matrix is testable; the probes around it
|
||||
* are deliberately thin.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const { resolveSqliteFilename } = require('./sqlitePath');
|
||||
// Shared with knexfile so the engine guard can never probe a different target
|
||||
// than the application opens (#1038).
|
||||
const { pgConnectionFromEnv } = require('./pgConnection');
|
||||
|
||||
// Diagnostics go through an injected sink, never a module-level logger: the
|
||||
// resolver's STDOUT is a protocol channel (wait-for-db.sh captures it), and the
|
||||
// app logger writes there whenever LOG_TO_CONSOLE=true.
|
||||
const warnToStderr = (msg) => process.stderr.write(`${msg}\n`);
|
||||
|
||||
/** Absolute path of the SQLite file this install would use — the SAME
|
||||
* resolution knexfile performs, so the guard can never probe a different file
|
||||
* than the one knex opens. */
|
||||
function resolveSqlitePath() {
|
||||
return resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db');
|
||||
}
|
||||
|
||||
/** Human-readable "engine + target", safe to log — never includes credentials. */
|
||||
function describeEngine(knexConfig) {
|
||||
const client = knexConfig?.client || 'unknown';
|
||||
if (client === 'pg') {
|
||||
const c = knexConfig.connection || {};
|
||||
return `postgres (${c.host || 'unknown-host'}:${c.port || 5432}/${c.database || 'unknown-db'})`;
|
||||
}
|
||||
const filename = knexConfig?.connection?.filename || resolveSqlitePath();
|
||||
return `sqlite (${filename})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which engine should this boot actually use?
|
||||
*
|
||||
* @param {object} state
|
||||
* @param {string} state.configuredClient what knexfile resolved to
|
||||
* @param {string=} state.explicitClient DATABASE_CLIENT, if the operator set it
|
||||
* @param {boolean} state.pgHasData the Postgres target already holds galleries
|
||||
* @param {boolean} state.sqliteHasData a SQLite file exists AND holds events
|
||||
* @returns {{ client: string, overridden: boolean, reason: string|null }}
|
||||
*/
|
||||
function decideBootEngine({
|
||||
configuredClient, explicitClient, pgHasData, sqliteHasData,
|
||||
migrationInProgress = false, migrationCompleted = false, pgConfigured = false,
|
||||
}) {
|
||||
// A migration that never finished outranks everything, including an explicit
|
||||
// DATABASE_CLIENT=pg: Postgres may hold a half-written copy while SQLite is
|
||||
// still the database of record. Deleting the marker is the documented
|
||||
// override. (Explicit sqlite3 already points at the data, so leave it alone.)
|
||||
if (migrationInProgress && sqliteHasData && explicitClient !== 'sqlite3') {
|
||||
return { client: 'sqlite3', overridden: true, reason: 'migration-incomplete' };
|
||||
}
|
||||
|
||||
// The data was migrated to Postgres, but nothing in the environment says so:
|
||||
// DATABASE_CLIENT is unset and NODE_ENV still resolves to the development
|
||||
// block, i.e. sqlite3. That is the state the affected installs are IN — it is
|
||||
// why they ended up on SQLite in the first place — so an operator can easily
|
||||
// migrate before fixing it. The source file has been renamed away by then, so
|
||||
// honouring the implicit sqlite3 would create a NEW, empty database and serve
|
||||
// it. The marker is durable proof of where the data actually is.
|
||||
if (!explicitClient && configuredClient !== 'pg' && migrationCompleted && pgConfigured) {
|
||||
return { client: 'pg', overridden: true, reason: 'migrated-to-postgres' };
|
||||
}
|
||||
|
||||
// An explicit DATABASE_CLIENT is an instruction, not a guess. Never override
|
||||
// it — this is also the documented way to force Postgres and start fresh.
|
||||
if (explicitClient) {
|
||||
return {
|
||||
client: explicitClient,
|
||||
overridden: false,
|
||||
reason: explicitClient === 'pg' && sqliteHasData && !pgHasData
|
||||
? 'explicit-pg-leaves-sqlite-behind'
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// A migration started and never finished. Postgres may hold a partial copy,
|
||||
// which would otherwise read as "occupied" and win — while SQLite is still
|
||||
// the database of record.
|
||||
if (configuredClient === 'pg' && migrationInProgress && sqliteHasData) {
|
||||
return { client: 'sqlite3', overridden: true, reason: 'migration-incomplete' };
|
||||
}
|
||||
|
||||
// Both sides hold data and nothing records which is authoritative. This is
|
||||
// the shape of an install that ran on Postgres, silently fell to SQLite when
|
||||
// NODE_ENV was lost, and kept working there: the Postgres rows are real but
|
||||
// stale, and the SQLite rows are real and newer. A completed migration would
|
||||
// have left a marker; without one, guessing either way hides data and splits
|
||||
// subsequent writes across two databases. Stop and let a human decide.
|
||||
if (configuredClient === 'pg' && !migrationCompleted && pgHasData && sqliteHasData) {
|
||||
return { client: null, overridden: false, reason: 'ambiguous-both-populated' };
|
||||
}
|
||||
|
||||
// Configured for Postgres, Postgres holds no galleries, and real data sits in
|
||||
// a SQLite file: this install has been unknowingly running on SQLite. Keep
|
||||
// serving from where the data actually is. Deliberately keyed on DATA, not on
|
||||
// "has tables" — a stray migration run against the empty Postgres would
|
||||
// otherwise blind this check and strand the operator on an empty database.
|
||||
if (configuredClient === 'pg' && !pgHasData && sqliteHasData) {
|
||||
return { client: 'sqlite3', overridden: true, reason: 'stranded-sqlite-data' };
|
||||
}
|
||||
|
||||
return { client: configuredClient, overridden: false, reason: null };
|
||||
}
|
||||
|
||||
/** Marker written by scripts/migrate-sqlite-to-postgres.js once the data is in
|
||||
* Postgres. Its presence pins the install to Postgres for good: without it, a
|
||||
* Postgres that is merely EMPTY (every gallery deleted, say) would look
|
||||
* identical to one that was never migrated, and the boot would fall back to a
|
||||
* stale SQLite file that has been out of date since the migration. */
|
||||
function migrationMarkerPath(sqlitePath = resolveSqlitePath()) {
|
||||
return `${sqlitePath}.migrated-to-postgres`;
|
||||
}
|
||||
|
||||
function hasMigrationMarker(sqlitePath = resolveSqlitePath()) {
|
||||
return fs.existsSync(migrationMarkerPath(sqlitePath));
|
||||
}
|
||||
|
||||
/** The marker's contents, or null when absent/unreadable. */
|
||||
function readMigrationMarker(sqlitePath = resolveSqlitePath()) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(migrationMarkerPath(sqlitePath), 'utf8'));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `host:port/database`, the identity the migration records and compares. */
|
||||
function currentPgTargetId() {
|
||||
const c = pgConnectionFromEnv();
|
||||
return `${c.host}:${c.port}/${c.database}`;
|
||||
}
|
||||
|
||||
/** Written before the migration touches Postgres, cleared only on success.
|
||||
* While it exists, Postgres may hold a PARTIAL copy — or just the bootstrap
|
||||
* admin that schema creation seeds — and SQLite is still the authoritative
|
||||
* database. Without this pin, a migration that failed after writing anything
|
||||
* to Postgres would make the next boot switch engines and hide the real data. */
|
||||
function migrationInProgressPath(sqlitePath = resolveSqlitePath()) {
|
||||
return `${sqlitePath}.migration-in-progress`;
|
||||
}
|
||||
|
||||
function hasMigrationInProgress(sqlitePath = resolveSqlitePath()) {
|
||||
return fs.existsSync(migrationInProgressPath(sqlitePath));
|
||||
}
|
||||
|
||||
// Tables that are EMPTY on a freshly migrated schema, so a row in any of them
|
||||
// means a human has used this install. Deliberately wider than `events`:
|
||||
// judging occupancy by galleries alone would abandon an install whose galleries
|
||||
// were all deleted but whose admins, customers and accounting records remain.
|
||||
// Mirrors USER_DATA_TABLES in scripts/migrate-sqlite-to-postgres.js.
|
||||
const USER_DATA_TABLES = [
|
||||
'events', 'photos', 'photo_feedback', 'admin_users', 'customer_accounts',
|
||||
'quotes', 'invoices', 'projects', 'expenses', 'inbound_documents',
|
||||
];
|
||||
|
||||
// core/001_init.js seeds an admin with must_change_password = true when
|
||||
// ADMIN_PASSWORD is set; setupService writes false once a human completes
|
||||
// first-run setup. So the FLAG, not the table, is what distinguishes an
|
||||
// untouched bootstrap row from a real account. Dropping the whole table (as an
|
||||
// earlier revision did) made a legitimately set-up Postgres look empty, which
|
||||
// would hand the install to a stale SQLite file and lose the admin's
|
||||
// credentials and configuration.
|
||||
const isUntouchedBootstrapRow = (v) => v === true || v === 1 || v === '1';
|
||||
|
||||
// Has anyone actually USED this install's admin accounts? Layered, because no
|
||||
// single column survives every path:
|
||||
// - more than one admin → somebody created accounts
|
||||
// - any admin has logged in → real use, even if the password was later reset
|
||||
// - must_change_password false → first-run setup was completed
|
||||
// Only the exact shape core/001_init.js leaves behind — one admin, never logged
|
||||
// in, still flagged — reads as an untouched bootstrap seed.
|
||||
function adminsIndicateUse(rows) {
|
||||
if (rows.length > 1) return true;
|
||||
return rows.some((r) => r.last_login || !isUntouchedBootstrapRow(r.must_change_password));
|
||||
}
|
||||
|
||||
async function countsAsUse(conn, table, { ignoreBootstrapAdmins }) {
|
||||
if (table === 'admin_users' && ignoreBootstrapAdmins) {
|
||||
const cols = ['must_change_password'];
|
||||
if (await conn.schema.hasColumn('admin_users', 'last_login')) cols.push('last_login');
|
||||
return adminsIndicateUse(await conn('admin_users').select(cols));
|
||||
}
|
||||
const row = await conn(table).count('* as count').first();
|
||||
return Number(row?.count || 0) > 0;
|
||||
}
|
||||
|
||||
async function anyUserData(conn, { ignoreBootstrapAdmins = false } = {}) {
|
||||
for (const table of USER_DATA_TABLES) {
|
||||
if (!(await conn.schema.hasTable(table))) continue;
|
||||
if (await countsAsUse(conn, table, { ignoreBootstrapAdmins })) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True when a SQLite file exists and carries user data. */
|
||||
async function probeSqliteData(sqlitePath = resolveSqlitePath(), onWarn = warnToStderr) {
|
||||
if (hasMigrationMarker(sqlitePath)) return false;
|
||||
if (!fs.existsSync(sqlitePath)) return false;
|
||||
const knex = require('knex');
|
||||
const probe = knex({
|
||||
client: 'sqlite3',
|
||||
connection: { filename: sqlitePath },
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
try {
|
||||
// Same discrimination as the Postgres side. An accidental SQLite database
|
||||
// gets a seeded admin from core/001_init.js when ADMIN_PASSWORD is set, and
|
||||
// counting that as use would make a healthy Postgres install look like a
|
||||
// both-populated conflict and refuse to boot. A setup-completed or
|
||||
// logged-in admin still counts.
|
||||
return await anyUserData(probe, { ignoreBootstrapAdmins: true });
|
||||
} catch (err) {
|
||||
// Unreadable or corrupt: fail CLOSED. Reporting "no data" here would switch
|
||||
// the install to an empty Postgres — the precise failure this module exists
|
||||
// to prevent. Staying on SQLite surfaces the real error instead.
|
||||
onWarn(
|
||||
`[database-engine] SQLite at ${sqlitePath} exists but could not be probed (${err.message}); `
|
||||
+ 'assuming it holds data and staying on it.'
|
||||
);
|
||||
return true;
|
||||
} finally {
|
||||
await probe.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the configured Postgres target already holds user data. */
|
||||
async function probePgData(pgConnection, onWarn = warnToStderr) {
|
||||
const knex = require('knex');
|
||||
const probe = knex({ client: 'pg', connection: pgConnection, pool: { min: 0, max: 1 } });
|
||||
try {
|
||||
// Two very different failures hide behind one catch, and they need opposite
|
||||
// answers, so establish reachability first — this branch returns, so
|
||||
// everything below it is reachable-by-construction.
|
||||
try {
|
||||
await probe.raw('SELECT 1');
|
||||
} catch (err) {
|
||||
// Cannot reach Postgres at all. The app could not run on it either way,
|
||||
// so report "occupied" to avoid diverting a healthy pg install to a stale
|
||||
// SQLite file over a transient network blip — startup then fails with the
|
||||
// real connection error, exactly as it always has.
|
||||
onWarn(`[database-engine] Postgres unreachable while probing (${err.message}); leaving the configured engine alone.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Substantive use only: an untouched bootstrap admin does not make a
|
||||
// Postgres target worth switching to, but a completed setup does.
|
||||
return await anyUserData(probe, { ignoreBootstrapAdmins: true });
|
||||
} catch (err) {
|
||||
// Connected, but the query failed — a half-built or damaged schema. That
|
||||
// is NOT evidence of data: reporting "occupied" here would boot the empty
|
||||
// Postgres and hide a populated SQLite file, the exact failure this guard
|
||||
// exists to prevent. Say "not proven occupied" and let the SQLite side win
|
||||
// if it actually holds data.
|
||||
onWarn(`[database-engine] Postgres reachable but could not be inspected (${err.message}); treating it as unproven rather than occupied.`);
|
||||
return false;
|
||||
}
|
||||
} finally {
|
||||
await probe.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
const CONFLICT_MESSAGE = (sqlitePath, pgTarget) => `
|
||||
${'='.repeat(78)}
|
||||
REFUSING TO START — two databases, both with data, and no record of which is current.
|
||||
|
||||
sqlite : ${sqlitePath}
|
||||
postgres : ${pgTarget}
|
||||
|
||||
This is what an install looks like after it ran on PostgreSQL, lost NODE_ENV or
|
||||
DATABASE_CLIENT, and kept working on SQLite without anyone noticing (see
|
||||
https://github.com/PicPeak/picpeak/issues/1038). The PostgreSQL rows are real
|
||||
but probably old; the SQLite rows are real and probably newer.
|
||||
|
||||
Starting either one would hide the other's galleries and split every new upload
|
||||
across two databases, so PicPeak will not choose for you. Compare them, then say
|
||||
which is authoritative:
|
||||
|
||||
DATABASE_CLIENT=sqlite3 keep serving the SQLite file (its data is newer)
|
||||
DATABASE_CLIENT=pg keep serving PostgreSQL
|
||||
|
||||
To combine them, start on SQLite and run: node scripts/migrate-sqlite-to-postgres.js
|
||||
(it replaces the PostgreSQL contents with the SQLite data and records the switch).
|
||||
${'='.repeat(78)}
|
||||
`.trim();
|
||||
|
||||
const STRANDED_WARNING = (sqlitePath, pgTarget) => `
|
||||
${'='.repeat(78)}
|
||||
STILL RUNNING ON SQLITE — Postgres is configured but empty.
|
||||
|
||||
data in use : ${sqlitePath}
|
||||
configured : ${pgTarget} (no galleries in it)
|
||||
|
||||
This install has been running on SQLite. Until now the image left NODE_ENV
|
||||
unset, so knexfile.js fell back to its development block and ignored DB_HOST /
|
||||
DB_USER / DB_PASSWORD — see https://github.com/PicPeak/picpeak/issues/1038.
|
||||
|
||||
Nothing has changed for you: your galleries are served from the SQLite file
|
||||
above, exactly as before. Switching engines now would start from an empty
|
||||
database, so PicPeak will not do that on its own.
|
||||
|
||||
To move your data to Postgres when you are ready:
|
||||
|
||||
node scripts/migrate-sqlite-to-postgres.js
|
||||
|
||||
It copies every row into Postgres and leaves the SQLite file untouched as a
|
||||
fallback. To go to Postgres WITHOUT the data, set DATABASE_CLIENT=pg.
|
||||
${'='.repeat(78)}
|
||||
`.trim();
|
||||
|
||||
/**
|
||||
* Resolve the engine for this boot, log what happened, and return the client
|
||||
* the process should use. Called before migrations touch anything.
|
||||
*/
|
||||
async function resolveBootEngine({ knexConfig, logger }) {
|
||||
const explicitClient = process.env.DATABASE_CLIENT || null;
|
||||
const configuredClient = knexConfig?.client;
|
||||
const sqlitePath = resolveSqlitePath();
|
||||
|
||||
// Probe whenever Postgres is the engine in play — including when it was named
|
||||
// explicitly, otherwise the "leaving SQLite behind" warning is unreachable.
|
||||
const effectiveClient = explicitClient || configuredClient;
|
||||
const migrationInProgress = hasMigrationInProgress(sqlitePath);
|
||||
const marker = readMigrationMarker(sqlitePath);
|
||||
const migrationCompleted = hasMigrationMarker(sqlitePath);
|
||||
// The marker vouches for ONE Postgres. If the configuration now points at a
|
||||
// different one, it says nothing about that target — and trusting it would
|
||||
// boot an unrelated empty database while the real data sits in the recorded
|
||||
// one and in the renamed rollback copy.
|
||||
const markerTargetMismatch = Boolean(
|
||||
migrationCompleted && marker && marker.target && marker.target !== currentPgTargetId(),
|
||||
);
|
||||
const pgConfigured = Boolean(process.env.DB_HOST || process.env.DB_PASSWORD);
|
||||
// Probe when Postgres is in play, and also whenever a migration is pinned or
|
||||
// finished — those decisions need to know what each side holds.
|
||||
const probing = effectiveClient === 'pg' || migrationInProgress || migrationCompleted;
|
||||
const decision = decideBootEngine({
|
||||
configuredClient,
|
||||
explicitClient,
|
||||
pgHasData: probing
|
||||
? await probePgData(
|
||||
knexConfig.client === 'pg' ? knexConfig.connection : pgConnectionFromEnv(),
|
||||
(m) => logger.warn(m),
|
||||
)
|
||||
: true,
|
||||
sqliteHasData: probing ? await probeSqliteData(sqlitePath, (m) => logger.warn(m)) : false,
|
||||
migrationInProgress,
|
||||
migrationCompleted,
|
||||
pgConfigured,
|
||||
});
|
||||
|
||||
if (markerTargetMismatch) {
|
||||
logger.error(`
|
||||
${'='.repeat(78)}
|
||||
REFUSING TO START — this install was migrated to a different PostgreSQL.
|
||||
|
||||
migrated to : ${marker.target}
|
||||
configured : ${currentPgTargetId()}
|
||||
|
||||
${migrationMarkerPath(sqlitePath)} records where the data was moved. The current
|
||||
settings point somewhere else, so starting would open an unrelated database and
|
||||
present an empty installation while your galleries stay in the one above.
|
||||
|
||||
Either restore the original connection settings, or — if this move is deliberate
|
||||
and the data is already in the new target — update the "target" field in that
|
||||
marker file to match.
|
||||
${'='.repeat(78)}
|
||||
`.trim());
|
||||
return { client: null, overridden: false, reason: 'marker-target-mismatch' };
|
||||
}
|
||||
|
||||
if (decision.reason === 'ambiguous-both-populated') {
|
||||
logger.error(CONFLICT_MESSAGE(sqlitePath, describeEngine({
|
||||
client: 'pg', connection: pgConnectionFromEnv(),
|
||||
})));
|
||||
return decision;
|
||||
}
|
||||
|
||||
if (decision.reason === 'migrated-to-postgres') {
|
||||
logger.warn(
|
||||
`This install's data was migrated to PostgreSQL (${migrationMarkerPath(sqlitePath)}), but the `
|
||||
+ 'environment still resolves to SQLite. Using PostgreSQL — set NODE_ENV=production (or '
|
||||
+ 'DATABASE_CLIENT=pg) to make that explicit.'
|
||||
);
|
||||
} else if (decision.reason === 'migration-incomplete') {
|
||||
logger.warn(
|
||||
`A SQLite → PostgreSQL migration did not finish (${migrationInProgressPath(sqlitePath)} is still `
|
||||
+ 'present), so PostgreSQL may hold a partial copy. Staying on SQLite, which is still the '
|
||||
+ 'database of record. Re-run scripts/migrate-sqlite-to-postgres.js with the backend stopped; '
|
||||
+ 'delete that file only if you have decided to abandon the migration.'
|
||||
);
|
||||
} else if (decision.overridden && decision.reason === 'stranded-sqlite-data') {
|
||||
logger.warn(STRANDED_WARNING(sqlitePath, describeEngine(knexConfig)));
|
||||
} else if (decision.reason === 'explicit-pg-leaves-sqlite-behind') {
|
||||
logger.warn(
|
||||
'DATABASE_CLIENT=pg is set explicitly, so PicPeak is starting on an empty Postgres while '
|
||||
+ `gallery data exists at ${sqlitePath}. Run scripts/migrate-sqlite-to-postgres.js to bring it across.`
|
||||
);
|
||||
}
|
||||
|
||||
// Describe what was DECIDED, not what knexfile said: after a marker override
|
||||
// knexConfig still describes SQLite while the process goes to Postgres.
|
||||
logger.info(`Database engine: ${decision.client === 'pg'
|
||||
? describeEngine(knexConfig.client === 'pg' ? knexConfig : { client: 'pg', connection: pgConnectionFromEnv() })
|
||||
: `sqlite (${sqlitePath})`}`);
|
||||
return decision;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveSqlitePath,
|
||||
pgConnectionFromEnv,
|
||||
isUntouchedBootstrapRow,
|
||||
adminsIndicateUse,
|
||||
migrationMarkerPath,
|
||||
hasMigrationMarker,
|
||||
readMigrationMarker,
|
||||
currentPgTargetId,
|
||||
migrationInProgressPath,
|
||||
hasMigrationInProgress,
|
||||
describeEngine,
|
||||
decideBootEngine,
|
||||
probeSqliteData,
|
||||
probePgData,
|
||||
resolveBootEngine,
|
||||
};
|
||||
@@ -20,6 +20,19 @@
|
||||
* - The PDF's internal `Title` metadata (Chrome's PDF viewer
|
||||
* uses this as the default name when saving from a blob URL,
|
||||
* where Content-Disposition can't reach)
|
||||
*
|
||||
* IMPORTANT (#1024): the preserved non-ASCII is exactly what a raw
|
||||
* `filename="${...}"` header cannot carry. HTTP header values are
|
||||
* latin1, so a customer label reaching a header directly either
|
||||
* mangles (U+0080-U+00FF — every German umlaut: `Müller` is sent as
|
||||
* the byte 0xFC and read back as garbage) or throws ERR_INVALID_CHAR
|
||||
* and 500s the request (anything above U+00FF — Polish ł, Czech ř,
|
||||
* Turkish ş, €, Cyrillic, CJK, emoji).
|
||||
*
|
||||
* Never interpolate this result into a header. Pass it through
|
||||
* `buildContentDisposition()` in utils/filenameSanitizer, which emits
|
||||
* an ASCII fallback plus the RFC 5987 `filename*=UTF-8''…` form so the
|
||||
* unicode name survives in browsers and the header stays legal.
|
||||
*/
|
||||
|
||||
function sanitiseSegment(input, maxLen = 80) {
|
||||
@@ -33,7 +46,18 @@ function sanitiseSegment(input, maxLen = 80) {
|
||||
s = s.replace(/-+/g, '-');
|
||||
// Trim leading/trailing dashes + dots.
|
||||
s = s.replace(/^[-.]+|[-.]+$/g, '');
|
||||
if (s.length > maxLen) s = s.slice(0, maxLen);
|
||||
if (s.length > maxLen) {
|
||||
s = s.slice(0, maxLen);
|
||||
// slice() cuts UTF-16 code units, so a boundary landing inside an astral
|
||||
// character (emoji, rarer CJK) leaves a dangling high surrogate. That is
|
||||
// not merely cosmetic: the lone surrogate makes encodeURIComponent throw
|
||||
// `URIError: URI malformed` inside buildContentDisposition, which 500s
|
||||
// the PDF endpoint — the exact failure #1024 set out to remove, just via
|
||||
// a different route. Drop the orphan rather than widening the cap, so the
|
||||
// byte budget this limit exists to protect is unchanged.
|
||||
const lastUnit = s.charCodeAt(s.length - 1);
|
||||
if (lastUnit >= 0xD800 && lastUnit <= 0xDBFF) s = s.slice(0, -1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* The PostgreSQL target, resolved in exactly one place (#1038).
|
||||
*
|
||||
* Three different defaults for the same connection used to coexist:
|
||||
*
|
||||
* knexfile development : localhost / postgres / photo_sharing
|
||||
* knexfile production : db / picpeak / picpeak
|
||||
* wait-for-db.sh : postgres / picpeak / picpeak (and it EXPORTS them)
|
||||
*
|
||||
* so a process that probed or migrated against one could hand over to a process
|
||||
* that opened another. Two review rounds in a row traced back to that, each
|
||||
* time through a caller the previous fix had not covered — the engine guard,
|
||||
* the migration CLI's child phases, then server.js.
|
||||
*
|
||||
* The host and user defaults are the ones a running container actually uses,
|
||||
* because wait-for-db.sh resolves and exports them before anything starts.
|
||||
* The database name matters most: a wrong host or user fails loudly at connect
|
||||
* time, while a wrong database name connects fine and presents an empty
|
||||
* installation.
|
||||
*
|
||||
* Reading process.env on every call is deliberate — the entrypoint and
|
||||
* server.js both normalise these variables before the app opens a pool.
|
||||
*/
|
||||
|
||||
function pgConnectionFromEnv() {
|
||||
return {
|
||||
host: process.env.DB_HOST || 'postgres',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'picpeak',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'picpeak',
|
||||
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { pgConnectionFromEnv };
|
||||
@@ -35,10 +35,15 @@
|
||||
*
|
||||
* **What the contract surface uses**
|
||||
*
|
||||
* Two roots:
|
||||
* 1. `<cwd>/storage/business-docs/contract/<year>/` — system-stamped
|
||||
* PDFs (immutable as-sent + signed copies).
|
||||
* 2. `<STORAGE_PATH or cwd/storage>/uploads/contracts/signed/` —
|
||||
* Three roots:
|
||||
* 1. `<storage root>/business-docs/contract/` — system-stamped PDFs
|
||||
* (immutable as-sent + signed copies) and the signature images
|
||||
* below them. This is where the writers persist.
|
||||
* 2. `<cwd>/storage/business-docs/contract/` — the same tree as written
|
||||
* before the writers moved onto the shared storage resolver. Kept so
|
||||
* pre-existing rows, whose absolute paths are in the database, still
|
||||
* resolve; identical to (1) on a stock compose install.
|
||||
* 3. `<storage root>/uploads/contracts/signed/` —
|
||||
* wet-upload PDFs (admin or customer-supplied).
|
||||
*
|
||||
* Both roots are constants from the operator's perspective; legitimate
|
||||
@@ -48,6 +53,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { AppError } = require('./errors');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
|
||||
/**
|
||||
* Resolve the canonical (symlink-followed) absolute path. Throws
|
||||
@@ -111,8 +117,22 @@ function assertPathInside(filePath, allowedRoots) {
|
||||
*/
|
||||
function assertContractPdfPath(filePath) {
|
||||
const cwd = process.cwd();
|
||||
const storageRoot = process.env.STORAGE_PATH || path.join(cwd, 'storage');
|
||||
// getStoragePath() rather than a second `STORAGE_PATH || cwd` expression:
|
||||
// the two disagree whenever STORAGE_PATH is unset, because the shared
|
||||
// resolver falls back module-relative (<repo>/storage) while this file used
|
||||
// to fall back to <cwd>/storage — and the backend is normally started from
|
||||
// backend/, so those are different directories. The writers use the shared
|
||||
// resolver, so a guard with its own idea of the root refuses exactly the
|
||||
// files it is meant to serve.
|
||||
const storageRoot = getStoragePath();
|
||||
return assertPathInside(filePath, [
|
||||
// The configured storage root is where the contract writers persist, so it
|
||||
// has to be allowed here or every generated PDF is refused with
|
||||
// PATH_OUTSIDE_STORAGE the moment STORAGE_PATH is not <cwd>/storage. The
|
||||
// cwd root stays alongside it: contracts written before the writers moved
|
||||
// still live there, and their absolute paths are recorded in the database.
|
||||
// Both collapse to the same directory on a stock compose install.
|
||||
path.join(storageRoot, 'business-docs', 'contract'),
|
||||
path.join(cwd, 'storage', 'business-docs', 'contract'),
|
||||
path.join(storageRoot, 'uploads', 'contracts', 'signed'),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Where this install's SQLite database lives.
|
||||
*
|
||||
* Extracted from knexfile.js so the engine guard (#1038) resolves EXACTLY the
|
||||
* same path knex opens. When the two disagree — a DATABASE_PATH with stray
|
||||
* whitespace, or the legacy duplicated-backend form this collapses — the guard
|
||||
* probes a file nobody uses, concludes there is no SQLite data, and lets the
|
||||
* boot switch to an empty Postgres while the real galleries sit in the file it
|
||||
* failed to look at.
|
||||
*
|
||||
* Behaviour is unchanged from the original; only its home moved.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const BACKEND_ROOT = path.resolve(__dirname, '..', '..');
|
||||
|
||||
function resolveSqliteFilename(filenameEnv, baseDir = BACKEND_ROOT) {
|
||||
const fallback = path.join(baseDir, './data/photo_sharing.db');
|
||||
|
||||
if (!filenameEnv) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const trimmed = String(filenameEnv).trim();
|
||||
if (!trimmed) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let resolved;
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
resolved = trimmed;
|
||||
} else if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
|
||||
resolved = path.resolve(baseDir, trimmed);
|
||||
} else {
|
||||
resolved = path.join(baseDir, trimmed);
|
||||
}
|
||||
|
||||
const normalized = path.normalize(resolved);
|
||||
const baseSuffix = path.relative(path.parse(baseDir).root, path.normalize(baseDir));
|
||||
const duplicatePattern = `${path.sep}${baseSuffix}${path.sep}${baseSuffix}`;
|
||||
|
||||
if (normalized.includes(duplicatePattern)) {
|
||||
return normalized.replace(duplicatePattern, `${path.sep}${baseSuffix}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
module.exports = { resolveSqliteFilename };
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Pipe a file/storage stream to an Express response without betting the
|
||||
* process on the source still being there (#1128).
|
||||
*
|
||||
* `fs.createReadStream` — what LocalFsStorage.get() returns — is LAZY. It
|
||||
* resolves immediately and only opens the file on a later tick, so an ENOENT
|
||||
* arrives AFTER the `await` returned and outside the route's try/catch. An
|
||||
* EventEmitter that emits 'error' with no listener throws, and an uncaught
|
||||
* throw from an I/O callback is not something Express can catch: Node exits.
|
||||
*
|
||||
* That is how one missing thumbnail tier took down every gallery on the
|
||||
* install — the process died on the first grid load and only came back
|
||||
* because Docker restarted it.
|
||||
*
|
||||
* The window is real and cannot be closed by a stat() beforehand: between the
|
||||
* stat and the open, another request regenerating the same derivative can
|
||||
* unlink it. So the handler is the fix, not the preflight.
|
||||
*/
|
||||
|
||||
const logger = require('./logger');
|
||||
|
||||
/**
|
||||
* @param {import('stream').Readable} stream source, already opened or lazy
|
||||
* @param {import('express').Response} res
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.context] what was being served, for the log line
|
||||
* @param {number} [options.missingStatus=404] status when the source is gone
|
||||
*/
|
||||
function pipeStreamToResponse(stream, res, options = {}) {
|
||||
const { context = 'file', missingStatus = 404 } = options;
|
||||
|
||||
stream.on('error', (err) => {
|
||||
const gone = err && (err.code === 'ENOENT' || err.code === 'EISDIR');
|
||||
|
||||
// Once bytes are on the wire the status line is spent — there is no way to
|
||||
// turn this into a 404. Destroy the response so the client sees a broken
|
||||
// connection rather than a silently truncated image it would cache.
|
||||
if (res.headersSent) {
|
||||
logger.warn(`Stream failed mid-response for ${context}: ${err.message}`);
|
||||
res.destroy(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Every header staged for the FILE now describes a body that will never
|
||||
// be sent. They are cleared rather than left to Express, which does not
|
||||
// overwrite a Content-Type that is already set — so without this the JSON
|
||||
// error goes out as `image/jpeg`, or as an `application/zip` attachment
|
||||
// that saves to disk as a corrupt download.
|
||||
//
|
||||
// Cache-Control matters most. The image routes stage `max-age=1800` (the
|
||||
// hero route 3600), so a 404 from the regeneration race — the transient
|
||||
// case this whole helper exists for — would be cached as a broken tile for
|
||||
// up to an hour after the tier finished generating.
|
||||
res.removeHeader('Content-Length');
|
||||
res.removeHeader('ETag');
|
||||
res.removeHeader('Content-Type');
|
||||
res.removeHeader('Content-Disposition');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
if (gone) {
|
||||
// Expected under the regeneration race — the tier existed at stat time
|
||||
// and was replaced before the open. One broken tile, not an outage.
|
||||
logger.warn(`Source vanished while serving ${context}: ${err.message}`);
|
||||
res.status(missingStatus).json({ error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error(`Failed to stream ${context}`, { error: err.message, code: err.code });
|
||||
res.status(500).json({ error: 'Failed to serve file' });
|
||||
});
|
||||
|
||||
// A client that navigates away mid-download leaves the source handle open
|
||||
// otherwise; on a gallery grid that is one leaked fd per abandoned tile.
|
||||
res.on('close', () => {
|
||||
if (!res.writableEnded) stream.destroy();
|
||||
});
|
||||
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
module.exports = { pipeStreamToResponse };
|
||||
@@ -59,6 +59,16 @@ host="${DB_HOST:-postgres}"
|
||||
port="${DB_PORT:-5432}"
|
||||
user="${DB_USER:-picpeak}"
|
||||
target_db="${DB_NAME:-picpeak}"
|
||||
|
||||
# Hand the app EXACTLY the connection this script verified. knexfile's
|
||||
# production block defaults DB_HOST to `db` while this script defaults to
|
||||
# `postgres`, so a bare `docker run` with no DB_HOST would have had the
|
||||
# readiness check pass against one host and the app then dial another (#1038
|
||||
# review). Compose sets DB_HOST explicitly and is unaffected.
|
||||
export DB_HOST="$host"
|
||||
export DB_PORT="$port"
|
||||
export DB_USER="$user"
|
||||
export DB_NAME="$target_db"
|
||||
# Use target database for checks - the picpeak user may not have access to 'postgres' database
|
||||
default_db="${DB_CHECK_DB:-$target_db}"
|
||||
|
||||
@@ -120,6 +130,36 @@ 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
|
||||
|
||||
# Resolve which database engine this boot should use (#1038) BEFORE migrations
|
||||
# run, while the Postgres target is still untouched. An install that has been
|
||||
# unknowingly running on SQLite (the image used to leave NODE_ENV unset, so
|
||||
# knexfile.js fell back to sqlite3 and ignored DB_HOST/DB_USER/DB_PASSWORD)
|
||||
# keeps serving from its SQLite file instead of coming up against an empty
|
||||
# Postgres. The exported value survives the `exec` below, so the migration
|
||||
# runner and the server agree on the engine.
|
||||
RESOLVED_DB_CLIENT="$(node scripts/resolve-db-engine.js)"
|
||||
RESOLVER_STATUS=$?
|
||||
# Exit 3 means two populated databases with no record of which is current
|
||||
# (#1038). Starting either would hide the other's data, so stop here — the
|
||||
# resolver has already printed what to do.
|
||||
if [ "$RESOLVER_STATUS" = "3" ]; then
|
||||
exit 1
|
||||
fi
|
||||
# Validate rather than trust: anything unexpected on stdout (a stray log line
|
||||
# from a library that writes to the console) must not become DATABASE_CLIENT,
|
||||
# which would break knexfile for every process that follows.
|
||||
case "$RESOLVED_DB_CLIENT" in
|
||||
pg|sqlite3)
|
||||
export DATABASE_CLIENT="$RESOLVED_DB_CLIENT"
|
||||
;;
|
||||
"")
|
||||
>&2 echo "Database engine resolver returned nothing; falling back to the configured client."
|
||||
;;
|
||||
*)
|
||||
>&2 echo "Database engine resolver returned an unexpected value; ignoring it and falling back to the configured client."
|
||||
;;
|
||||
esac
|
||||
|
||||
# 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).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.45.14",
|
||||
"version": "3.46.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -16,6 +16,7 @@ interface RestoreResult {
|
||||
tables: number;
|
||||
filesRestored: number;
|
||||
usesExternalMedia: boolean;
|
||||
crossEngine?: boolean;
|
||||
sessionInvalidated?: boolean;
|
||||
}
|
||||
|
||||
@@ -137,7 +138,7 @@ export const PicpeakRestoreCard: React.FC = () => {
|
||||
{t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')}
|
||||
{t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Restoring a SQLite backup onto a PostgreSQL instance is supported (the upgrade path); other engine combinations must match.')}
|
||||
</p>
|
||||
<input ref={fileRef} type="file" accept=".picpeak,application/zip" className="hidden" onChange={onFilePick} />
|
||||
<Button
|
||||
@@ -163,6 +164,11 @@ export const PicpeakRestoreCard: React.FC = () => {
|
||||
files: result.filesRestored,
|
||||
})}
|
||||
</p>
|
||||
{result.crossEngine && (
|
||||
<p className="mt-0.5 text-xs text-green-700 dark:text-green-300">
|
||||
{t('backup.picpeak.crossEngineNote', 'Cross-engine restore: a SQLite backup was converted onto this PostgreSQL instance.')}
|
||||
</p>
|
||||
)}
|
||||
{result.usesExternalMedia && (
|
||||
<p className="mt-2 flex items-start gap-1 text-xs text-amber-800 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
|
||||
@@ -33,6 +33,20 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface GalleryViewProps {
|
||||
slug: string;
|
||||
/**
|
||||
* Whether this gallery is password-protected (#1149).
|
||||
*
|
||||
* Drives the Logout button. Logging out of a gallery that asks for nothing
|
||||
* is meaningless — there is no credential to drop and nothing to return to
|
||||
* — and it used to strand the visitor: GalleryPage's auto-login is a
|
||||
* one-shot latch, so clearing the session left the page rendering its
|
||||
* skeleton until a manual reload.
|
||||
*
|
||||
* A client (PIN) session still gets the button on a public gallery: that
|
||||
* one IS a credential, and it is the only way back to the guest view. So is
|
||||
* a customer-portal session.
|
||||
*/
|
||||
requiresPassword?: boolean;
|
||||
event: {
|
||||
id: number;
|
||||
event_name: string;
|
||||
@@ -67,9 +81,9 @@ const parseDefaultPhotoSort = (defaultSort?: string): { sortBy: 'date' | 'name'
|
||||
}
|
||||
};
|
||||
|
||||
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresPassword = true }) => {
|
||||
const { t } = useTranslation();
|
||||
const { logout, isClient } = useGalleryAuth();
|
||||
const { logout, isClient, viaCustomer } = useGalleryAuth();
|
||||
const { setTheme, theme } = useTheme();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
||||
@@ -705,6 +719,18 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
// Skip all wrapper elements (header, footer, sidebar, filters) for these layouts
|
||||
const isFullPageLayout = theme.galleryLayout === 'gallery-premium' || theme.galleryLayout === 'gallery-story';
|
||||
|
||||
// Does this session hold something worth dropping? A password gallery and a
|
||||
// PIN client obviously do, and so does a customer-portal session — its token
|
||||
// opens the gallery without the password and lives for 24h in a cookie the
|
||||
// customer logout does not clear.
|
||||
//
|
||||
// Read from the auth context, which resolves it from /auth/session on mount.
|
||||
// accessLevel used to come from sessionStorage alone, which is per-TAB while
|
||||
// the cookie is per-browser: a gallery reopened in a second tab lost
|
||||
// 'client' while the backend went on serving it as one, and the gate would
|
||||
// then hide the only control that clears the privileged cookie (#1149).
|
||||
const showLogoutControl = requiresPassword || isClient || viaCustomer;
|
||||
|
||||
// For full-page layouts, render just the PhotoGridWithLayouts without any wrappers
|
||||
if (isFullPageLayout) {
|
||||
return (
|
||||
@@ -745,7 +771,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
|
||||
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
|
||||
welcomeMessage={event.welcome_message}
|
||||
onLogout={logout}
|
||||
// Same gate as the standard layout below (#1149). These layouts
|
||||
// render the button on the callback being present rather than on a
|
||||
// showLogout flag, so withholding it is how the gate reaches them.
|
||||
onLogout={showLogoutControl ? logout : undefined}
|
||||
showOriginalFilename={showOriginalFilename}
|
||||
/>
|
||||
|
||||
@@ -823,7 +852,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
heroLogoVisible={data?.event?.hero_logo_visible !== false}
|
||||
heroLogoSize={data?.event?.hero_logo_size || undefined}
|
||||
headerStyle={data?.event?.header_style || theme.headerStyle}
|
||||
showLogout={true}
|
||||
showLogout={showLogoutControl}
|
||||
onLogout={logout}
|
||||
// Old Download All header button is replaced by the new
|
||||
// showHeaderDownload below — accent-coloured, always visible when
|
||||
|
||||
@@ -54,7 +54,7 @@ interface PhotoCardProps {
|
||||
const PhotoCard: React.FC<PhotoCardProps> = ({
|
||||
photo,
|
||||
width,
|
||||
height: _height,
|
||||
height,
|
||||
onClick,
|
||||
onLike,
|
||||
onSelect,
|
||||
@@ -70,8 +70,13 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
|
||||
allowLikes = false,
|
||||
index
|
||||
}) => {
|
||||
// Note: height is passed but not used as we maintain aspect ratio via width
|
||||
void _height;
|
||||
// The height MasonryPhotoAlbum computed from photos.width/height is used, not
|
||||
// discarded (#1130). Letting the tile size itself from the image meant the
|
||||
// rendered shape came from whatever rendition was served — and with
|
||||
// thumbnail_fit seeded to 'cover' (migration 040) every rendition is square,
|
||||
// so the masonry laid out identical squares and was indistinguishable from
|
||||
// the fixed grid. The photo's real aspect ratio is in the DB and is what the
|
||||
// album already laid out against.
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
threshold: 0.1,
|
||||
@@ -85,7 +90,7 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className={`gallery-premium-photo-card group ${isSelected ? 'selected' : ''}`}
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
style={{ width: '100%', height, display: 'block' }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.4, delay: Math.min(index * 0.05, 0.3) }}
|
||||
@@ -95,8 +100,11 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
style={{ width, height: 'auto' }}
|
||||
className="w-full h-auto object-cover"
|
||||
// No inline height: the card now has a definite one, so the
|
||||
// stylesheet's `.gallery-premium-photo-card img { height: 100% }` can
|
||||
// finally apply and object-fit: cover crops a square rendition INTO the
|
||||
// correctly-shaped tile, rather than the rendition dictating the shape.
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
slug={slug}
|
||||
|
||||
@@ -39,6 +39,8 @@ interface GalleryAuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
event: GalleryEvent | null;
|
||||
accessLevel: GalleryAccessLevel;
|
||||
/** Session was minted by the customer portal — credentialed, not a plain guest. */
|
||||
viaCustomer: boolean;
|
||||
isClient: boolean;
|
||||
login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise<void>;
|
||||
clientLogin: (slug: string, password: string) => Promise<void>;
|
||||
@@ -65,6 +67,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [event, setEvent] = useState<GalleryEvent | null>(null);
|
||||
const [accessLevel, setAccessLevel] = useState<GalleryAccessLevel>('guest');
|
||||
const [viaCustomer, setViaCustomer] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [routeError, setRouteError] = useState<string | null>(null);
|
||||
@@ -204,13 +207,25 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
const initialise = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>(
|
||||
const sessionResponse = await api.get<{
|
||||
valid: boolean; type: string; eventSlug?: string;
|
||||
accessLevel?: GalleryAccessLevel; viaCustomer?: boolean;
|
||||
}>(
|
||||
'/auth/session',
|
||||
{ params: { slug: currentSlug } }
|
||||
);
|
||||
|
||||
if (sessionResponse.data?.valid && sessionResponse.data.type === 'gallery' && sessionResponse.data.eventSlug === currentSlug) {
|
||||
setIsAuthenticated(true);
|
||||
// The SERVER's view of this session, not the per-tab sessionStorage
|
||||
// guess above (#1149). A second tab has no sessionStorage but the
|
||||
// same cookie, so the stored value silently downgraded a client
|
||||
// session to 'guest' while the backend kept serving it as a client.
|
||||
if (sessionResponse.data.accessLevel === 'client') {
|
||||
setAccessLevel('client');
|
||||
sessionStorage.setItem(`gallery_access_level_${currentSlug}`, 'client');
|
||||
}
|
||||
setViaCustomer(Boolean(sessionResponse.data.viaCustomer));
|
||||
|
||||
// Always refresh from the server — the stored event from sessionStorage
|
||||
// is shown above as an instant placeholder for perceived perf, but it
|
||||
@@ -340,6 +355,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
setIsAuthenticated(false);
|
||||
setEvent(null);
|
||||
setAccessLevel('guest');
|
||||
setViaCustomer(false);
|
||||
clearActiveGallerySlug();
|
||||
};
|
||||
|
||||
@@ -350,6 +366,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
event,
|
||||
accessLevel,
|
||||
isClient: accessLevel === 'client',
|
||||
viaCustomer,
|
||||
login,
|
||||
clientLogin: clientLoginFn,
|
||||
logout,
|
||||
|
||||
@@ -352,6 +352,10 @@
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"picpeak": {
|
||||
"restoreIntro": "Laden Sie eine .picpeak-Datei von dieser oder einer anderen Instanz hoch. Die Wiederherstellung eines SQLite-Backups auf einer PostgreSQL-Instanz wird unterstützt (Upgrade-Pfad); ansonsten müssen die Datenbank-Engines übereinstimmen.",
|
||||
"crossEngineNote": "Engine-übergreifende Wiederherstellung: Ein SQLite-Backup wurde auf diese PostgreSQL-Instanz übernommen."
|
||||
},
|
||||
"title": "Backup-Verwaltung",
|
||||
"subtitle": "Verwalten Sie System-Backups, konfigurieren Sie automatisierte Backups und stellen Sie vorherige Backups wieder her.",
|
||||
"tabs": {
|
||||
@@ -1795,27 +1799,7 @@
|
||||
"title": "E-Mail-Einstellungen"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup",
|
||||
"picpeak": {
|
||||
"title": "Portables Backup (.picpeak)",
|
||||
"intro": "Laden Sie eine einzelne, in sich geschlossene Datei herunter und laden Sie sie auf einer anderen Instanz hoch, um diese zu klonen — komplett im Browser.",
|
||||
"includePhotos": "Original-Galeriefotos einschließen (größere Datei)",
|
||||
"secretsWarning": "Diese Datei enthält Geheimnisse im Klartext (E-Mail-Passwort, Admin-Zugangsdaten, API-Schlüssel). Bewahren Sie sie sicher auf und übertragen Sie sie nur über vertrauenswürdige Kanäle.",
|
||||
"download": ".picpeak herunterladen",
|
||||
"downloadFailed": "Die Backup-Datei konnte nicht erstellt werden.",
|
||||
"restoreTitle": "Aus einer .picpeak wiederherstellen",
|
||||
"restoreIntro": "Laden Sie eine .picpeak von dieser oder einer anderen Instanz hoch. Nur dieselbe Datenbank-Engine.",
|
||||
"chooseFile": ".picpeak-Datei auswählen…",
|
||||
"restoreDone": "Backup wiederhergestellt.",
|
||||
"restoreFailed": "Wiederherstellung fehlgeschlagen.",
|
||||
"restoreSummary": "{{tables}} Tabellen und {{files}} Dateien wiederhergestellt.",
|
||||
"externalMediaNote": "Dieses Backup verweist auf eine externe Medienbibliothek. Stellen Sie sicher, dass das externe Medien-Routing auf dieser Instanz konfiguriert ist.",
|
||||
"externalMediaLink": "Einrichtungsanleitung",
|
||||
"reload": "App neu laden",
|
||||
"confirmTitle": "Die Wiederherstellung löscht alle aktuellen Daten",
|
||||
"confirmBody": "Dies ersetzt ALLE Daten auf dieser Instanz dauerhaft durch das hochgeladene Backup, mit Ausnahme Ihres aktuellen Kontos. Dies kann nicht rückgängig gemacht werden.",
|
||||
"confirmRestore": "Löschen & wiederherstellen"
|
||||
}
|
||||
"title": "Backup"
|
||||
},
|
||||
"branding": {
|
||||
"title": "Branding"
|
||||
|
||||
@@ -1340,27 +1340,7 @@
|
||||
"title": "Email Settings"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup",
|
||||
"picpeak": {
|
||||
"title": "Portable backup (.picpeak)",
|
||||
"intro": "Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.",
|
||||
"includePhotos": "Include original gallery photos (larger file)",
|
||||
"secretsWarning": "This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.",
|
||||
"download": "Download .picpeak",
|
||||
"downloadFailed": "Could not create the backup file.",
|
||||
"restoreTitle": "Restore from a .picpeak",
|
||||
"restoreIntro": "Upload a .picpeak taken from this or another instance. Same database engine only.",
|
||||
"chooseFile": "Choose .picpeak file…",
|
||||
"restoreDone": "Backup restored.",
|
||||
"restoreFailed": "Restore failed.",
|
||||
"restoreSummary": "{{tables}} tables and {{files}} files restored.",
|
||||
"externalMediaNote": "This backup references an external-media library. Make sure external-media routing is configured on this instance.",
|
||||
"externalMediaLink": "Setup guide",
|
||||
"reload": "Reload app",
|
||||
"confirmTitle": "Restore will delete all current data",
|
||||
"confirmBody": "This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.",
|
||||
"confirmRestore": "Delete & restore"
|
||||
}
|
||||
"title": "Backup"
|
||||
},
|
||||
"branding": {
|
||||
"title": "Branding"
|
||||
@@ -2798,6 +2778,10 @@
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"picpeak": {
|
||||
"restoreIntro": "Upload a .picpeak taken from this or another instance. Restoring a SQLite backup onto a PostgreSQL instance is supported (the upgrade path); other engine combinations must match.",
|
||||
"crossEngineNote": "Cross-engine restore: a SQLite backup was converted onto this PostgreSQL instance."
|
||||
},
|
||||
"title": "Backup Management",
|
||||
"subtitle": "Manage system backups, configure automated backups, and restore from previous backups.",
|
||||
"tabs": {
|
||||
|
||||
@@ -716,3 +716,56 @@
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* iOS Safari zooms the whole page in when a focused form control computes to
|
||||
* less than 16px, and it does not zoom back out (#1105). Unlocking a gallery
|
||||
* is a client-side transition rather than a document navigation, so the zoom
|
||||
* the password field triggered carries straight into the gallery: the layout
|
||||
* pans horizontally and the header actions sit off-screen until the visitor
|
||||
* pinch-zooms out by hand.
|
||||
*
|
||||
* The lever is the font size, not the viewport meta — adding maximum-scale=1
|
||||
* would suppress the zoom by disabling pinch-to-zoom for everyone, which is an
|
||||
* accessibility regression, so index.html deliberately omits it.
|
||||
*
|
||||
* Keyed to the POINTER, not a width. The zoom depends on the computed font
|
||||
* size and a touch device, never on how wide the viewport is — and a phone in
|
||||
* landscape is 667–956 CSS px, above any width you could call "phone". A
|
||||
* max-width query fixes portrait and leaves every landscape phone (and iPad)
|
||||
* still zooming. `pointer: coarse` is the population that actually has the
|
||||
* behaviour; a mouse-driven desktop reports `fine` and keeps its 14px density.
|
||||
*
|
||||
* Deliberately NOT inside @layer, and deliberately more specific than a single
|
||||
* utility class: `.input` is 14px and ~440 raw controls carry their own
|
||||
* `text-sm`, so a rule that loses to a utility fixes almost nothing. The
|
||||
* `:not()` on each selector is what buys that specificity — without it,
|
||||
* `select`/`textarea` (0,0,1) lose to `.text-sm` (0,1,0) and keep zooming,
|
||||
* while `input` alone happens to win. Excluding checkbox and radio keeps
|
||||
* font-size off controls that size their box from it.
|
||||
*
|
||||
* max(16px, 1em, 1rem) is a FLOOR, not a size. Writing a flat 16px would make
|
||||
* controls that are already larger smaller: Typography -> Large sets
|
||||
* --font-size-base to 18px on body, so anything inheriting it would be clamped
|
||||
* down and the setting quietly ignored. Each term covers a case the others
|
||||
* miss - 1em follows the theme's body size, 1rem follows a browser default the
|
||||
* visitor raised themselves, 16px catches Small themes and .text-sm controls:
|
||||
*
|
||||
* normal (body 16) 16px Large theme (body 18) 18px
|
||||
* Small theme (body 14) 16px browser default 20px 20px
|
||||
*
|
||||
* The specificity that beats a utility class also beats a gallery's custom CSS
|
||||
* (Theme -> Custom CSS), so `.input-themed { font-size: 20px }` lands at 16px
|
||||
* on touch. That is unavoidable here rather than an oversight: nothing in CSS
|
||||
* distinguishes a class that sets 14px from one that sets 20px, so a rule that
|
||||
* loses to the second also loses to the first and fixes nothing. Overriding
|
||||
* DOWNWARD is the point; upward is the cost. `font-size: 20px !important`
|
||||
* still wins for anyone who wants it.
|
||||
*/
|
||||
@media (pointer: coarse) {
|
||||
input:not([type="checkbox"]):not([type="radio"]),
|
||||
select:not([hidden]),
|
||||
textarea:not([hidden]) {
|
||||
font-size: max(16px, 1em, 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,14 +345,51 @@ export const GalleryPage: React.FC = () => {
|
||||
|
||||
// Show gallery view if authenticated
|
||||
if (isAuthenticated && event) {
|
||||
return <GalleryView slug={gallerySlugForView} event={event} />;
|
||||
return <GalleryView slug={gallerySlugForView} event={event} requiresPassword={requiresPassword} />;
|
||||
}
|
||||
|
||||
// Public gallery: auto-login is in flight (or about to fire). Show the
|
||||
// skeleton instead of the "publicly accessible — loading photos" card so
|
||||
// visitors see one continuous skeleton until real photos appear (#321).
|
||||
if (!requiresPassword) {
|
||||
return <GallerySkeleton />;
|
||||
if (!autoLoginAttempted || isLoggingIn) {
|
||||
return <GallerySkeleton />;
|
||||
}
|
||||
|
||||
// Auto-login has run and we are still not authenticated (#1149).
|
||||
//
|
||||
// Returning the skeleton here meant it never stopped: the effect above is
|
||||
// latched on autoLoginAttempted and will not fire again, so the visitor
|
||||
// sat on a loading gallery until they reloaded by hand. It also swallowed
|
||||
// loginError completely — a public gallery that failed to open showed no
|
||||
// reason, because this branch returns before the form that renders it.
|
||||
//
|
||||
// Reachable two ways: a failed or expired auto-login, and clearing the
|
||||
// session from inside the gallery (the Logout button that should not have
|
||||
// been there, or GalleryView's 401 handler). Retry re-arms the latch; it
|
||||
// is a button rather than an automatic re-fire so a genuinely failing
|
||||
// gallery cannot spin.
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4"
|
||||
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="p-6 text-center">
|
||||
<AlertCircle className="w-10 h-10 mx-auto mb-3 text-muted-theme" />
|
||||
<p className="text-base mb-4">
|
||||
{loginError || t('gallery.failedToLoad', 'Failed to load gallery')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setLoginError(null);
|
||||
setAutoLoginAttempted(false);
|
||||
}}
|
||||
>
|
||||
{t('gallery.tryAgain', 'Try again')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show login form
|
||||
|
||||
@@ -524,11 +524,18 @@ export const EventDetailsPage: React.FC = () => {
|
||||
// Update event details
|
||||
updateMutation.mutate(updateData);
|
||||
|
||||
// Update feedback settings separately
|
||||
// Update feedback settings separately. This is its own request, so a
|
||||
// failure here is NOT covered by updateMutation's onError (#1030) — the
|
||||
// old bare catch left the admin looking at "Event updated successfully"
|
||||
// while the Guest Feedback toggle silently never persisted.
|
||||
try {
|
||||
await feedbackService.updateEventFeedbackSettings(id!, feedbackSettings);
|
||||
} catch {
|
||||
// Error already handled by mutation
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event-feedback-settings', id] });
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
error?.response?.data?.error
|
||||
|| t('feedback.settingsUpdateError', 'Failed to update settings')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -23,8 +23,15 @@ const STATE_POLL_MS = 3000;
|
||||
// Prefer the aspect-preserved preview (≤1920px) over the full original; fall
|
||||
// back to the standard url. Always absolutised so it works whether the API is
|
||||
// same-origin or an explicit absolute base.
|
||||
//
|
||||
// Deliberately never `hero_url` (#1015): that tier is cover-cropped to 16:9
|
||||
// for gallery header banners, so with fit='contain' the show letterboxed an
|
||||
// already-cropped frame — portrait photos lost their top and bottom and the
|
||||
// "Black Bars (No crop)" setting looked broken. `slideshow_url` is the same
|
||||
// aspect-preserved preview as `preview_url` but is always emitted, so the
|
||||
// crop can't come back when lightbox previews are off (the default).
|
||||
function photoSrc(photo: Photo): string {
|
||||
return buildResourceUrl(photo.preview_url || photo.hero_url || photo.url);
|
||||
return buildResourceUrl(photo.slideshow_url || photo.preview_url || photo.url);
|
||||
}
|
||||
|
||||
// CSS `filter` applied directly to the image for filters that are pure tone
|
||||
|
||||
@@ -116,6 +116,11 @@ export interface Photo {
|
||||
// ≤1920px JPEG; the lightbox prefers it over `url` for image photos
|
||||
// and falls back to `url` when null (off, video, or not yet generated).
|
||||
preview_url?: string | null;
|
||||
// Aspect-preserved ≤1920px source for the fullscreen slideshow (#1015).
|
||||
// Always set for image photos, unlike `preview_url` — the slideshow must
|
||||
// never fall back to `hero_url`, which is a 16:9 centre crop and makes
|
||||
// the "Black Bars (No crop)" fit letterbox an already-cropped frame.
|
||||
slideshow_url?: string | null;
|
||||
secure_url_template?: string;
|
||||
download_url_template?: string;
|
||||
requires_token?: boolean;
|
||||
|
||||
@@ -18,9 +18,9 @@ async function createEventWithPhotos(page: Page, adminToken?: string, attempt =
|
||||
},
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
const loginData = await loginResponse.json();
|
||||
token = loginData.token;
|
||||
expect(token).toBeTruthy();
|
||||
const cookies = await page.context().cookies();
|
||||
token = cookies.find((c) => c.name === 'admin_token')?.value;
|
||||
expect(token, 'admin_token cookie missing from the login response').toBeTruthy();
|
||||
}
|
||||
|
||||
const eventName = `Playwright Smoke ${Date.now()}`;
|
||||
|
||||
@@ -40,9 +40,14 @@ async function adminLogin(page: Page): Promise<string> {
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const json = await res.json();
|
||||
expect(json.token).toBeTruthy();
|
||||
return json.token;
|
||||
// The admin JWT is delivered as the httpOnly `admin_token` cookie, not in
|
||||
// the response body. Server-side the cookie and an Authorization: Bearer
|
||||
// header are interchangeable, so read it back out of the context jar and
|
||||
// keep threading it as a Bearer — every downstream call stays as it was.
|
||||
const cookies = await page.context().cookies();
|
||||
const token = cookies.find((c) => c.name === 'admin_token')?.value;
|
||||
expect(token, 'admin_token cookie missing from the login response').toBeTruthy();
|
||||
return token as string;
|
||||
}
|
||||
|
||||
async function setCustomerPortalEnabled(page: Page, adminToken: string, enabled: boolean) {
|
||||
|
||||
@@ -8,9 +8,14 @@ async function getAdminToken(page: Page): Promise<string> {
|
||||
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
expect(body.token).toBeTruthy();
|
||||
return body.token;
|
||||
// The admin JWT is delivered as the httpOnly `admin_token` cookie, not in
|
||||
// the response body. Server-side the cookie and an Authorization: Bearer
|
||||
// header are interchangeable, so read it back out of the context jar and
|
||||
// keep threading it as a Bearer — every downstream call stays as it was.
|
||||
const cookies = await page.context().cookies();
|
||||
const token = cookies.find((c) => c.name === 'admin_token')?.value;
|
||||
expect(token, 'admin_token cookie missing from the login response').toBeTruthy();
|
||||
return token as string;
|
||||
}
|
||||
|
||||
async function updateEventSettings(
|
||||
|
||||
Reference in New Issue
Block a user