Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f99357460f | |||
| 90b589a88e | |||
| 1ad8ad5b68 | |||
| 962f1d9586 | |||
| a7885846ac | |||
| d868aac703 | |||
| 577b7fa6ae | |||
| a27c705e39 | |||
| b0e9145bba | |||
| 39696d42fe | |||
| 50f5ca1d5b | |||
| 11b6490e4c | |||
| 1cff576439 | |||
| 8978acdb49 |
@@ -1 +1 @@
|
||||
{".":"3.45.3"}
|
||||
{".":"3.45.9"}
|
||||
|
||||
@@ -5,6 +5,50 @@ All notable changes to PicPeak will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.45.9](https://github.com/PicPeak/picpeak/compare/v3.45.8...v3.45.9) (2026-07-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **analytics:** make per-photo view/download counters actually count ([#895](https://github.com/PicPeak/picpeak/issues/895)) (stable) ([#905](https://github.com/PicPeak/picpeak/issues/905)) ([90b589a](https://github.com/PicPeak/picpeak/commit/90b589a88e4c56ccac6dba86c48a604f2abcc008))
|
||||
|
||||
## [3.45.8](https://github.com/PicPeak/picpeak/compare/v3.45.7...v3.45.8) (2026-07-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tests:** raise jest timeouts to the 120s convention (stable) ([#902](https://github.com/PicPeak/picpeak/issues/902)) ([962f1d9](https://github.com/PicPeak/picpeak/commit/962f1d95868251ddeb01a5234c8d9bd1d57429e8))
|
||||
|
||||
## [3.45.7](https://github.com/PicPeak/picpeak/compare/v3.45.6...v3.45.7) (2026-07-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** close 5 Trivy alerts — postcss/tar bumps + drop npm from the runtime image (stable) ([#879](https://github.com/PicPeak/picpeak/issues/879)) ([d868aac](https://github.com/PicPeak/picpeak/commit/d868aac70300149e77fb9568735b6481f88a644f))
|
||||
|
||||
## [3.45.6](https://github.com/PicPeak/picpeak/compare/v3.45.5...v3.45.6) (2026-07-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backup:** make backup settings actually apply ([#871](https://github.com/PicPeak/picpeak/issues/871)) (stable) ([#875](https://github.com/PicPeak/picpeak/issues/875)) ([a27c705](https://github.com/PicPeak/picpeak/commit/a27c705e392ec1e6b8d8be945127de9ff11d5db0))
|
||||
|
||||
## [3.45.5](https://github.com/PicPeak/picpeak/compare/v3.45.4...v3.45.5) (2026-07-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** bump backend deps to close all 14 open Trivy code-scanning alerts (stable) ([#870](https://github.com/PicPeak/picpeak/issues/870)) ([39696d4](https://github.com/PicPeak/picpeak/commit/39696d42fe22478aee1b8d777d8bcfd5ef986fe4))
|
||||
* **security:** read the password-complexity key the settings UI writes (stable) ([#844](https://github.com/PicPeak/picpeak/issues/844)) ([50f5ca1](https://github.com/PicPeak/picpeak/commit/50f5ca1d5bbe6f03397b3dc66ea8c3bea538466e))
|
||||
|
||||
## [3.45.4](https://github.com/PicPeak/picpeak/compare/v3.45.3...v3.45.4) (2026-07-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) ([8978acd](https://github.com/PicPeak/picpeak/commit/8978acdb492f085fbe92186d9dbddfb35b07b676))
|
||||
* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) (stable) ([1cff576](https://github.com/PicPeak/picpeak/commit/1cff576439bce06536f7d308d4ef6d52bdc9bb12))
|
||||
|
||||
## [3.45.3](https://github.com/PicPeak/picpeak/compare/v3.45.2...v3.45.3) (2026-07-17)
|
||||
|
||||
|
||||
|
||||
+10
-8
@@ -37,14 +37,16 @@ ARG CACHEBUST=1
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
|
||||
|
||||
# Upgrade the npm CLI in the final image so its bundled deps are patched
|
||||
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
|
||||
# Trivy alerts. Safe here: only the CLI present in the image changes. Runtime
|
||||
# dependencies come from the builder stage (COPY --from=builder node_modules
|
||||
# below) and the entrypoint runs node, not npm — so npm 11's install behaviour
|
||||
# (the reason 10.x was pinned) never executes in this stage. npm 11 needs
|
||||
# Node >=22.9, satisfied by node:22-alpine.
|
||||
RUN npm install -g npm@11
|
||||
# Remove the npm CLI from the final image. Nothing runs npm here: the
|
||||
# entrypoint is node, runtime deps are COPY'd from the builder stage, and
|
||||
# wait-for-db.sh invokes the migration runners via node directly. npm's
|
||||
# bundled node_modules kept tripping Trivy (sigstore, tar 7.5.19,
|
||||
# brace-expansion 5.0.7 — even npm 12.0.1 still ships the vulnerable
|
||||
# copies), so shipping no npm ends that alert class instead of chasing
|
||||
# per-release patches. Note: `docker exec … npm run <script>` no longer
|
||||
# works in the container — use `node migrations/run-migrations-safe.js`
|
||||
# and friends instead.
|
||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
# Install dumb-init for proper signal handling, postgresql-client for database
|
||||
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
|
||||
|
||||
@@ -40,7 +40,7 @@ jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('GET /api/admin/system-health/backup-coverage', () => {
|
||||
let db;
|
||||
|
||||
@@ -29,7 +29,7 @@ jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('GET /api/admin/system-health/backup-integrity', () => {
|
||||
let cleanup;
|
||||
|
||||
@@ -23,7 +23,7 @@ const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupService — configurable walker (backup_paths)', () => {
|
||||
let db;
|
||||
@@ -177,4 +177,203 @@ describe('backupService — configurable walker (backup_paths)', () => {
|
||||
const filesOn = await backupService.getFilesToBackup(true);
|
||||
expect(filesOn.map((f) => f.relativePath)).toContain('events/archived/E3/legacy.jpg');
|
||||
});
|
||||
|
||||
// Issue #871 — the "What to Backup" checkboxes were stored but never read.
|
||||
describe('UI opt-out toggles (issue #871)', () => {
|
||||
it('unchecking Thumbnails excludes thumbnails/', async () => {
|
||||
seedFile('thumbnails/E1/thumb.jpg');
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_include_thumbnails: false,
|
||||
});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
|
||||
});
|
||||
|
||||
it('unchecking Photos excludes events/active', async () => {
|
||||
seedFile('thumbnails/E1/thumb.jpg');
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_include_photos: false,
|
||||
});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('thumbnails/E1/thumb.jpg');
|
||||
expect(rels).not.toContain('events/active/E1/photo.jpg');
|
||||
});
|
||||
|
||||
it('defaults to including everything when the keys were never saved', async () => {
|
||||
seedFile('thumbnails/E1/thumb.jpg');
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('thumbnails/E1/thumb.jpg');
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
});
|
||||
|
||||
it("accepts the UI's plural backup_include_archives for the archived gate", async () => {
|
||||
seedFile('events/archived/E4/archived.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_include_archives: true,
|
||||
});
|
||||
expect(files.map((f) => f.relativePath)).toContain('events/archived/E4/archived.jpg');
|
||||
});
|
||||
|
||||
it('the UI plural key beats the migration-seeded singular key', async () => {
|
||||
// Migration seeds backup_include_archived=true on every install; the
|
||||
// form only ever writes the plural key, so unchecking Archives must
|
||||
// win over the stale seeded value.
|
||||
seedFile('events/archived/E5/archived.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_include_archived: true, // seeded default
|
||||
backup_include_archives: false, // what the admin actually chose
|
||||
});
|
||||
expect(files.map((f) => f.relativePath)).not.toContain('events/archived/E5/archived.jpg');
|
||||
});
|
||||
|
||||
it('rsync gets the de-selected paths and noise filters as --exclude args', async () => {
|
||||
const excluded = await backupService.resolveExcludedBackupPaths({
|
||||
backup_include_thumbnails: false,
|
||||
backup_include_archives: false,
|
||||
});
|
||||
expect(excluded.map((r) => r.path)).toEqual(
|
||||
expect.arrayContaining(['thumbnails', 'events/archived'])
|
||||
);
|
||||
|
||||
const args = backupService.buildRsyncArgs(
|
||||
{ backup_rsync_host: 'backup.example.com', backup_rsync_path: '/srv/backups' },
|
||||
excluded.map((r) => `/${r.path}/`)
|
||||
);
|
||||
const excludes = args
|
||||
.map((a, i) => (a === '--exclude' ? args[i + 1] : null))
|
||||
.filter(Boolean);
|
||||
expect(excludes).toEqual(expect.arrayContaining([
|
||||
'.nfs*',
|
||||
'/thumbnails/',
|
||||
'/events/archived/',
|
||||
]));
|
||||
});
|
||||
|
||||
it('rows toggled off via include_in_default also become rsync excludes', async () => {
|
||||
// The enabled-only loader hides these rows from the walker, but rsync
|
||||
// syncs the whole storage root, so they must still appear as excludes.
|
||||
await db('backup_paths').where('path', 'previews').update({
|
||||
include_in_default: false,
|
||||
});
|
||||
|
||||
const excluded = await backupService.resolveExcludedBackupPaths({});
|
||||
expect(excluded.map((r) => r.path)).toContain('previews');
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #871 — .nfs* silly-rename artifacts were uploaded to S3.
|
||||
it('never backs up filesystem noise (.nfs*, .DS_Store)', async () => {
|
||||
seedFile('thumbnails/E1/.nfs000000000000006600000008');
|
||||
seedFile('events/active/E1/.DS_Store');
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
expect(rels.some((r) => r.includes('.nfs'))).toBe(false);
|
||||
expect(rels.some((r) => r.includes('.DS_Store'))).toBe(false);
|
||||
});
|
||||
|
||||
it('the walker honors backup_exclude_patterns (previously rsync-only)', async () => {
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
seedFile('events/active/E1/scratch.tmp');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_exclude_patterns: ['*.tmp'],
|
||||
});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
expect(rels).not.toContain('events/active/E1/scratch.tmp');
|
||||
});
|
||||
|
||||
it('glob patterns are literal outside the star (.nfs* must not eat anfs-…)', async () => {
|
||||
seedFile('events/active/E1/anfs-photo.jpg');
|
||||
seedFile('events/active/E1/notes-tmp');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_exclude_patterns: ['*.tmp'],
|
||||
});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
// '.nfs*' used to compile to /^.nfs.*$/ whose dot matched any char;
|
||||
// '*.tmp' used to compile to /^.*.tmp$/ which also matched 'notes-tmp'.
|
||||
expect(rels).toContain('events/active/E1/anfs-photo.jpg');
|
||||
expect(rels).toContain('events/active/E1/notes-tmp');
|
||||
});
|
||||
|
||||
// Issue #871 — weekly schedules silently ran daily, and the dashboard's
|
||||
// "next backup" was a hardcoded "tomorrow 02:00".
|
||||
describe('schedule resolution + next run (issue #871)', () => {
|
||||
it('a named label beats the stray default cron the UI used to send', () => {
|
||||
expect(backupService.resolveScheduleCron({
|
||||
backup_schedule: 'weekly',
|
||||
backup_schedule_cron: '0 3 * * *', // old UI default, sent unconditionally
|
||||
})).toBe('0 3 * * 0');
|
||||
});
|
||||
|
||||
it('custom schedules use the cron field', () => {
|
||||
expect(backupService.resolveScheduleCron({
|
||||
backup_schedule: 'custom',
|
||||
backup_schedule_cron: '15 5 * * 2',
|
||||
})).toBe('15 5 * * 2');
|
||||
});
|
||||
|
||||
it('falls back to the default daily cron', () => {
|
||||
expect(backupService.resolveScheduleCron({})).toBe('0 2 * * *');
|
||||
});
|
||||
|
||||
it('getNextScheduledRun is null when backups are disabled', () => {
|
||||
expect(backupService.getNextScheduledRun(null)).toBeNull();
|
||||
expect(backupService.getNextScheduledRun({ backup_enabled: false })).toBeNull();
|
||||
});
|
||||
|
||||
it('getNextScheduledRun returns the real next weekly fire time', () => {
|
||||
const iso = backupService.getNextScheduledRun({
|
||||
backup_enabled: true,
|
||||
backup_schedule: 'weekly',
|
||||
backup_schedule_cron: '0 3 * * *',
|
||||
});
|
||||
const next = new Date(iso);
|
||||
expect(Number.isNaN(next.getTime())).toBe(false);
|
||||
expect(next.getTime()).toBeGreaterThan(Date.now());
|
||||
expect(next.getDay()).toBe(0); // Sunday
|
||||
expect(next.getHours()).toBe(3); // 03:00
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #871 — "Backup Size: 167.6 TB": file_size_bytes is a bigInteger
|
||||
// column, node-postgres returns int8 as a string, and the S3 path did
|
||||
// `backedUpSize += size` — string concatenation.
|
||||
it('getDatabaseBackupInfo coerces file_size_bytes to a number', async () => {
|
||||
await db('database_backup_runs').del();
|
||||
await db('database_backup_runs').insert({
|
||||
backup_type: 'full',
|
||||
status: 'completed',
|
||||
file_path: '/backups/db/dump.sql.gz',
|
||||
// Simulate the PG int8-as-string driver behaviour (sqlite stores
|
||||
// whatever it is handed, so the string round-trips).
|
||||
file_size_bytes: '421988',
|
||||
started_at: new Date().toISOString(),
|
||||
completed_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const info = await backupService.getDatabaseBackupInfo();
|
||||
expect(typeof info.size).toBe('number');
|
||||
expect(info.size).toBe(421988);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ jest.mock('../../src/services/databaseBackup', () => ({
|
||||
DatabaseBackupService: class {},
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupService — inline DB dump + fail-loud guard', () => {
|
||||
let db;
|
||||
|
||||
@@ -23,7 +23,7 @@ const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupService — per-Stage-B-path statistics', () => {
|
||||
let db;
|
||||
|
||||
@@ -14,7 +14,7 @@ const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupService — config + file collection + manifest (smoke)', () => {
|
||||
let db;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
const crypto = require('crypto');
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('booking cutover — draft invoices on hold', () => {
|
||||
let db; let cleanup; let adminId; let customerId; let quoteService;
|
||||
|
||||
@@ -14,7 +14,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
// Service-level CRM calls cold-require heavy modules (pdfService,
|
||||
// nodemailer, etc.) on first use; the global 5 s per-test budget is
|
||||
// too tight for that. Bump it for this file only.
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('discount line items (negative unit_price_minor)', () => {
|
||||
let db;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// bootCrmDb runs the full core-migration set in beforeAll.
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('event type slug rename cascade', () => {
|
||||
let db;
|
||||
|
||||
@@ -17,7 +17,7 @@ const request = require('supertest');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
let db; let cleanup; let service; let app;
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
let db; let cleanup; let service; let adminId;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// Service-level CRM calls cold-require heavy modules (pdfService, nodemailer)
|
||||
// on first use; bump the budget for this file.
|
||||
jest.setTimeout(60000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('incoming-invoice categorise / re-bill chain', () => {
|
||||
let db;
|
||||
|
||||
@@ -32,7 +32,7 @@ jest.mock('../../src/services/restoreService', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('installFromBackupBoot', () => {
|
||||
let db;
|
||||
|
||||
@@ -13,7 +13,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
|
||||
// parallel load on a small CI runner that can exceed the 5s default. Match the
|
||||
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
@@ -21,7 +21,7 @@ beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir
|
||||
({ createPicpeak } = require('../../src/services/picpeakExportService'));
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
@@ -28,7 +28,7 @@ beforeAll(async () => {
|
||||
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
superAdminRoleId = role.id;
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
@@ -13,14 +13,14 @@ const { execFileSync } = require('child_process');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -183,22 +183,24 @@ describe('restoreService — PG branch scope contract (PR #596 review)', () => {
|
||||
expect(window).toMatch(/was_successful:\s*true/);
|
||||
});
|
||||
|
||||
it('npm run migrate:safe is invoked after the replay in restore()', () => {
|
||||
it('the safe migration runner is invoked after the replay in restore()', () => {
|
||||
// Contract from PR #596 round 4: backups taken on older picpeak
|
||||
// versions must restore COMPLETELY on a newer image — even if new
|
||||
// migrations have been added since the backup was taken. The
|
||||
// restore() flow shells out to `npm run migrate:safe` AFTER the
|
||||
// restore() flow shells out to the safe migration runner AFTER the
|
||||
// operator-meta replay so the schema catches up to the running
|
||||
// code WITHIN the restore boundary (not on the next container
|
||||
// restart).
|
||||
// restart). Invoked as `node migrations/run-migrations-safe.js` —
|
||||
// the runtime image ships no npm, so the former `npm run
|
||||
// migrate:safe` would ENOENT into the non-fatal catch.
|
||||
//
|
||||
// Contract:
|
||||
// 1. A `migrate:safe` shell-out exists somewhere in restoreService
|
||||
// 1. A run-migrations-safe shell-out exists somewhere in restoreService
|
||||
// 2. It sits AFTER the replay drain — verification → replay →
|
||||
// migrations is the documented order
|
||||
// 3. It does NOT sit inside performDatabaseRestore (must run
|
||||
// against the reinit'd pool from the parent restore())
|
||||
const migrateLine = findFirst(/['"]migrate:safe['"]/);
|
||||
const migrateLine = findFirst(/run-migrations-safe\.js/);
|
||||
expect(migrateLine).toBeGreaterThan(0);
|
||||
|
||||
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
|
||||
|
||||
@@ -27,7 +27,7 @@ beforeAll(async () => {
|
||||
setupService = require('../../src/services/setupService');
|
||||
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
|
||||
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
@@ -10,7 +10,7 @@ const { bootCrmDb } = require('./helpers/crmDb');
|
||||
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
|
||||
// parallel load on a small CI runner that can exceed the 5s default. Match the
|
||||
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
@@ -9,7 +9,7 @@ const {
|
||||
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
|
||||
// parallel load on a small CI runner that can exceed the 5s default. Match the
|
||||
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('admin CRM routes — auth + permission gate', () => {
|
||||
// Invalid: signed with a different secret. adminAuth must reject.
|
||||
const jwt = require('jsonwebtoken');
|
||||
invalidToken = jwt.sign({ id: adminId, type: 'admin' }, 'WRONG-SECRET', { issuer: 'picpeak-auth' });
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -180,6 +180,27 @@ describe('admin events CRUD endpoints (smoke)', () => {
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// #822 — hero_logo_visible/position are nullable (null = "inherit the global
|
||||
// branding toggle"), but the validator used .optional() without
|
||||
// { nullable: true }, so an explicit null was rejected with 400.
|
||||
it('accepts hero_logo_visible: null and stores NULL (inherit)', async () => {
|
||||
const id = await insertEvent(db, adminId, { hero_logo_visible: 1 });
|
||||
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
|
||||
hero_logo_visible: null,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.hero_logo_visible).toBeNull();
|
||||
});
|
||||
|
||||
it('still rejects a non-boolean hero_logo_visible', async () => {
|
||||
const id = await insertEvent(db, adminId);
|
||||
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
|
||||
hero_logo_visible: 'maybe',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
|
||||
@@ -39,7 +39,7 @@ const {
|
||||
bootCrmDb, mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
@@ -95,7 +95,7 @@ beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth'));
|
||||
authApp = buildRouteApp('/api/auth', require('../../src/routes/auth'));
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Per-photo engagement counters (#895).
|
||||
*
|
||||
* Pins the contract that the admin EVENT > IMAGES table depends on:
|
||||
* - photos.view_count increments when the full-size photo is served
|
||||
* (it existed in the schema + admin UI but had NO writer at all)
|
||||
* - the slideshow kiosk never increments views (migration 138 design)
|
||||
* - single-photo downloads increment download_count (regression pin)
|
||||
* - zip downloads (download-all, download-selected) increment
|
||||
* download_count for the contained photos — previously they didn't,
|
||||
* so zip-heavy galleries showed 0 per-photo downloads forever
|
||||
* - the admin event-detail total_downloads counts singles AND zips
|
||||
* (it counted action='download' only, disagreeing with the dashboard)
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-engagement-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'engagement-test-secret';
|
||||
// Real files on disk so /photo and the zip routes actually stream bytes.
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-engagement-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
const SLUG = 'engagement-test-event';
|
||||
|
||||
describe('photo engagement counters (#895)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let eventId;
|
||||
let photoIds;
|
||||
let adminToken;
|
||||
|
||||
const galleryToken = (extra = {}) => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery', ...extra },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const getPhoto = async (id) => db('photos').where('id', id).first();
|
||||
// The counter writes are fire-and-forget on purpose — give the event
|
||||
// loop a beat before asserting.
|
||||
const settle = () => new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const inserted = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Engagement Test',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'engagement-test-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
allow_downloads: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
const photoDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
|
||||
fs.mkdirSync(photoDir, { recursive: true });
|
||||
|
||||
photoIds = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const filename = `photo-${i}.jpg`;
|
||||
fs.writeFileSync(path.join(photoDir, filename), Buffer.from(`fake-jpeg-bytes-${i}`));
|
||||
const p = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename,
|
||||
path: `${SLUG}/${filename}`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
photoIds.push(p[0]?.id ?? p[0]);
|
||||
}
|
||||
|
||||
const superRole = await db('roles').where({ name: 'super_admin' }).first();
|
||||
const [rootId] = await db('admin_users').insert({
|
||||
username: 'engagement-admin',
|
||||
email: 'engagement-admin@example.com',
|
||||
password_hash: await bcrypt.hash('EngagementAdmin123', 4),
|
||||
role_id: superRole.id,
|
||||
is_active: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id').then((r) => [r[0]?.id || r[0]]);
|
||||
adminToken = jwt.sign(
|
||||
{ id: rootId, username: 'engagement-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('photos').where('event_id', eventId).update({ view_count: 0, download_count: 0 });
|
||||
await db('access_logs').where('event_id', eventId).del();
|
||||
});
|
||||
|
||||
describe('view_count via the view beacon (#895 — previously never written)', () => {
|
||||
const beacon = (photoId, token = galleryToken()) => request(app)
|
||||
.post(`/api/gallery/${SLUG}/photo/${photoId}/view`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
it('increments exactly the beaconed photo', async () => {
|
||||
expect((await beacon(photoIds[0])).status).toBe(204);
|
||||
expect((await getPhoto(photoIds[0])).view_count).toBe(1);
|
||||
|
||||
expect((await beacon(photoIds[0])).status).toBe(204);
|
||||
expect((await getPhoto(photoIds[0])).view_count).toBe(2);
|
||||
// Other photos untouched
|
||||
expect((await getPhoto(photoIds[1])).view_count).toBe(0);
|
||||
});
|
||||
|
||||
it('serving the image bytes does NOT count (preloads must not inflate)', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/photo/${photoIds[0]}`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
await settle();
|
||||
expect((await getPhoto(photoIds[0])).view_count).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects the slideshow kiosk (migration 138 design)', async () => {
|
||||
const res = await beacon(photoIds[0], galleryToken({ accessLevel: 'slideshow' }));
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect((await getPhoto(photoIds[0])).view_count).toBe(0);
|
||||
});
|
||||
|
||||
it("404s a photo that isn't in the event", async () => {
|
||||
const res = await beacon(999999);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('download_count', () => {
|
||||
it('single-photo download increments (regression pin)', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/download/${photoIds[0]}`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
await settle();
|
||||
expect((await getPhoto(photoIds[0])).download_count).toBe(1);
|
||||
expect((await getPhoto(photoIds[1])).download_count).toBe(0);
|
||||
});
|
||||
|
||||
it('download-selected increments exactly the selected photos (#895)', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/gallery/${SLUG}/download-selected`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`)
|
||||
.send({ photo_ids: [photoIds[0], photoIds[1]] });
|
||||
expect(res.status).toBe(200);
|
||||
await settle();
|
||||
expect((await getPhoto(photoIds[0])).download_count).toBe(1);
|
||||
expect((await getPhoto(photoIds[1])).download_count).toBe(1);
|
||||
expect((await getPhoto(photoIds[2])).download_count).toBe(0);
|
||||
});
|
||||
|
||||
it('download-all increments every downloadable photo (#895)', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${SLUG}/download-all`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
await settle();
|
||||
for (const id of photoIds) {
|
||||
expect((await getPhoto(id)).download_count).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('skipped archive entries do not count (missing source file)', async () => {
|
||||
// Own event so the on-the-fly archiver path is guaranteed — the
|
||||
// main event may have a cached zip from the previous test's
|
||||
// background generation, and racing its build/invalidate hangs.
|
||||
const slug2 = `${SLUG}-skip`;
|
||||
const ev = await db('events').insert({
|
||||
slug: slug2,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Engagement Skip Test',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${slug2}/share`,
|
||||
share_token: 'engagement-skip-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
allow_downloads: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const eventId2 = ev[0]?.id ?? ev[0];
|
||||
const dir2 = path.join(process.env.STORAGE_PATH, 'events/active', slug2);
|
||||
fs.mkdirSync(dir2, { recursive: true });
|
||||
const ids2 = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
// Only photo 0 gets a real file — photo 1's source is missing.
|
||||
if (i === 0) fs.writeFileSync(path.join(dir2, `photo-${i}.jpg`), Buffer.from('skip-test-bytes'));
|
||||
const p = await db('photos').insert({
|
||||
event_id: eventId2,
|
||||
filename: `photo-${i}.jpg`,
|
||||
path: `${slug2}/photo-${i}.jpg`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
ids2.push(p[0]?.id ?? p[0]);
|
||||
}
|
||||
const token2 = jwt.sign(
|
||||
{ eventId: eventId2, eventSlug: slug2, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/gallery/${slug2}/download-all`)
|
||||
.set('Authorization', `Bearer ${token2}`);
|
||||
expect(res.status).toBe(200);
|
||||
await settle();
|
||||
expect((await db('photos').where('id', ids2[0]).first()).download_count).toBe(1);
|
||||
// photo-1's source was missing → skipped from the zip → not counted
|
||||
expect((await db('photos').where('id', ids2[1]).first()).download_count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin event-detail total_downloads (#895 — one definition everywhere)', () => {
|
||||
it('counts singles and every zip variant, one row each', async () => {
|
||||
const row = (action) => ({
|
||||
event_id: eventId,
|
||||
ip_address: '127.0.0.1',
|
||||
user_agent: 'jest',
|
||||
action,
|
||||
});
|
||||
await db('access_logs').insert([
|
||||
row('download'),
|
||||
row('download_all'),
|
||||
row('download_all_presigned'),
|
||||
row('download_selected'),
|
||||
row('view'), // not a download
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/admin/events/${eventId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.total_downloads).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ describe('publicContracts routes', () => {
|
||||
contractId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('publicPaymentCheck routes', () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
app = buildRouteApp('/api/public/payment-check', require('../../src/routes/publicPaymentCheck'));
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('publicQuotes routes', () => {
|
||||
quoteId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
app = buildRouteApp('/api/public/quotes', require('../../src/routes/publicQuotes'));
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('admin Live Slideshow endpoints', () => {
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 30000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
|
||||
@@ -67,11 +67,10 @@ async function insertEvent(db, over = {}) {
|
||||
describe('public Live Slideshow routes', () => {
|
||||
let db; let cleanup; let app;
|
||||
|
||||
// bootCrmDb runs the full migration set against a fresh SQLite file, which
|
||||
// takes <2s locally but has been observed to exceed Jest's default 5s
|
||||
// `beforeAll` timeout on slower GitHub Actions runners (~5.4s — runner-to-
|
||||
// runner I/O variance). Raise the hook timeout so this doesn't intermittently
|
||||
// block PRs on CI; doesn't affect happy-path local runs.
|
||||
// bootCrmDb runs the full migration set against a fresh SQLite file and the
|
||||
// chain keeps growing via backports. Hook-argument timeouts OVERRIDE the
|
||||
// 120s jest.config default (same trap as the jest.setTimeout pins) — keep
|
||||
// this at 120000, matching the config.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
@@ -86,7 +85,7 @@ describe('public Live Slideshow routes', () => {
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 30000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const crypto = require('crypto');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupIntegrityService.verifyDocumentArtefacts', () => {
|
||||
let db;
|
||||
|
||||
@@ -27,7 +27,7 @@ let db; let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 30000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('userManagementService — activate + delete (#574 follow-up)', () => {
|
||||
is_active: 1, created_at: new Date(),
|
||||
}).returning('id');
|
||||
targetId = targetInsert[0]?.id ?? targetInsert[0];
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
|
||||
@@ -115,7 +115,7 @@ beforeAll(async () => {
|
||||
}).returning('id');
|
||||
photoIds.push(r[0]?.id ?? r[0]);
|
||||
}
|
||||
}, 30000);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Regression tests for the password-complexity setting read path.
|
||||
*
|
||||
* Bug 1 (key mismatch): the settings UI saves the admin's choice as
|
||||
* `security_password_complexity` (useSettingsState.ts prefixes every
|
||||
* security field with `security_`), but getPasswordComplexitySettings()
|
||||
* queried `security_password_complexity_level` — a key nothing writes —
|
||||
* so the configured level was silently ignored.
|
||||
*
|
||||
* Bug 2 (driver shape, codex review of #843): on SQLite the TEXT column
|
||||
* returns the JSON-stringified value ('"very_strong"'), but on Postgres
|
||||
* (production default) `setting_value` is a json column and comes back
|
||||
* already decoded ('very_strong'). A bare JSON.parse throws on the
|
||||
* decoded shape and the outer catch fell back to 'moderate' — the
|
||||
* setting stayed unenforced on Postgres even with the right key.
|
||||
*/
|
||||
|
||||
const mockQueriedKeys = [];
|
||||
let mockStoredValue;
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => ({
|
||||
where(_col, key) {
|
||||
mockQueriedKeys.push(key);
|
||||
return this;
|
||||
},
|
||||
first() {
|
||||
return Promise.resolve(
|
||||
mockQueriedKeys[mockQueriedKeys.length - 1] === 'security_password_complexity'
|
||||
? { setting_key: 'security_password_complexity', setting_value: mockStoredValue }
|
||||
: undefined
|
||||
);
|
||||
},
|
||||
}),
|
||||
withRetry: (fn) => fn(),
|
||||
}));
|
||||
|
||||
const { getPasswordComplexitySettings } = require('../../src/utils/passwordValidation');
|
||||
|
||||
describe('getPasswordComplexitySettings', () => {
|
||||
beforeEach(() => { mockQueriedKeys.length = 0; });
|
||||
|
||||
it('reads the key the settings UI actually writes (SQLite shape: JSON-stringified)', async () => {
|
||||
mockStoredValue = JSON.stringify('very_strong'); // '"very_strong"'
|
||||
const level = await getPasswordComplexitySettings();
|
||||
expect(mockQueriedKeys).toContain('security_password_complexity');
|
||||
expect(level).toBe('very_strong');
|
||||
});
|
||||
|
||||
it('accepts the Postgres json-column shape (already decoded, no quotes)', async () => {
|
||||
mockStoredValue = 'very_strong'; // pg driver auto-parses the json column
|
||||
const level = await getPasswordComplexitySettings();
|
||||
expect(level).toBe('very_strong');
|
||||
});
|
||||
|
||||
it('falls back to moderate on an empty value', async () => {
|
||||
mockStoredValue = '';
|
||||
const level = await getPasswordComplexitySettings();
|
||||
expect(level).toBe('moderate');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
// bootCrmDb() runs EVERY core migration in beforeAll and the chain keeps
|
||||
// growing (134 migrations and counting via backports). 120s matches the
|
||||
// beta-branch convention from #860.
|
||||
testTimeout: 120000,
|
||||
coverageDirectory: 'coverage',
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.js',
|
||||
|
||||
Generated
+394
-254
File diff suppressed because it is too large
Load Diff
+11
-7
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.45.3",
|
||||
"version": "3.45.9",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
@@ -18,11 +21,12 @@
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "1.16.0",
|
||||
"axios": "1.18.1",
|
||||
"bcrypt": "6.0.0",
|
||||
"chokidar": "4.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"cron-parser": "^4.9.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"exifr": "^7.1.3",
|
||||
"express": "^4.18.2",
|
||||
@@ -50,16 +54,16 @@
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pg": "^8.16.3",
|
||||
"postcss": "8.5.10",
|
||||
"postcss": "8.5.18",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sharp": "0.34.3",
|
||||
"sharp": "0.35.3",
|
||||
"sqlite3": "^5.1.6",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"swissqrbill": "^4.3.0",
|
||||
"tar": ">=7.5.16",
|
||||
"tar": ">=7.5.21",
|
||||
"uuid": "^11.1.1",
|
||||
"winston": "^3.8.2",
|
||||
"zxcvbn": "^4.4.2"
|
||||
@@ -79,8 +83,8 @@
|
||||
"js-yaml": "^4.2.0",
|
||||
"fast-xml-parser": ">=5.7.0",
|
||||
"qs": ">=6.15.2",
|
||||
"tar": ">=7.5.16",
|
||||
"brace-expansion": ">=5.0.6",
|
||||
"tar": ">=7.5.21",
|
||||
"brace-expansion": ">=5.0.7",
|
||||
"minimatch": ">=9.0.7",
|
||||
"path-to-regexp": "0.1.13",
|
||||
"lodash": ">=4.18.1",
|
||||
|
||||
@@ -23,7 +23,7 @@ async function validateUploadedFile(filePath) {
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await sharp(filePath, {
|
||||
failOnError: false, // Don't fail on recoverable errors
|
||||
failOn: 'none', // Don't fail on recoverable errors
|
||||
limitInputPixels: 268402689 // ~16k x 16k max
|
||||
}).metadata();
|
||||
} catch (metadataError) {
|
||||
@@ -43,7 +43,7 @@ async function validateUploadedFile(filePath) {
|
||||
// Additional check: verify we can actually decode a small portion of the image
|
||||
try {
|
||||
await sharp(filePath, {
|
||||
failOnError: false,
|
||||
failOn: 'none',
|
||||
limitInputPixels: 268402689
|
||||
})
|
||||
.resize(10, 10) // Try to resize to very small size
|
||||
|
||||
@@ -69,7 +69,7 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
|
||||
|
||||
// Get total downloads (last 30 days) - include both single and bulk downloads
|
||||
const totalDownloads = await db('access_logs')
|
||||
.whereIn('action', ['download', 'download_all'])
|
||||
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
|
||||
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
|
||||
.count('id as count')
|
||||
.first();
|
||||
@@ -99,7 +99,7 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
|
||||
.first();
|
||||
|
||||
const previousDownloads = await db('access_logs')
|
||||
.whereIn('action', ['download', 'download_all'])
|
||||
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
|
||||
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
|
||||
.where('timestamp', '<', thirtyDaysAgo.toISOString())
|
||||
.count('id as count')
|
||||
@@ -271,7 +271,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
||||
// Get downloads per day - include both single and bulk downloads
|
||||
const downloadsData = await db('access_logs')
|
||||
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
|
||||
.whereIn('action', ['download', 'download_all'])
|
||||
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.groupByRaw('DATE(timestamp)');
|
||||
|
||||
@@ -307,7 +307,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
||||
.select('events.id', 'events.event_name', 'events.slug')
|
||||
.select(db.raw('COUNT(CASE WHEN action = \'view\' THEN 1 END) as views'))
|
||||
.select(db.raw('COUNT(DISTINCT CASE WHEN action = \'view\' THEN ip_address END) as uniqueVisitors'))
|
||||
.select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\') THEN 1 END) as downloads'))
|
||||
.select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\', \'download_all_presigned\', \'download_selected\') THEN 1 END) as downloads'))
|
||||
.join('events', 'access_logs.event_id', 'events.id')
|
||||
.where('access_logs.timestamp', '>=', startDateStr)
|
||||
.groupBy('events.id', 'events.event_name', 'events.slug')
|
||||
@@ -377,7 +377,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
||||
.first();
|
||||
|
||||
const totalDownloadsCount = await db('access_logs')
|
||||
.whereIn('action', ['download', 'download_all'])
|
||||
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
@@ -94,7 +94,7 @@ module.exports = (router) => {
|
||||
body('allow_presigned_download').optional().isBoolean(),
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
// Hero logo settings
|
||||
body('hero_logo_visible').optional().isBoolean(),
|
||||
body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
|
||||
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
|
||||
// Header style settings (decoupled from layout)
|
||||
@@ -342,8 +342,10 @@ module.exports = (router) => {
|
||||
// hero_logo_visible: store NULL ("inherit") unless the admin explicitly
|
||||
// set it, so the global branding_logo_display_hero toggle keeps
|
||||
// controlling this gallery afterwards (#756). Only an explicit per-event
|
||||
// choice overrides the global.
|
||||
const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined
|
||||
// choice overrides the global. `!= null` treats an explicit null the same
|
||||
// as omitted (both → inherit); otherwise formatBoolean(null) would coerce
|
||||
// to 0/false on SQLite instead of NULL (the PUT handler already does this).
|
||||
const effectiveHeroLogoVisible = req.body.hero_logo_visible != null
|
||||
? formatBoolean(hero_logo_visible)
|
||||
: null;
|
||||
// NULL = inherit the global branding_logo_size (#756), resolved at read
|
||||
@@ -769,9 +771,11 @@ module.exports = (router) => {
|
||||
.where('action', 'view')
|
||||
.count('* as totalViews');
|
||||
|
||||
// One row per download event: singles AND zips (#895). Must stay in
|
||||
// sync with adminDashboard's definition or the two surfaces disagree.
|
||||
const [{ totalDownloads }] = await db('access_logs')
|
||||
.where('event_id', id)
|
||||
.where('action', 'download')
|
||||
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
|
||||
.count('* as totalDownloads');
|
||||
|
||||
const [{ uniqueVisitors }] = await db('access_logs')
|
||||
@@ -1224,7 +1228,7 @@ module.exports = (router) => {
|
||||
}),
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
// Hero logo settings
|
||||
body('hero_logo_visible').optional().isBoolean(),
|
||||
body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
|
||||
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
|
||||
// Header style settings (decoupled from layout)
|
||||
|
||||
@@ -933,6 +933,22 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
|
||||
});
|
||||
|
||||
// Download all photos as ZIP
|
||||
// Zip downloads count toward each contained photo's download_count (#895)
|
||||
// — previously only single-photo downloads did, so galleries whose guests
|
||||
// grab the zip showed 0 per-photo downloads forever. Used by the
|
||||
// pre-generated-zip branches only: it mirrors downloadZipService._build,
|
||||
// which zips EVERY event photo with no per-category allow_downloads
|
||||
// filter — the counter has to reflect what actually shipped. (That the
|
||||
// prebuilt zip ignores per-category download opt-outs is a separate,
|
||||
// pre-existing issue.) Known approximation: _build skips entries whose
|
||||
// WATERMARK step fails and still publishes the zip; counting those
|
||||
// would need a persisted archive manifest, which isn't worth it for
|
||||
// that tail case. Fire-and-forget at the call sites: counters must
|
||||
// never fail a download.
|
||||
async function bumpEventDownloadCounts(eventId) {
|
||||
await db('photos').where('event_id', eventId).increment('download_count', 1);
|
||||
}
|
||||
|
||||
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
|
||||
try {
|
||||
// Check if downloads are allowed for this event
|
||||
@@ -962,6 +978,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download_all_presigned'
|
||||
}).catch(() => {});
|
||||
bumpEventDownloadCounts(req.event.id).catch(() => {});
|
||||
res.redirect(302, url);
|
||||
return;
|
||||
} catch (err) {
|
||||
@@ -985,6 +1002,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download_all'
|
||||
}).catch(() => {});
|
||||
bumpEventDownloadCounts(req.event.id).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1044,6 +1062,10 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
// get a deterministic `_1` suffix before the entries hit the archive.
|
||||
const useOriginalBulk = await getUseOriginalFilenames();
|
||||
const bulkEntryNames = getZipEntryNames(photos, useOriginalBulk);
|
||||
// Only photos whose append succeeded count as downloaded (#895) — the
|
||||
// catch below deliberately skips missing/corrupt sources, and those
|
||||
// never make it into the archive.
|
||||
const appendedIds = [];
|
||||
for (let i = 0; i < photos.length; i += 1) {
|
||||
const photo = photos[i];
|
||||
const storageKey = resolvePhotoStorageKey(req.event, photo);
|
||||
@@ -1057,6 +1079,22 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the source exists BEFORE appending — but only for local
|
||||
// sources: fs.createReadStream is lazy, so its error fires outside
|
||||
// this try/catch and the archive 'error' handler then kills the
|
||||
// whole response instead of skipping one photo (#895 review). S3's
|
||||
// get() awaits GetObject and rejects right here on a missing key,
|
||||
// so a preflight HEAD per entry would just be a redundant serial
|
||||
// round trip (500-photo zip = 500 extra HEADs).
|
||||
if (storageKey && storage.kind() === 'local') {
|
||||
const srcStat = await storage.stat(storageKey);
|
||||
if (!srcStat) {
|
||||
throw new Error(`Photo missing in storage: ${storageKey}`);
|
||||
}
|
||||
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
|
||||
throw new Error('Photo file missing on disk');
|
||||
}
|
||||
|
||||
if (shouldApplyWatermark && effectiveSettings) {
|
||||
// Watermark service operates on a local path. For managed photos in
|
||||
// S3 mode, materialize a tmp local copy first.
|
||||
@@ -1076,9 +1114,9 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
const stream = await storage.get(storageKey);
|
||||
archive.append(stream, { name: archiveName });
|
||||
} else {
|
||||
const filePath = resolvePhotoFilePath(req.event, photo);
|
||||
archive.file(filePath, { name: archiveName });
|
||||
archive.file(resolvePhotoFilePath(req.event, photo), { name: archiveName });
|
||||
}
|
||||
appendedIds.push(photo.id);
|
||||
} catch (err) {
|
||||
logger.warn('Skipping photo in bulk download due to error', {
|
||||
slug: req.params.slug,
|
||||
@@ -1098,6 +1136,12 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download_all'
|
||||
});
|
||||
// Exactly the photos that made it into this archive (#895) — skipped
|
||||
// (missing/corrupt) sources don't count.
|
||||
if (appendedIds.length > 0) {
|
||||
db('photos').whereIn('id', appendedIds)
|
||||
.increment('download_count', 1).catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to create download archive');
|
||||
}
|
||||
@@ -1179,11 +1223,26 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
||||
// #493: same display-name resolution as bulk download, with dedup.
|
||||
const useOriginalSelected = await getUseOriginalFilenames();
|
||||
const selectedEntryNames = getZipEntryNames(photos, useOriginalSelected);
|
||||
// Only photos whose append succeeded count as downloaded (#895).
|
||||
const appendedIds = [];
|
||||
for (let i = 0; i < photos.length; i += 1) {
|
||||
const photo = photos[i];
|
||||
const name = selectedEntryNames[i] || `photo-${photo.id}.jpg`;
|
||||
const storageKey = resolveSelectedKey(req.event, photo);
|
||||
try {
|
||||
// Same pre-append source check as download-all (#895 review),
|
||||
// local backend only: a lazy fs stream's async error would kill
|
||||
// the response instead of skipping the photo; S3's get() rejects
|
||||
// at the await below, so no redundant per-entry HEAD there.
|
||||
if (storageKey && selectedStorage.kind() === 'local') {
|
||||
const srcStat = await selectedStorage.stat(storageKey);
|
||||
if (!srcStat) {
|
||||
throw new Error(`Photo missing in storage: ${storageKey}`);
|
||||
}
|
||||
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
|
||||
throw new Error('Photo file missing on disk');
|
||||
}
|
||||
|
||||
if (shouldApplyWatermark && effectiveSettings) {
|
||||
const buf = storageKey
|
||||
? await withSelectedLocalCopy(storageKey, (lp) =>
|
||||
@@ -1197,6 +1256,7 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
||||
} else {
|
||||
archive.file(resolvePhotoFilePath(req.event, photo), { name });
|
||||
}
|
||||
appendedIds.push(photo.id);
|
||||
} catch (err) {
|
||||
logger.warn('Skipping selected photo due to error', {
|
||||
slug: req.params.slug,
|
||||
@@ -1215,12 +1275,49 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download_selected'
|
||||
});
|
||||
// Exactly the photos that made it into this archive (#895) — skipped
|
||||
// (missing/corrupt) sources don't count.
|
||||
if (appendedIds.length > 0) {
|
||||
db('photos').whereIn('id', appendedIds)
|
||||
.increment('download_count', 1).catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to download selected photos');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Explicit per-photo view beacon (#895). Counting views on the image-
|
||||
// serving routes is wrong in both directions: the lightbox preloads the
|
||||
// prev/next neighbours (three fetches per open), while a preloaded
|
||||
// neighbour that becomes the current slide is never re-fetched (#505
|
||||
// keeps the DOM node alive across the swipe) — so request-level counters
|
||||
// overcount preloads AND undercount swipe-throughs. Instead the lightbox
|
||||
// pings this endpoint exactly when a photo becomes the visible slide.
|
||||
// This also covers enhanced/maximum-protection galleries, whose bytes
|
||||
// are served by /api/secure-images and never pass the routes below.
|
||||
// The slideshow kiosk is excluded (denySlideshowToken; migration 138).
|
||||
router.post('/:slug/photo/:photoId/view',
|
||||
verifyGalleryAccess,
|
||||
denySlideshowToken,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const photo = await db('photos')
|
||||
.where({ id: req.params.photoId, event_id: req.event.id })
|
||||
.first('id', 'visibility');
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
await db('photos').where('id', photo.id).increment('view_count', 1);
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to record view');
|
||||
}
|
||||
});
|
||||
|
||||
// View single photo (with watermark if enabled)
|
||||
router.get('/:slug/photo/:photoId',
|
||||
verifyGalleryAccess,
|
||||
|
||||
@@ -212,17 +212,20 @@ async function buildConfiguredPathReport(configuredRows, config) {
|
||||
const includedInDefault = Boolean(row.include_in_default);
|
||||
let featureFlagValue = null;
|
||||
if (row.feature_flag) {
|
||||
const v = config[row.feature_flag];
|
||||
featureFlagValue = v === undefined ? null : Boolean(v);
|
||||
// Alias-aware: show the value the gate actually used, not a seeded
|
||||
// canonical key shadowed by the UI's spelling. Normalize like the
|
||||
// walker does — Boolean('false') is true.
|
||||
const v = backupService.effectiveFlagValue(row, config);
|
||||
featureFlagValue = v === undefined || v === null ? null : backupService.normalizeBoolean(v);
|
||||
}
|
||||
|
||||
let coverage;
|
||||
if (!includedInDefault) {
|
||||
coverage = 'skipped-by-toggle';
|
||||
} else if (row.feature_flag && featureFlagValue !== true) {
|
||||
// null (unset) and explicit false both gate the path off — matches
|
||||
// the walker's normalizeBoolean semantics
|
||||
coverage = 'skipped-by-feature-flag';
|
||||
} else if (!backupService.backupPathIncluded(row, config)) {
|
||||
// Same gate the walker uses — feature flags (incl. the UI's
|
||||
// backup_include_archives alias) and the What-to-Backup opt-outs.
|
||||
coverage = row.feature_flag ? 'skipped-by-feature-flag' : 'skipped-by-setting';
|
||||
} else if (!stat.exists) {
|
||||
coverage = 'missing-on-disk';
|
||||
} else {
|
||||
@@ -313,6 +316,7 @@ async function getCoverageReport() {
|
||||
willScanCount: paths.filter((p) => p.coverage === 'will-scan').length,
|
||||
skippedByToggleCount: paths.filter((p) => p.coverage === 'skipped-by-toggle').length,
|
||||
skippedByFeatureFlagCount: paths.filter((p) => p.coverage === 'skipped-by-feature-flag').length,
|
||||
skippedBySettingCount: paths.filter((p) => p.coverage === 'skipped-by-setting').length,
|
||||
missingOnDiskCount: paths.filter((p) => p.coverage === 'missing-on-disk').length,
|
||||
driftCount: unconfiguredOnDisk.length,
|
||||
tableMissingFallbackInUse: fallback,
|
||||
|
||||
@@ -7,6 +7,7 @@ const os = require('os');
|
||||
const { promisify } = require('util');
|
||||
|
||||
const cron = require('node-cron');
|
||||
const cronParser = require('cron-parser');
|
||||
const { db } = require('../database/db');
|
||||
const { queueEmail } = require('./emailProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -348,7 +349,10 @@ async function getDatabaseBackupInfoInternal() {
|
||||
return {
|
||||
type: recent.backup_type || 'unknown',
|
||||
backupFile: recent.file_path,
|
||||
size: recent.file_size_bytes,
|
||||
// file_size_bytes is a bigInteger column — node-postgres returns int8
|
||||
// as a STRING, and `backedUpSize += size` then concatenates instead of
|
||||
// adding (issue #871: "167.6 TB" dashboard size). Coerce at the source.
|
||||
size: Number(recent.file_size_bytes) || 0,
|
||||
checksum: recent.checksum,
|
||||
hasChanged,
|
||||
backupTime: recent.completed_at,
|
||||
@@ -389,7 +393,11 @@ async function scanDirectory(dirPath, fileList, basePath, excludePatterns = [])
|
||||
|
||||
const isExcluded = excludePatterns.some(pattern => {
|
||||
if (pattern.includes('*')) {
|
||||
const regex = new RegExp(`^${pattern.replace(/\*/g, '.*')}$`);
|
||||
// Escape regex metacharacters before expanding the glob star — the
|
||||
// raw replace turned '.nfs*' into /^.nfs.*$/ whose leading dot
|
||||
// matched any character (e.g. 'anfs-photo.jpg' was excluded too).
|
||||
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
||||
const regex = new RegExp(`^${escaped}$`);
|
||||
return regex.test(entry.name);
|
||||
}
|
||||
return entry.name === pattern;
|
||||
@@ -437,6 +445,26 @@ const LEGACY_BACKUP_PATHS = [
|
||||
{ path: 'business-docs', feature_flag: null },
|
||||
];
|
||||
|
||||
// "What to Backup" opt-OUT toggles written by BackupConfiguration.tsx.
|
||||
// Default-ON semantics: only an explicit false excludes the path, so
|
||||
// installs that never saved the backup form keep backing up everything
|
||||
// (issue #871: unchecking Thumbnails had no effect because these keys
|
||||
// were stored but never read).
|
||||
const OPT_OUT_FLAGS = {
|
||||
'events/active': 'backup_include_photos',
|
||||
'thumbnails': 'backup_include_thumbnails',
|
||||
};
|
||||
|
||||
// The UI "Archives" checkbox writes backup_include_archives (plural) while
|
||||
// the feature_flag rows use backup_include_archived — accept both.
|
||||
const FLAG_ALIASES = {
|
||||
backup_include_archived: 'backup_include_archives',
|
||||
};
|
||||
|
||||
// Filesystem noise that must never land in a backup: NFS silly-rename
|
||||
// artifacts (issue #871 showed .nfs* files uploaded to S3) and OS metadata.
|
||||
const DEFAULT_EXCLUDE_PATTERNS = ['.nfs*', '.DS_Store', 'Thumbs.db'];
|
||||
|
||||
/**
|
||||
* Resolve the walker's target subdirectories from `backup_paths`.
|
||||
*
|
||||
@@ -456,34 +484,84 @@ const LEGACY_BACKUP_PATHS = [
|
||||
* Used to evaluate feature_flag gates.
|
||||
* @returns {Promise<Array<{ path: string, feature_flag: string|null }>>}
|
||||
*/
|
||||
async function resolveBackupPaths(config) {
|
||||
let rows;
|
||||
async function loadBackupPathRows({ includeDisabled = false } = {}) {
|
||||
try {
|
||||
if (!(await db.schema.hasTable('backup_paths'))) {
|
||||
logger.warn('backup_paths table missing — falling back to LEGACY_BACKUP_PATHS');
|
||||
rows = LEGACY_BACKUP_PATHS;
|
||||
} else {
|
||||
rows = await db('backup_paths')
|
||||
.where('include_in_default', formatBoolean(true))
|
||||
.orderBy('display_order', 'asc')
|
||||
.select('path', 'feature_flag');
|
||||
if (!rows.length) {
|
||||
logger.warn('backup_paths has no rows with include_in_default=true — falling back to LEGACY_BACKUP_PATHS');
|
||||
rows = LEGACY_BACKUP_PATHS;
|
||||
}
|
||||
return LEGACY_BACKUP_PATHS;
|
||||
}
|
||||
let query = db('backup_paths')
|
||||
.orderBy('display_order', 'asc')
|
||||
.select('path', 'feature_flag', 'include_in_default');
|
||||
if (!includeDisabled) {
|
||||
query = query.where('include_in_default', formatBoolean(true));
|
||||
}
|
||||
const rows = await query;
|
||||
if (!rows.filter((r) => normalizeBoolean(r.include_in_default)).length) {
|
||||
logger.warn('backup_paths has no rows with include_in_default=true — falling back to LEGACY_BACKUP_PATHS');
|
||||
return LEGACY_BACKUP_PATHS;
|
||||
}
|
||||
return rows;
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to query backup_paths (${err.message}) — falling back to LEGACY_BACKUP_PATHS`);
|
||||
rows = LEGACY_BACKUP_PATHS;
|
||||
return LEGACY_BACKUP_PATHS;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply feature_flag gating. A row with feature_flag='backup_include_archived'
|
||||
// requires config.backup_include_archived to be truthy (same semantics as
|
||||
// the historical `includeArchived` parameter).
|
||||
// Per-row gate. Applies the UI opt-out toggles first, then feature_flag
|
||||
// gating: a row with feature_flag='backup_include_archived' requires the
|
||||
// corresponding config key to be truthy (same semantics as the historical
|
||||
// `includeArchived` parameter).
|
||||
function backupPathIncluded(row, config) {
|
||||
const optOutKey = OPT_OUT_FLAGS[row.path];
|
||||
if (optOutKey && config) {
|
||||
const optOutValue = config[optOutKey];
|
||||
if (optOutValue !== undefined && optOutValue !== null && normalizeBoolean(optOutValue) === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!row.feature_flag) return true;
|
||||
let flagValue;
|
||||
if (config) {
|
||||
// The alias (backup_include_archives) is what the current UI writes;
|
||||
// the canonical singular key is seeded true by migration on every
|
||||
// install, so the UI value must take precedence or the checkbox can
|
||||
// never turn the flag off.
|
||||
const alias = FLAG_ALIASES[row.feature_flag];
|
||||
if (alias && config[alias] !== undefined && config[alias] !== null) {
|
||||
flagValue = config[alias];
|
||||
} else {
|
||||
flagValue = config[row.feature_flag];
|
||||
}
|
||||
}
|
||||
return normalizeBoolean(flagValue);
|
||||
}
|
||||
|
||||
// The raw config value the gate actually consulted for a row's feature
|
||||
// flag (alias-aware) — the coverage report shows it next to the status,
|
||||
// so it must not display the shadowed seeded key.
|
||||
function effectiveFlagValue(row, config) {
|
||||
if (!row.feature_flag || !config) return undefined;
|
||||
const alias = FLAG_ALIASES[row.feature_flag];
|
||||
if (alias && config[alias] !== undefined && config[alias] !== null) {
|
||||
return config[alias];
|
||||
}
|
||||
return config[row.feature_flag];
|
||||
}
|
||||
|
||||
async function resolveBackupPaths(config) {
|
||||
return (await loadBackupPathRows()).filter((row) => backupPathIncluded(row, config));
|
||||
}
|
||||
|
||||
// The rows the admin de-selected — the rsync destination needs them as
|
||||
// --exclude filters because it syncs the whole storage root rather than
|
||||
// the walker's file list. Includes rows with include_in_default=false,
|
||||
// which the enabled-only loader would otherwise hide from rsync entirely.
|
||||
async function resolveExcludedBackupPaths(config) {
|
||||
const rows = await loadBackupPathRows({ includeDisabled: true });
|
||||
return rows.filter((row) => {
|
||||
if (!row.feature_flag) return true;
|
||||
const flagValue = config ? config[row.feature_flag] : undefined;
|
||||
return normalizeBoolean(flagValue);
|
||||
const disabled = row.include_in_default !== undefined && !normalizeBoolean(row.include_in_default);
|
||||
return disabled || !backupPathIncluded(row, config);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -574,6 +652,14 @@ async function getFilesToBackupInternal(configOrIncludeArchived = true) {
|
||||
|
||||
const targets = await resolveBackupPaths(config);
|
||||
|
||||
// backup_exclude_patterns was only honored by the rsync destination
|
||||
// (as --exclude args); the local/S3 walker ignored it. Merge it with
|
||||
// the always-on noise filters here so every destination agrees.
|
||||
const configuredExcludes = Array.isArray(config.backup_exclude_patterns)
|
||||
? config.backup_exclude_patterns
|
||||
: [];
|
||||
const excludePatterns = [...new Set([...DEFAULT_EXCLUDE_PATTERNS, ...configuredExcludes])];
|
||||
|
||||
for (const target of targets) {
|
||||
// CRM document estate is special-cased in the comment block below
|
||||
// because it's the most expensive omission to recover from:
|
||||
@@ -589,7 +675,7 @@ async function getFilesToBackupInternal(configOrIncludeArchived = true) {
|
||||
// those values refer to do not, leaving every CRM *_path column a
|
||||
// broken FK. scanDirectory short-circuits on ENOENT so installs
|
||||
// that never used CRM features won't error.
|
||||
await scanDirectory(path.join(storagePath, target.path), files, storagePath);
|
||||
await scanDirectory(path.join(storagePath, target.path), files, storagePath, excludePatterns);
|
||||
}
|
||||
|
||||
return files;
|
||||
@@ -686,7 +772,7 @@ function validateRsyncParam(value, label) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildRsyncArgs(config) {
|
||||
function buildRsyncArgs(config, extraExcludes = []) {
|
||||
const storagePath = getStoragePath();
|
||||
const host = validateRsyncParam(config.backup_rsync_host, 'host');
|
||||
const remotePath = validateRsyncParam(config.backup_rsync_path, 'remote path');
|
||||
@@ -713,7 +799,14 @@ function buildRsyncArgs(config) {
|
||||
args.push('-e', `ssh -i ${sshKey} -o StrictHostKeyChecking=no`);
|
||||
}
|
||||
|
||||
const excludePatterns = config.backup_exclude_patterns || [];
|
||||
// Same noise filters as the walker, plus the de-selected backup paths
|
||||
// (extraExcludes) — rsync syncs the whole storage root, so this is the
|
||||
// only place the What-to-Backup selection can take effect for rsync.
|
||||
const excludePatterns = [...new Set([
|
||||
...DEFAULT_EXCLUDE_PATTERNS,
|
||||
...(Array.isArray(config.backup_exclude_patterns) ? config.backup_exclude_patterns : []),
|
||||
...extraExcludes,
|
||||
])];
|
||||
excludePatterns.forEach(pattern => args.push('--exclude', pattern));
|
||||
|
||||
const source = `${storagePath}/`;
|
||||
@@ -752,7 +845,11 @@ function parseRsyncStats(output) {
|
||||
|
||||
async function performRsyncBackup(config, files) {
|
||||
const { spawnAsync } = require('../utils/safeExec');
|
||||
const rsyncArgs = buildRsyncArgs(config);
|
||||
// Anchored excludes for the de-selected What-to-Backup paths; rsync
|
||||
// otherwise transfers the whole storage root regardless of the walker's
|
||||
// file list (which only feeds manifests and file state).
|
||||
const excludedPaths = await resolveExcludedBackupPaths(config);
|
||||
const rsyncArgs = buildRsyncArgs(config, excludedPaths.map((row) => `/${row.path}/`));
|
||||
const { stdout } = await spawnAsync('rsync', rsyncArgs);
|
||||
const stats = parseRsyncStats(stdout);
|
||||
|
||||
@@ -1175,6 +1272,50 @@ async function runBackupInternal(isManual = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// Two settings cooperate here:
|
||||
// - backup_schedule — UI label like "daily" / "weekly" / "custom"
|
||||
// - backup_schedule_cron — actual cron expression (custom schedules)
|
||||
// Older startup code read backup_schedule and crashed when it found a label
|
||||
// instead of a cron expression. Resolution order: explicit cron field, then
|
||||
// map known labels, then fall back to default.
|
||||
const NAMED_SCHEDULES = {
|
||||
hourly: '0 * * * *',
|
||||
daily: '0 2 * * *',
|
||||
weekly: '0 3 * * 0', // Sunday 03:00
|
||||
monthly: '0 4 1 * *',
|
||||
};
|
||||
|
||||
function resolveScheduleCron(config) {
|
||||
const isCronExpression = (s) => typeof s === 'string' && /^\s*\S+(\s+\S+){4}\s*$/.test(s);
|
||||
const readSetting = (key) => {
|
||||
if (config && Object.prototype.hasOwnProperty.call(config, key)) {
|
||||
return String(config[key] ?? '').trim();
|
||||
}
|
||||
if (config?.__raw && Object.prototype.hasOwnProperty.call(config.__raw, key)) {
|
||||
return String(parseSettingValue(config.__raw[key]) ?? '').trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
let schedule = '0 2 * * *';
|
||||
const cronCandidate = readSetting('backup_schedule_cron');
|
||||
const labelCandidate = readSetting('backup_schedule');
|
||||
// A named label wins over the cron field: the UI always used to send its
|
||||
// default cron ('0 3 * * *') alongside e.g. backup_schedule='weekly', which
|
||||
// silently turned weekly schedules into daily ones (issue #871). The cron
|
||||
// field only applies for 'custom' (or when no known label is set).
|
||||
if (labelCandidate && labelCandidate.toLowerCase() !== 'custom' && NAMED_SCHEDULES[labelCandidate.toLowerCase()]) {
|
||||
schedule = NAMED_SCHEDULES[labelCandidate.toLowerCase()];
|
||||
} else if (cronCandidate && isCronExpression(cronCandidate)) {
|
||||
schedule = cronCandidate;
|
||||
} else if (labelCandidate && isCronExpression(labelCandidate)) {
|
||||
// Back-compat: a deployment that wrote a cron expression directly into
|
||||
// backup_schedule (no _cron field) still works.
|
||||
schedule = labelCandidate;
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
|
||||
async function startBackupService() {
|
||||
try {
|
||||
const config = await resolveConfigWithFallback();
|
||||
@@ -1192,42 +1333,7 @@ async function startBackupService() {
|
||||
backupJob = null;
|
||||
}
|
||||
|
||||
// Two settings cooperate here:
|
||||
// - backup_schedule — UI label like "daily" / "weekly" / "custom"
|
||||
// - backup_schedule_cron — actual cron expression
|
||||
// The frontend writes both (BackupConfiguration.jsx). Older startup code
|
||||
// here read backup_schedule and crashed when it found a label instead of
|
||||
// a cron expression. Resolution order: explicit cron field, then map known
|
||||
// labels, then fall back to default.
|
||||
const NAMED_SCHEDULES = {
|
||||
hourly: '0 * * * *',
|
||||
daily: '0 2 * * *',
|
||||
weekly: '0 3 * * 0', // Sunday 03:00
|
||||
monthly: '0 4 1 * *',
|
||||
};
|
||||
const isCronExpression = (s) => typeof s === 'string' && /^\s*\S+(\s+\S+){4}\s*$/.test(s);
|
||||
const readSetting = (key) => {
|
||||
if (config && Object.prototype.hasOwnProperty.call(config, key)) {
|
||||
return String(config[key] ?? '').trim();
|
||||
}
|
||||
if (config?.__raw && Object.prototype.hasOwnProperty.call(config.__raw, key)) {
|
||||
return String(parseSettingValue(config.__raw[key]) ?? '').trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
let schedule = '0 2 * * *';
|
||||
const cronCandidate = readSetting('backup_schedule_cron');
|
||||
const labelCandidate = readSetting('backup_schedule');
|
||||
if (cronCandidate && isCronExpression(cronCandidate)) {
|
||||
schedule = cronCandidate;
|
||||
} else if (labelCandidate && NAMED_SCHEDULES[labelCandidate.toLowerCase()]) {
|
||||
schedule = NAMED_SCHEDULES[labelCandidate.toLowerCase()];
|
||||
} else if (labelCandidate && isCronExpression(labelCandidate)) {
|
||||
// Back-compat: a deployment that wrote a cron expression directly into
|
||||
// backup_schedule (no _cron field) still works.
|
||||
schedule = labelCandidate;
|
||||
}
|
||||
const schedule = resolveScheduleCron(config);
|
||||
|
||||
backupJob = cron.schedule(schedule, async () => {
|
||||
logger.info('Starting scheduled backup');
|
||||
@@ -1319,6 +1425,8 @@ async function getBackupStatus(limit = 10) {
|
||||
// ago looked identical to a successful one. Same "silent failure
|
||||
// not surfaced" class Stage A was designed to fight.
|
||||
const lastSuccessful = runs.find(r => r.status === 'completed') || null;
|
||||
const config = await getBackupConfigInternal();
|
||||
const nextRun = getNextScheduledRun(config);
|
||||
// Detect zombie running rows (started >30min ago, never updated)
|
||||
// — these are processes that died without writing a completed_at.
|
||||
// Surface them so the admin can tell at a glance vs a live run.
|
||||
@@ -1339,7 +1447,8 @@ async function getBackupStatus(limit = 10) {
|
||||
recentRuns: runs,
|
||||
recentBackups: runs, // Alias for frontend compatibility
|
||||
totalBackups: runs.filter(r => r.status === 'completed').length,
|
||||
nextScheduledRun: getNextScheduledRun()
|
||||
nextScheduledRun: nextRun,
|
||||
nextBackup: nextRun // BackupManagement.tsx reads this name
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup status:', error);
|
||||
@@ -1351,12 +1460,19 @@ async function getBackupStatus(limit = 10) {
|
||||
}
|
||||
}
|
||||
|
||||
function getNextScheduledRun() {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setDate(now.getDate() + 1);
|
||||
next.setHours(2, 0, 0, 0);
|
||||
return next.toISOString();
|
||||
function getNextScheduledRun(config) {
|
||||
// null → the UI shows "Not scheduled". Only a real, enabled schedule
|
||||
// produces a date (issue #871: this used to be a hardcoded "tomorrow
|
||||
// 02:00" that ignored the configured schedule entirely).
|
||||
if (!config || !normalizeBoolean(config.backup_enabled)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return cronParser.parseExpression(resolveScheduleCron(config)).next().toISOString();
|
||||
} catch (error) {
|
||||
logger.warn(`Could not compute next backup run: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupOldBackupRuns(retentionDays = 30) {
|
||||
@@ -1569,5 +1685,13 @@ service.getBackupStatus = getBackupStatus;
|
||||
service.cleanupOldBackupRuns = cleanupOldBackupRuns;
|
||||
service.getBackupManifest = getBackupManifest;
|
||||
service.validateBackupManifest = validateBackupManifest;
|
||||
service.resolveBackupPaths = resolveBackupPaths;
|
||||
service.resolveExcludedBackupPaths = resolveExcludedBackupPaths;
|
||||
service.backupPathIncluded = backupPathIncluded;
|
||||
service.effectiveFlagValue = effectiveFlagValue;
|
||||
service.normalizeBoolean = normalizeBoolean;
|
||||
service.buildRsyncArgs = buildRsyncArgs;
|
||||
service.resolveScheduleCron = resolveScheduleCron;
|
||||
service.getNextScheduledRun = getNextScheduledRun;
|
||||
|
||||
module.exports = service;
|
||||
|
||||
@@ -149,7 +149,7 @@ async function generateThumbnail(imagePath, options = {}) {
|
||||
let sharpInstance = sharp(imagePath, {
|
||||
limitInputPixels: 268402689, // ~16k x 16k max
|
||||
sequentialRead: true,
|
||||
failOnError: false
|
||||
failOn: 'none'
|
||||
});
|
||||
|
||||
// Strip EXIF/metadata from thumbnails (privacy: prevent GPS leak etc.)
|
||||
@@ -389,7 +389,7 @@ async function generateHeroImage(imagePath, options = {}) {
|
||||
let sharpInstance = sharp(imagePath, {
|
||||
limitInputPixels: 268402689,
|
||||
sequentialRead: true,
|
||||
failOnError: false
|
||||
failOn: 'none'
|
||||
});
|
||||
|
||||
// Strip EXIF/metadata from hero images (privacy: prevent GPS leak etc.)
|
||||
@@ -519,7 +519,7 @@ async function generatePreviewImage(imagePath, options = {}) {
|
||||
let sharpInstance = sharp(imagePath, {
|
||||
limitInputPixels: 268402689, // ~16k x 16k max
|
||||
sequentialRead: true,
|
||||
failOnError: false,
|
||||
failOn: 'none',
|
||||
});
|
||||
|
||||
// Strip EXIF — same privacy reasoning as thumbnails/heroes.
|
||||
|
||||
@@ -298,7 +298,10 @@ class RestoreService {
|
||||
this.log('info', 'Applying post-restore migrations to restored database...');
|
||||
this.updateProgress('Applying any post-backup migrations...');
|
||||
const backendRoot = path.join(__dirname, '..', '..');
|
||||
const { stderr } = await spawnAsync('npm', ['run', 'migrate:safe'], {
|
||||
// Invoked via node directly — the runtime image ships no npm
|
||||
// (see Dockerfile), and an ENOENT here would be swallowed by the
|
||||
// non-fatal catch below, silently skipping post-restore migrations.
|
||||
const { stderr } = await spawnAsync('node', ['migrations/run-migrations-safe.js'], {
|
||||
cwd: backendRoot,
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
@@ -123,20 +123,32 @@ async function getPasswordComplexitySettings() {
|
||||
|
||||
// Use retry wrapper to handle connection failures
|
||||
const settings = await withRetry(async () => {
|
||||
// Key must match what the settings UI writes: `security_` prefix +
|
||||
// `password_complexity` (useSettingsState.ts saveSecurityMutation).
|
||||
// The old `security_password_complexity_level` key is written by
|
||||
// nothing, so the admin's choice was silently ignored.
|
||||
return await db('app_settings')
|
||||
.where('setting_key', 'security_password_complexity_level')
|
||||
.where('setting_key', 'security_password_complexity')
|
||||
.first();
|
||||
});
|
||||
|
||||
if (!settings || !settings.setting_value) {
|
||||
return 'moderate'; // Default
|
||||
}
|
||||
|
||||
const value = typeof settings.setting_value === 'string'
|
||||
? JSON.parse(settings.setting_value)
|
||||
: settings.setting_value;
|
||||
|
||||
return value;
|
||||
|
||||
// Parse with fallback, mirroring getAppSetting: on SQLite the TEXT
|
||||
// column returns the JSON-stringified value ('"very_strong"'), but on
|
||||
// Postgres the json column comes back already decoded ('very_strong')
|
||||
// — a bare JSON.parse would throw there and the outer catch would
|
||||
// silently fall back to 'moderate' again.
|
||||
let value = settings.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (_) { /* already-decoded plain string — keep as-is */ }
|
||||
}
|
||||
|
||||
return value || 'moderate';
|
||||
} catch (error) {
|
||||
logger.error('Failed to get password complexity settings:', error);
|
||||
return 'moderate'; // Default on error - ensures app continues working
|
||||
|
||||
@@ -120,12 +120,14 @@ echo "Ensuring storage directories exist..."
|
||||
STORAGE_BASE="${STORAGE_PATH:-/app/storage}"
|
||||
mkdir -p "$STORAGE_BASE/events/active" "$STORAGE_BASE/events/archived" "$STORAGE_BASE/thumbnails" 2>/dev/null || true
|
||||
|
||||
# Run migrations (use safe runner in production)
|
||||
# Run migrations (use safe runner in production). Invoked via node directly —
|
||||
# the runtime image no longer ships npm (see Dockerfile: its bundled deps kept
|
||||
# tripping CVE scanners while npm itself never runs in production).
|
||||
echo "Running database migrations..."
|
||||
if [ "$NODE_ENV" = "production" ]; then
|
||||
npm run migrate:safe
|
||||
node migrations/run-migrations-safe.js
|
||||
else
|
||||
npm run migrate
|
||||
node migrations/run-migrations.js
|
||||
fi
|
||||
|
||||
# Execute the main command
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.45.3",
|
||||
"version": "3.45.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Server,
|
||||
Cloud,
|
||||
HardDrive,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Wifi,
|
||||
@@ -102,7 +101,9 @@ export const BackupConfiguration: React.FC<BackupConfigurationProps> = ({ config
|
||||
backup_include_database: true,
|
||||
backup_include_photos: true,
|
||||
backup_include_archives: true,
|
||||
backup_include_thumbnails: false,
|
||||
// Matches the backend never-saved fallback (include everything) so the
|
||||
// form does not show "off" while thumbnails are in fact being backed up.
|
||||
backup_include_thumbnails: true,
|
||||
backup_include_temp: false,
|
||||
backup_compression: true,
|
||||
backup_encryption: false,
|
||||
@@ -111,8 +112,7 @@ export const BackupConfiguration: React.FC<BackupConfigurationProps> = ({ config
|
||||
|
||||
const [showSecrets, setShowSecrets] = useState({
|
||||
s3_secret_key: false,
|
||||
ssh_key: false,
|
||||
encryption_passphrase: false
|
||||
ssh_key: false
|
||||
});
|
||||
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
@@ -152,6 +152,15 @@ export const BackupConfiguration: React.FC<BackupConfigurationProps> = ({ config
|
||||
return;
|
||||
}
|
||||
|
||||
// A custom schedule needs a real 5-field cron — the backend silently
|
||||
// falls back to daily 02:00 otherwise. For named schedules the stored
|
||||
// cron is kept (the backend prefers the label), so switching back to
|
||||
// Custom keeps the previously saved expression.
|
||||
if (formData.backup_schedule === 'custom' && !/^\s*\S+(\s+\S+){4}\s*$/.test(formData.backup_schedule_cron)) {
|
||||
toast.error(t('backup.configuration.messages.invalidCron', 'Please enter a valid cron expression (5 fields)'));
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
@@ -546,69 +555,6 @@ export const BackupConfiguration: React.FC<BackupConfigurationProps> = ({ config
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.advancedOptions.title')}</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.backup_compression}
|
||||
onChange={(e) => handleChange('backup_compression', e.target.checked)}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.advancedOptions.compression')}</span>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.advancedOptions.compressionHelp')}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.backup_encryption}
|
||||
onChange={(e) => handleChange('backup_encryption', e.target.checked)}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.advancedOptions.encryption')}</span>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.advancedOptions.encryptionHelp')}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{formData.backup_encryption && (
|
||||
<div className="ml-7">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('backup.configuration.advancedOptions.encryptionPassphrase')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showSecrets.encryption_passphrase ? 'text' : 'password'}
|
||||
value={formData.backup_encryption_passphrase}
|
||||
onChange={(e) => handleChange('backup_encryption_passphrase', e.target.value)}
|
||||
placeholder={t('backup.configuration.advancedOptions.encryptionPassphraseHelp')}
|
||||
required={formData.backup_encryption}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecrets(prev => ({ ...prev, encryption_passphrase: !prev.encryption_passphrase }))}
|
||||
className="absolute top-1/2 -translate-y-1/2 right-2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
>
|
||||
{showSecrets.encryption_passphrase ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-red-600">
|
||||
<AlertCircle className="inline h-3 w-3 mr-1" />
|
||||
{t('backup.configuration.advancedOptions.encryptionPassphraseHelp')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
|
||||
@@ -258,6 +258,12 @@ const SummaryCard: React.FC<{
|
||||
value={String(summary.skippedByFeatureFlagCount)}
|
||||
/>
|
||||
)}
|
||||
{summary.skippedBySettingCount > 0 && (
|
||||
<Row
|
||||
label={t('backup.coverage.summary.skippedBySetting', 'Skipped (backup settings)')}
|
||||
value={String(summary.skippedBySettingCount)}
|
||||
/>
|
||||
)}
|
||||
{summary.missingOnDiskCount > 0 && (
|
||||
<Row
|
||||
label={t('backup.coverage.summary.missingOnDisk', 'Missing on disk')}
|
||||
@@ -380,6 +386,10 @@ const CoverageBadge: React.FC<{ coverage: BackupPathCoverage }> = ({ coverage })
|
||||
tone: 'neutral',
|
||||
label: t('backup.coverage.coverage.skippedByFlag', 'Gated off'),
|
||||
},
|
||||
'skipped-by-setting': {
|
||||
tone: 'neutral',
|
||||
label: t('backup.coverage.coverage.skippedBySetting', 'Off (backup settings)'),
|
||||
},
|
||||
'missing-on-disk': {
|
||||
tone: 'amber',
|
||||
label: t('backup.coverage.coverage.missingOnDisk', 'Missing on disk'),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSavePhotoToDevice } from '../../hooks/useGallery';
|
||||
import { AuthenticatedImage } from '../common';
|
||||
import { PhotoFeedback } from './PhotoFeedback';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
@@ -102,6 +103,17 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
// View beacon (#895): count exactly the photo that became the visible
|
||||
// slide. The image fetches themselves can't be counted — preloaded
|
||||
// neighbours would inflate, and a neighbour promoted by a swipe is
|
||||
// never re-fetched (#505).
|
||||
const currentPhotoId = photos[currentIndex]?.id;
|
||||
useEffect(() => {
|
||||
if (currentPhotoId !== undefined) {
|
||||
galleryService.trackPhotoView(slug, currentPhotoId);
|
||||
}
|
||||
}, [slug, currentPhotoId]);
|
||||
|
||||
|
||||
// Save-aware download. On mobile (where Web Share + files is supported)
|
||||
// this opens the OS share sheet so "Save to Photos" actually lands in
|
||||
|
||||
@@ -563,6 +563,14 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
close={() => setLightboxIndex(-1)}
|
||||
index={lightboxIndex}
|
||||
slides={slides}
|
||||
// View beacon (#895): yarl fires `view` on open and on every
|
||||
// slide change — same semantics as PhotoLightbox's beacon.
|
||||
on={{
|
||||
view: ({ index }) => {
|
||||
const photo = filteredPhotos[index];
|
||||
if (photo) galleryService.trackPhotoView(slug, photo.id);
|
||||
},
|
||||
}}
|
||||
plugins={[
|
||||
Thumbnails,
|
||||
Zoom,
|
||||
|
||||
@@ -416,6 +416,7 @@
|
||||
"willScan": "Wird gescannt",
|
||||
"skippedByToggle": "Übersprungen (Schalter aus)",
|
||||
"skippedByFlag": "Übersprungen (Feature-Flag)",
|
||||
"skippedBySetting": "Übersprungen (Backup-Einstellungen)",
|
||||
"missingOnDisk": "Auf Festplatte fehlend",
|
||||
"drift": "Nicht konfiguriert auf Festplatte (Drift)"
|
||||
},
|
||||
@@ -430,6 +431,7 @@
|
||||
"willScan": "Wird gescannt",
|
||||
"skippedByToggle": "Aus",
|
||||
"skippedByFlag": "Per Flag aus",
|
||||
"skippedBySetting": "In Backup-Einstellungen aus",
|
||||
"missingOnDisk": "Auf Festplatte fehlend"
|
||||
},
|
||||
"drift": {
|
||||
|
||||
@@ -2862,6 +2862,7 @@
|
||||
"willScan": "Will scan",
|
||||
"skippedByToggle": "Skipped (toggle off)",
|
||||
"skippedByFlag": "Skipped (feature flag)",
|
||||
"skippedBySetting": "Skipped (backup settings)",
|
||||
"missingOnDisk": "Missing on disk",
|
||||
"drift": "Unconfigured on disk (drift)"
|
||||
},
|
||||
@@ -2876,6 +2877,7 @@
|
||||
"willScan": "Will scan",
|
||||
"skippedByToggle": "Off",
|
||||
"skippedByFlag": "Gated off",
|
||||
"skippedBySetting": "Off (backup settings)",
|
||||
"missingOnDisk": "Missing on disk"
|
||||
},
|
||||
"drift": {
|
||||
|
||||
@@ -128,7 +128,7 @@ export const BackupManagement: React.FC = () => {
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-5 w-5 text-neutral-400" />
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('backup.status.nextBackup')}: {backupStatus?.nextBackup || t('backup.status.notScheduled')}
|
||||
{t('backup.status.nextBackup')}: {backupStatus?.nextBackup ? fmtDateTime(backupStatus.nextBackup) : t('backup.status.notScheduled')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -251,6 +251,7 @@ export type BackupPathCoverage =
|
||||
| 'will-scan'
|
||||
| 'skipped-by-toggle'
|
||||
| 'skipped-by-feature-flag'
|
||||
| 'skipped-by-setting'
|
||||
| 'missing-on-disk';
|
||||
|
||||
export interface BackupCoveragePath {
|
||||
@@ -289,6 +290,7 @@ export interface BackupCoverageReport {
|
||||
willScanCount: number;
|
||||
skippedByToggleCount: number;
|
||||
skippedByFeatureFlagCount: number;
|
||||
skippedBySettingCount: number;
|
||||
missingOnDiskCount: number;
|
||||
driftCount: number;
|
||||
tableMissingFallbackInUse: boolean;
|
||||
|
||||
@@ -198,6 +198,15 @@ export const galleryService = {
|
||||
this.triggerBrowserDownload(fetched.blob, fetched.serverFilename || filename);
|
||||
},
|
||||
|
||||
// Per-photo view beacon (#895). Fired by the lightbox when a photo
|
||||
// becomes the visible slide — request-level counting on the image
|
||||
// endpoints can't tell the current slide from its preloaded
|
||||
// neighbours. Fire-and-forget: view counting must never surface an
|
||||
// error to the guest.
|
||||
trackPhotoView(slug: string, photoId: number): void {
|
||||
api.post(`/gallery/${slug}/photo/${photoId}/view`).catch(() => {});
|
||||
},
|
||||
|
||||
// Download all photos as ZIP
|
||||
// When a pre-generated zip is available, use native browser download (Content-Length → progress bar).
|
||||
// Otherwise fall back to blob download.
|
||||
|
||||
@@ -15,6 +15,7 @@ readonly SCRIPT_VERSION="2.1.0"
|
||||
readonly APP_NAME="PicPeak"
|
||||
readonly REPO_URL="https://github.com/PicPeak/picpeak.git"
|
||||
readonly NODE_VERSION="20"
|
||||
readonly NODE_MIN_VERSION="20.19.0" # backend engines: ^20.19.0 || >=22 (sharp 0.35, html-to-text 10)
|
||||
readonly MIN_RAM_DOCKER=2048
|
||||
readonly MIN_RAM_NATIVE=1024
|
||||
readonly MIN_DISK_GB=2
|
||||
@@ -721,7 +722,15 @@ EOF
|
||||
################################################################################
|
||||
|
||||
install_nodejs() {
|
||||
if command_exists node && [[ $(node -v | cut -d'v' -f2 | cut -d'.' -f1) -ge $NODE_VERSION ]]; then
|
||||
# --update dispatches here before main() runs detect_os, so detect on demand
|
||||
if [[ -z "$PACKAGE_MANAGER" ]]; then
|
||||
detect_os
|
||||
fi
|
||||
|
||||
local node_ver
|
||||
node_ver=$(command_exists node && node -v | cut -d'v' -f2 || echo "0")
|
||||
# backend engines range is ^20.19.0 || >=22 (Node 21 is excluded by the glob/minimatch family)
|
||||
if [[ "$(printf '%s\n' "$NODE_MIN_VERSION" "$node_ver" | sort -V | head -1)" == "$NODE_MIN_VERSION" && "${node_ver%%.*}" != "21" ]]; then
|
||||
log_success "Node.js $(node -v) is already installed"
|
||||
return
|
||||
fi
|
||||
@@ -738,7 +747,12 @@ install_nodejs() {
|
||||
$PACKAGE_MANAGER install -y nodejs
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
# Package managers won't downgrade a newer Node (e.g. 21), so re-verify before continuing
|
||||
node_ver=$(command_exists node && node -v | cut -d'v' -f2 || echo "0")
|
||||
if [[ "$(printf '%s\n' "$NODE_MIN_VERSION" "$node_ver" | sort -V | head -1)" != "$NODE_MIN_VERSION" || "${node_ver%%.*}" == "21" ]]; then
|
||||
die "Node.js v$node_ver does not satisfy the backend requirement (^$NODE_MIN_VERSION || >=22); remove the current Node.js, install a supported version, then re-run this script"
|
||||
fi
|
||||
log_success "Node.js installed: $(node -v)"
|
||||
}
|
||||
|
||||
@@ -1254,7 +1268,10 @@ update_docker_installation() {
|
||||
|
||||
update_native_installation() {
|
||||
log_step "Updating native installation..."
|
||||
|
||||
|
||||
# Make sure the runtime satisfies the backend engines range before taking the service down
|
||||
install_nodejs
|
||||
|
||||
# Stop services
|
||||
systemctl stop picpeak-backend || true
|
||||
if systemctl list-unit-files | grep -q '^picpeak-workers.service'; then
|
||||
|
||||
Reference in New Issue
Block a user