Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed4e32c4df | |||
| 9003b34c8a | |||
| de459c701f | |||
| 8b6cd3c74f | |||
| fb3d0b08b2 | |||
| 945e63ae86 | |||
| 93d4ae68f4 | |||
| 2bdb1204fe | |||
| cee0a380a6 |
+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.45.16"}
|
||||
|
||||
@@ -5,6 +5,24 @@ All notable changes to PicPeak will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.45.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,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,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,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,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);
|
||||
});
|
||||
});
|
||||
+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,
|
||||
|
||||
@@ -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.
|
||||
};
|
||||
@@ -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
+8
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.45.13",
|
||||
"version": "3.45.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.45.13",
|
||||
"version": "3.45.14",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -7981,9 +7981,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 +9076,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.45.16",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"engines": {
|
||||
|
||||
@@ -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.
|
||||
// allowEngineSwitch: moving between engines is the whole point here. The
|
||||
// upload/restore UI keeps refusing it.
|
||||
const summary = await importFromPicpeak({ picpeakPath: archivePath, allowEngineSwitch: true });
|
||||
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);
|
||||
});
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
@@ -534,7 +539,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
// 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;
|
||||
const showFeedbackToGuests = isClient || parseBooleanInput(feedbackSettings.show_feedback_to_guests, true);
|
||||
|
||||
// Then get comment counts separately
|
||||
const commentCounts = await db('photo_feedback')
|
||||
@@ -599,7 +604,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 +631,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 +680,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 +733,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 +752,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 +862,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 +886,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 +990,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' });
|
||||
}
|
||||
|
||||
@@ -1192,7 +1210,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' });
|
||||
}
|
||||
|
||||
@@ -1872,26 +1890,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) => {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,7 +44,7 @@ async function readManifestFromZip(picpeakPath) {
|
||||
}
|
||||
|
||||
// Returns an array of human-readable blockers ([] = OK to restore).
|
||||
async function validateManifest(manifest) {
|
||||
async function validateManifest(manifest, { allowEngineSwitch = false } = {}) {
|
||||
const errors = [];
|
||||
if (!manifest || manifest.kind !== 'picpeak-backup') {
|
||||
return ['This file is not a PicPeak backup (.picpeak).'];
|
||||
@@ -53,7 +53,13 @@ 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) {
|
||||
// Cross-engine loads are opt-in and CLI-only (#1038). The archive format is
|
||||
// engine-neutral NDJSON, but this path had never been exercised, so the
|
||||
// upload/restore surface keeps refusing it — only
|
||||
// scripts/migrate-sqlite-to-postgres.js, which exists to move an install
|
||||
// between engines, passes allowEngineSwitch.
|
||||
if (!allowEngineSwitch
|
||||
&& 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.`);
|
||||
}
|
||||
// Forward-only: the target schema must be at least as new as the backup's.
|
||||
@@ -184,11 +190,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 +292,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);
|
||||
@@ -275,9 +363,9 @@ async function detectExternalMedia() {
|
||||
* @param {number} [opts.currentAdminId] admin to preserve across the wipe
|
||||
* @returns {Promise<{restored:boolean, tables:number, filesRestored:number, usesExternalMedia:boolean, manifest:object}>}
|
||||
*/
|
||||
async function importFromPicpeak({ picpeakPath, currentAdminId }) {
|
||||
async function importFromPicpeak({ picpeakPath, currentAdminId, allowEngineSwitch = false }) {
|
||||
const manifest = await readManifestFromZip(picpeakPath);
|
||||
const blockers = await validateManifest(manifest);
|
||||
const blockers = await validateManifest(manifest, { allowEngineSwitch });
|
||||
if (blockers.length) {
|
||||
const err = new Error(blockers[0]);
|
||||
err.statusCode = 400;
|
||||
@@ -316,7 +404,14 @@ 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: allowEngineSwitch });
|
||||
|
||||
// Cross-engine only (#1038): rows are inserted with explicit ids, which
|
||||
// leaves Postgres identity sequences at 1 and makes the next natural insert
|
||||
// collide on the primary key. Same-engine restores keep today's behaviour
|
||||
// untouched — this branch exists for scripts/migrate-sqlite-to-postgres.js.
|
||||
if (allowEngineSwitch) await resyncSequences(tables);
|
||||
|
||||
const filesRestored = await restoreFiles(staging);
|
||||
const usesExternalMedia = await detectExternalMedia();
|
||||
|
||||
@@ -333,5 +428,8 @@ module.exports = {
|
||||
importFromPicpeak,
|
||||
readManifestFromZip,
|
||||
validateManifest,
|
||||
// exported for testing — the cross-engine coercion (#1038)
|
||||
epochToIso,
|
||||
coerceForTargetEngine,
|
||||
reinjectCurrentAdmin,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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.45.16",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user