Compare commits

..

2 Commits

Author SHA1 Message Date
Paul Nothaft e1ae562a37 before/after for stray-0 fix PR 2026-07-03 08:58:55 +02:00
Paul Nothaft c153b5b891 screenshots for refactor/codebase-cleanup PR 2026-07-03 08:28:05 +02:00
125 changed files with 1666 additions and 8474 deletions
-4
View File
@@ -1,4 +0,0 @@
# These are supported funding model platforms
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
buy_me_a_coffee: theluap
-1
View File
@@ -25,7 +25,6 @@ jobs:
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
target-branch: stable
# Auto-approve + auto-merge the open stable release PR. See the beta
# workflow for the full rationale. Skipped on the release-cutting run and
+2 -2
View File
@@ -17,9 +17,9 @@ name: Tests
on:
push:
branches: [main, beta, stable]
branches: [main, beta]
pull_request:
branches: [main, beta, stable]
branches: [main, beta]
workflow_dispatch:
permissions:
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.83.0-beta.0"
".": "3.79.1-beta.0"
}
+3 -1
View File
@@ -1 +1,3 @@
{".":"3.45.3"}
{
".": "2.6.1"
}
+932 -824
View File
File diff suppressed because it is too large Load Diff
-10
View File
@@ -9,16 +9,6 @@ PORT=3001
# Generate with: openssl rand -base64 32
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
# Admin 2FA (TOTP) secret encryption key — OPTIONAL.
# Admin authenticator secrets are encrypted at rest (AES-256-GCM). By default
# the key is derived from JWT_SECRET, so you do NOT need to set this. Set it
# only if you want the MFA encryption key decoupled from JWT_SECRET (e.g. so
# rotating JWT_SECRET doesn't invalidate enrolled authenticators). If you set
# it, changing/losing it makes existing 2FA secrets undecryptable — recover
# with: docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
# Generate with: openssl rand -base64 32
#MFA_ENCRYPTION_KEY=
# Auth cookie Secure flag
# unset - default: 'auto' in production, false in dev (#427)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
+1 -8
View File
@@ -27,15 +27,8 @@ FROM node:22-alpine
WORKDIR /app
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
# stage's declaration never reached this stage. Consuming it in the RUN below
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
# image always picks up current Alpine security updates instead of reusing a
# stale cached upgrade layer.
ARG CACHEBUST=1
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
RUN 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
@@ -1,94 +0,0 @@
'use strict';
// Validates the engine-neutral .picpeak export: it must produce a real zip with
// a manifest + per-table NDJSON, exclude knex bookkeeping, and honour the photo
// toggle. Uses the shared CRM DB harness (temp SQLite) — no docker needed.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const StreamZip = require('node-stream-zip');
const { bootCrmDb } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let createPicpeak;
// bootCrmDb MUST run before requiring the service (which transitively requires
// db.js) so the export reads this test's DB, not the default path.
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);
afterAll(async () => {
await cleanup();
});
async function readZip(filePath) {
const zip = new StreamZip.async({ file: filePath });
const entries = Object.keys(await zip.entries());
const manifest = JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
await zip.close();
return { entries, manifest };
}
describe('picpeak export (.picpeak logical export)', () => {
it('produces a .picpeak with a manifest and per-table NDJSON', async () => {
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
expect(filePath.endsWith('.picpeak')).toBe(true);
expect(fs.existsSync(filePath)).toBe(true);
expect(manifest.format).toBe(1);
expect(manifest.kind).toBe('picpeak-backup');
expect(manifest.database.engine).toBe('sqlite');
expect(manifest.options.includePhotos).toBe(false);
expect(manifest.contains_secrets).toBe(true);
// Migrations seed real tables (e.g. app_settings) — expect several.
expect(Object.keys(manifest.tables).length).toBeGreaterThan(0);
expect(Object.keys(manifest.tables)).toContain('app_settings');
const { entries, manifest: zipped } = await readZip(filePath);
expect(entries).toContain('manifest.json');
expect(entries.some((n) => n.startsWith('data/') && n.endsWith('.ndjson'))).toBe(true);
expect(entries).toContain('data/app_settings.ndjson');
// Manifest inside the zip matches the returned one.
expect(zipped.tables).toEqual(manifest.tables);
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('never exports knex bookkeeping tables', async () => {
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
const names = Object.keys(manifest.tables);
expect(names).not.toContain('knex_migrations');
expect(names).not.toContain('knex_migrations_lock');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('row counts in the manifest match the NDJSON line counts', async () => {
// Insert a couple of settings so at least one table is non-empty.
await db('app_settings')
.insert({ setting_key: 'picpeak_export_test_a', setting_value: JSON.stringify('1'), setting_type: 'string' })
.onConflict('setting_key').merge();
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
const zip = new StreamZip.async({ file: filePath });
const buf = await zip.entryData('data/app_settings.ndjson');
await zip.close();
const lines = buf.toString('utf8').split('\n').filter((l) => l.trim().length > 0);
expect(lines.length).toBe(manifest.tables.app_settings.rowCount);
expect(manifest.tables.app_settings.rowCount).toBeGreaterThan(0);
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
});
@@ -1,180 +0,0 @@
'use strict';
// Full .picpeak roundtrip on a temp SQLite DB:
// 1. seed a "backup" instance (admin A + a marker setting)
// 2. export → .picpeak
// 3. simulate a reinstall: wipe, create a DIFFERENT current admin B, mutate data
// 4. import the backup with currentAdminId = B
// 5. assert the backup data is restored AND the current account (B) survives,
// while the backup's admin (A) is also present (different email → added).
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let createPicpeak;
let importFromPicpeak;
let validateManifest;
let superAdminRoleId;
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.STORAGE_PATH = tmpDir;
({ createPicpeak } = require('../../src/services/picpeakExportService'));
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
const role = await db('roles').where({ name: 'super_admin' }).first();
superAdminRoleId = role.id;
}, 60000);
afterAll(async () => {
await cleanup();
});
const adminRow = (email, hash) => ({
username: email,
email,
password_hash: hash,
role_id: superAdminRoleId,
is_active: true,
must_change_password: false,
created_at: new Date(),
updated_at: new Date(),
});
async function setMarker(value) {
await db('app_settings')
.insert({ setting_key: 'roundtrip_marker', setting_value: JSON.stringify(value), setting_type: 'string' })
.onConflict('setting_key').merge();
}
async function getMarker() {
const row = await db('app_settings').where({ setting_key: 'roundtrip_marker' }).first();
return row ? JSON.parse(row.setting_value) : null;
}
describe('.picpeak roundtrip (export → import)', () => {
it('restores backup data and preserves the current account', async () => {
// 1. Seed the "source" instance.
await db('admin_users').del();
await db('admin_users').insert(adminRow('backup-admin@old.example', 'HASH_A'));
await setMarker('from_backup');
// 2. Export.
const { filePath } = await createPicpeak({ includePhotos: false });
try {
// 3. Simulate a reinstall: fresh current admin B, mutated data.
await db('admin_users').del();
const [bId] = await db('admin_users').insert(adminRow('current-admin@new.example', 'HASH_B')).returning('id');
const currentAdminId = typeof bId === 'object' ? bId.id : bId;
await setMarker('mutated_after_backup');
// 4. Import, preserving the current admin.
const result = await importFromPicpeak({ filePath: undefined, picpeakPath: filePath, currentAdminId });
expect(result.restored).toBe(true);
expect(result.tables).toBeGreaterThan(0);
// 5a. Backup data restored (marker reverted to the backup value).
expect(await getMarker()).toBe('from_backup');
// 5b. The backup's admin is present (different email → added).
const a = await db('admin_users').whereRaw('lower(email) = lower(?)', ['backup-admin@old.example']).first();
expect(a).toBeTruthy();
expect(a.password_hash).toBe('HASH_A');
// 5c. The current account SURVIVES the override, with its own credentials.
const b = await db('admin_users').whereRaw('lower(email) = lower(?)', ['current-admin@new.example']).first();
expect(b).toBeTruthy();
expect(b.password_hash).toBe('HASH_B');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('overwrites a backup admin that collides with the current account email', async () => {
// Source has an admin at the SAME email the current operator will use.
await db('admin_users').del();
await db('admin_users').insert(adminRow('shared@example.com', 'OLD_HASH'));
await setMarker('collision_case');
const { filePath } = await createPicpeak({ includePhotos: false });
try {
// Reinstall: current admin uses the same email but a NEW password.
await db('admin_users').del();
const [id] = await db('admin_users').insert(adminRow('shared@example.com', 'NEW_HASH')).returning('id');
const currentAdminId = typeof id === 'object' ? id.id : id;
await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
// Exactly one admin at that email, and it keeps the CURRENT password.
const rows = await db('admin_users').whereRaw('lower(email) = lower(?)', ['shared@example.com']);
expect(rows).toHaveLength(1);
expect(rows[0].password_hash).toBe('NEW_HASH');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('restores files/ and reports filesRestored', async () => {
// A business-doc that lives in storage → travels in the backup.
const docDir = path.join(tmpDir, 'business-docs');
const marker = path.join(docDir, 'roundtrip-doc.txt');
fs.mkdirSync(docDir, { recursive: true });
fs.writeFileSync(marker, 'hello');
await db('admin_users').del();
const [id] = await db('admin_users').insert(adminRow('files@example.com', 'H')).returning('id');
const currentAdminId = typeof id === 'object' ? id.id : id;
const { filePath } = await createPicpeak({ includePhotos: false });
try {
fs.rmSync(marker); // delete on disk so the restore must bring it back
const result = await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
expect(result.filesRestored).toBeGreaterThanOrEqual(1);
expect(fs.existsSync(marker)).toBe(true);
expect(fs.readFileSync(marker, 'utf8')).toBe('hello');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
fs.rmSync(docDir, { recursive: true, force: true });
}
});
});
describe('.picpeak manifest validation', () => {
it('rejects a database-engine mismatch', async () => {
// Harness runs on SQLite, so a pg manifest must be refused.
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
});
expect(blockers.some((b) => /engine/i.test(b))).toBe(true);
});
it('rejects a backup from a newer schema (forward-only)', async () => {
// validateManifest reads knex_migrations for the target's latest migration;
// the harness has none, so create it with an older migration than the backup.
await db.schema.createTable('knex_migrations', (t) => {
t.increments('id');
t.string('name');
t.integer('batch');
t.timestamp('migration_time');
});
try {
await db('knex_migrations').insert({ name: '100_baseline', batch: 1 });
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1,
database: { engine: 'sqlite', latest_migration: '999_from_the_future' },
tables: {},
});
expect(blockers.some((b) => /newer/i.test(b))).toBe(true);
} finally {
await db.schema.dropTableIfExists('knex_migrations');
}
});
it('rejects a file that is not a PicPeak backup', async () => {
const blockers = await validateManifest({ some: 'random-json' });
expect(blockers.length).toBeGreaterThan(0);
});
});
@@ -1,82 +0,0 @@
/**
* CLI test for scripts/reset-admin-mfa.js — break-glass MFA reset (#738).
*
* Boots a temp-SQLite DB, seeds an admin with MFA fully enabled, then runs
* the script in a child process (--email <addr> --yes) pointed at the same
* DB file, and asserts the four MFA columns are zeroed. The script runs in
* its own process with its own knex connection; the parent connection is
* idle during the spawn so the SQLite write lock isn't contended.
*/
const path = require('path');
const { execFileSync } = require('child_process');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
let db;
let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
const SCRIPT = path.resolve(__dirname, '..', '..', 'scripts', 'reset-admin-mfa.js');
async function seedEnrolledAdmin(email) {
const inserted = await db('admin_users').insert({
username: email.split('@')[0],
email,
password_hash: 'x',
is_active: true,
two_factor_enabled: true,
two_factor_secret: 'iv.tag.ct',
two_factor_recovery_codes: JSON.stringify(['$2b$10$fakehashfakehashfakehashfa']),
two_factor_enrolled_at: new Date(),
created_at: new Date(),
}).returning('id');
return inserted[0]?.id ?? inserted[0];
}
it('zeroes the four MFA columns for the targeted admin', async () => {
const email = 'reset-me@example.com';
const id = await seedEnrolledAdmin(email);
execFileSync('node', [SCRIPT, '--email', email, '--yes'], {
env: {
...process.env,
NODE_ENV: 'test',
TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH,
},
stdio: 'pipe',
});
const row = await db('admin_users').where({ id }).first();
expect(Number(row.two_factor_enabled)).toBe(0);
expect(row.two_factor_secret).toBeNull();
expect(row.two_factor_recovery_codes).toBeNull();
expect(row.two_factor_enrolled_at).toBeNull();
});
it('leaves a different admin untouched', async () => {
const targetEmail = 'target@example.com';
const bystanderEmail = 'bystander@example.com';
const targetId = await seedEnrolledAdmin(targetEmail);
const bystanderId = await seedEnrolledAdmin(bystanderEmail);
execFileSync('node', [SCRIPT, '--email', targetEmail, '--yes'], {
env: { ...process.env, NODE_ENV: 'test', TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH },
stdio: 'pipe',
});
const target = await db('admin_users').where({ id: targetId }).first();
const bystander = await db('admin_users').where({ id: bystanderId }).first();
expect(Number(target.two_factor_enabled)).toBe(0);
expect(Number(bystander.two_factor_enabled)).toBe(1);
expect(bystander.two_factor_secret).toBe('iv.tag.ct');
});
@@ -239,7 +239,7 @@ describe('workflow engine', () => {
expect(again.already).toBe(true);
});
test('seeds the invoice-dunning built-in as the delegation graph (disabled for first beta)', async () => {
test('seeds the invoice-dunning built-in as the delegation graph (v6, disabled for first beta)', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
@@ -248,7 +248,7 @@ describe('workflow engine', () => {
expect(wf).toBeTruthy();
expect(!!wf.is_builtin).toBe(true);
expect(!!wf.enabled).toBe(false); // first beta: ships disabled; legacy ladder runs until enabled
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(7);
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(6);
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
@@ -273,7 +273,7 @@ describe('workflow engine', () => {
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const reseeded = await db('workflows').where({ id: wf.id }).first();
expect(reseeded.version).toBe(wf.version + 1); // bumped
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(7);
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(6);
expect(!!reseeded.enabled).toBe(false); // seed default re-applied (not admin-owned → flips enabled→disabled)
const newNodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: reseeded.version });
expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced
@@ -1,78 +0,0 @@
/**
* Regression test for the bulk archive/delete ownership bypass.
*
* bulk-archive and bulk-delete acted on body-supplied event ids with no
* ownership filter, so an admin/editor scoped to their own events (the
* single-event routes enforce requireEventOwnership) could archive or
* cascade-delete ANY event by id. filterOwnedEventIds is the helper those
* routes now use to drop foreign/non-existent ids.
*/
// events owned by admin 7; event 3 owned by someone else; event 4 is
// ownerless (legacy). The mock models:
// whereIn('id', ids).andWhere(created_by IS NULL OR created_by = admin.id)
const EVENTS = [
{ id: 1, created_by: 7 },
{ id: 2, created_by: 7 },
{ id: 3, created_by: 99 }, // foreign
{ id: 4, created_by: null }, // ownerless/legacy
];
jest.mock('../../src/database/db', () => ({
db: () => {
const q = {
_ids: null,
_adminId: null,
whereIn(_col, ids) { this._ids = ids; return this; },
andWhere(cb) {
// Emulate the (created_by IS NULL OR created_by = admin.id) builder
// by capturing the admin id the callback closes over via a probe.
const probe = {
_adminId: null,
whereNull() { return this; },
orWhere(_col, id) { this._adminId = id; return this; },
};
cb(probe);
this._adminId = probe._adminId;
return this;
},
select() {
return Promise.resolve(
EVENTS
.filter((e) => this._ids.includes(e.id))
.filter((e) => e.created_by === null || e.created_by === this._adminId)
.map((e) => ({ id: e.id }))
);
},
};
return q;
},
}));
const { filterOwnedEventIds } = require('../../src/middleware/ownership');
describe('filterOwnedEventIds', () => {
it('super_admin gets every id, nothing denied', async () => {
const { allowed, denied } = await filterOwnedEventIds(
{ id: 7, roleName: 'super_admin' }, [1, 3, 4, 999]
);
expect(allowed).toEqual([1, 3, 4, 999]);
expect(denied).toEqual([]);
});
it('non-super_admin keeps owned + ownerless, denies foreign and non-existent', async () => {
const { allowed, denied } = await filterOwnedEventIds(
{ id: 7, roleName: 'admin' }, [1, 2, 3, 4, 999]
);
expect(allowed.sort()).toEqual([1, 2, 4]); // owns 1,2; 4 is ownerless
expect(denied.sort()).toEqual([3, 999]); // 3 foreign, 999 missing
});
it('foreign-only request yields empty allowed', async () => {
const { allowed, denied } = await filterOwnedEventIds(
{ id: 7, roleName: 'editor' }, [3]
);
expect(allowed).toEqual([]);
expect(denied).toEqual([3]);
});
});
@@ -1,103 +0,0 @@
/**
* Regression test for the cross-event thumbnail enumeration leak.
*
* Thumbnails are served flat from /thumbnails/thumb_<name> with
* deterministic, enumerable filenames. photoAuth previously granted any
* holder of a gallery token for ANY active event access to ANY thumbnail
* (it set eventSlug=null and returned next() as long as the token's event
* existed), so a visitor to one gallery could pull another (password-
* protected) gallery's entire thumbnail set. The fix scopes thumbnail
* access to the token's event by matching the requested file against
* photos.thumbnail_path for that event_id.
*/
process.env.JWT_SECRET = 'test-secret-thumbnail-scope-000000000000';
const jwt = require('jsonwebtoken');
// Two events, each owning one thumbnail. The photos mock resolves a row
// only when BOTH event_id and thumbnail_path match — i.e. it models the
// real ownership query.
const EVENTS = [
{ id: 10, slug: 'event-a', is_active: 1 },
{ id: 20, slug: 'event-b', is_active: 1 },
];
const PHOTOS = [
{ id: 1, event_id: 10, thumbnail_path: 'thumbnails/thumb_event-a_ceremony_0001.jpg' },
{ id: 2, event_id: 20, thumbnail_path: 'thumbnails/thumb_event-b_ceremony_0001.jpg' },
];
jest.mock('../../src/database/db', () => ({
db: (table) => ({
_cond: null,
where(cond) { this._cond = cond; return this; },
first() {
if (table === 'events') {
return Promise.resolve(EVENTS.find((e) => e.id === this._cond.id) || null);
}
if (table === 'photos') {
return Promise.resolve(
PHOTOS.find((p) => p.event_id === this._cond.event_id
&& p.thumbnail_path === this._cond.thumbnail_path) || null
);
}
return Promise.resolve(null);
},
}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
const photoAuth = require('../../src/middleware/photoAuth');
function galleryToken(eventId) {
return jwt.sign({ type: 'gallery', eventId }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
}
function makeReqRes(token, thumbPath) {
const req = { path: thumbPath, headers: { authorization: `Bearer ${token}` }, cookies: {} };
const res = {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
};
return { req, res };
}
describe('photoAuth — thumbnail ownership scoping', () => {
it('denies a gallery token for event A fetching event B\'s thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(10), '/thumb_event-b_ceremony_0001.jpg');
const next = jest.fn();
await photoAuth(req, res, next);
// Access denied: middleware must not pass the request through.
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(req.event).toBeUndefined();
});
it('allows a gallery token to fetch its own event\'s thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(20), '/thumb_event-b_ceremony_0001.jpg');
const next = jest.fn();
await photoAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.event).toMatchObject({ id: 20 });
});
it('denies a traversal / foreign filename that matches no owned thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(10), '/thumb_../../etc/passwd');
const next = jest.fn();
await photoAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(req.event).toBeUndefined();
});
});
-345
View File
@@ -1,345 +0,0 @@
/**
* HTTP-level tests for the admin TOTP MFA feature (#738).
*
* Two surfaces:
* 1. Enrollment (adminAuth-gated) — POST /mfa/setup, /mfa/enable,
* GET /mfa/status, POST /mfa/disable — mounted like server.js at
* /api/admin/auth (src/routes/adminAuth.js).
* 2. Login challenge — POST /admin/login + POST /admin/login/mfa
* (src/routes/auth.js, mounted /api/auth).
*
* Uses the same real-SQLite harness as the CRM route tests
* (bootCrmDb + seedMinimal + mintAdminToken). Valid TOTP codes are
* generated in-test via otplib's authenticator against the secret the
* /setup endpoint returns in plaintext.
*
* NOTE: env (TEST_DATABASE_PATH / JWT_SECRET) must be set BEFORE the
* first require of db.js — mirror adminCrmAuth.test.js exactly.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-adminmfa-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-route-test-secret';
// reCAPTCHA disabled (default) → verifyRecaptcha returns true, so login
// tests don't need a token. Be explicit so a leaked env can't flip it on.
delete process.env.RECAPTCHA_SECRET_KEY;
const request = require('supertest');
const bcrypt = require('bcrypt');
const { authenticator } = require('otplib');
const {
bootCrmDb, mintAdminToken, buildRouteApp,
} = require('../integration/helpers/crmDb');
jest.setTimeout(60000);
let db;
let cleanup;
let adminApp; // /api/admin/auth (enrollment)
let authApp; // /api/auth (login challenge)
/**
* Seed a bare admin (password known) and return its id + login creds.
* seedMinimal always creates username 'tester'; we need distinct rows per
* scenario, so insert directly with a unique username/email.
*/
async function seedAdmin({ username, superAdmin = false } = {}) {
const password = 'correct-horse';
const passwordHash = await bcrypt.hash(password, 4);
const uname = username || `admin-${Math.random().toString(36).slice(2, 8)}`;
const row = {
username: uname,
email: `${uname}@example.com`,
password_hash: passwordHash,
must_change_password: false,
is_active: true,
created_at: new Date(),
};
if (superAdmin) {
const role = await db('roles').where({ name: 'super_admin' }).first();
if (!role) throw new Error('super_admin role not seeded');
row.role_id = role.id;
}
const inserted = await db('admin_users').insert(row).returning('id');
const id = inserted[0]?.id ?? inserted[0];
return { id, username: uname, password };
}
/** Run the full setup→enable enrollment against the live app. Returns
* the plaintext TOTP secret (for later login codes) and recovery codes. */
async function enroll(adminId) {
const token = mintAdminToken(adminId);
const setup = await request(adminApp)
.post('/api/admin/auth/mfa/setup')
.set('Authorization', `Bearer ${token}`);
expect(setup.status).toBe(200);
const secret = setup.body.secret;
const enable = await request(adminApp)
.post('/api/admin/auth/mfa/enable')
.set('Authorization', `Bearer ${token}`)
.send({ code: authenticator.generate(secret) });
expect(enable.status).toBe(200);
return { secret, recoveryCodes: enable.body.recoveryCodes, token };
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth'));
authApp = buildRouteApp('/api/auth', require('../../src/routes/auth'));
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('MFA enrollment — /api/admin/auth/mfa/*', () => {
it('setup returns a secret + otpauth URI + QR and does NOT enable yet', async () => {
const admin = await seedAdmin();
const token = mintAdminToken(admin.id);
const res = await request(adminApp)
.post('/api/admin/auth/mfa/setup')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.secret).toEqual(expect.any(String));
expect(res.body.otpauthUri).toMatch(/^otpauth:\/\/totp\//);
expect(res.body.qr).toMatch(/^data:image\/png;base64,/);
// Not yet enabled: status must still report disabled.
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(false);
// And the row stores an encrypted secret (not the plaintext one).
const row = await db('admin_users').where({ id: admin.id }).first();
expect(row.two_factor_secret).toBeTruthy();
expect(row.two_factor_secret).not.toBe(res.body.secret);
expect(Number(row.two_factor_enabled)).toBe(0);
});
it('full flow: setup → enable(valid TOTP) → status shows enabled + 10 recovery codes', async () => {
const admin = await seedAdmin();
const { recoveryCodes, token } = await enroll(admin.id);
expect(Array.isArray(recoveryCodes)).toBe(true);
expect(recoveryCodes).toHaveLength(10);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.status).toBe(200);
expect(status.body.enabled).toBe(true);
expect(status.body.recoveryCodesRemaining).toBe(10);
expect(status.body.enrolledAt).toBeTruthy();
});
it('enable with a WRONG code is rejected (400) and MFA stays off', async () => {
const admin = await seedAdmin();
const token = mintAdminToken(admin.id);
const setup = await request(adminApp)
.post('/api/admin/auth/mfa/setup')
.set('Authorization', `Bearer ${token}`);
const valid = authenticator.generate(setup.body.secret);
const wrong = valid === '000000' ? '111111' : '000000';
const res = await request(adminApp)
.post('/api/admin/auth/mfa/enable')
.set('Authorization', `Bearer ${token}`)
.send({ code: wrong });
expect(res.status).toBe(400);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(false);
});
it('enable before setup is rejected', async () => {
const admin = await seedAdmin();
const token = mintAdminToken(admin.id);
const res = await request(adminApp)
.post('/api/admin/auth/mfa/enable')
.set('Authorization', `Bearer ${token}`)
.send({ code: '123456' });
// No provisional secret → ValidationError (400).
expect(res.status).toBe(400);
});
it('all enrollment endpoints require a valid admin token (401 without one)', async () => {
const noToken = await request(adminApp).get('/api/admin/auth/mfa/status');
expect(noToken.status).toBe(401);
const setup = await request(adminApp).post('/api/admin/auth/mfa/setup');
expect(setup.status).toBe(401);
});
// Regression guard for #735: super_admin used to be blocked from enrolling.
// Enrollment operates on req.admin.id and is role-agnostic — assert a
// super_admin can complete the full setup→enable flow.
it('#735 regression — a super_admin can enroll in MFA', async () => {
const admin = await seedAdmin({ superAdmin: true });
const { recoveryCodes, token } = await enroll(admin.id);
expect(recoveryCodes).toHaveLength(10);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(true);
});
});
describe('MFA disable — /api/admin/auth/mfa/disable', () => {
it('requires a valid code; a wrong code is rejected and state persists', async () => {
const admin = await seedAdmin();
const { token } = await enroll(admin.id);
const bad = await request(adminApp)
.post('/api/admin/auth/mfa/disable')
.set('Authorization', `Bearer ${token}`)
.send({ code: '000000' });
expect(bad.status).toBe(400);
const stillOn = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(stillOn.body.enabled).toBe(true);
});
it('a valid TOTP disables MFA and clears the stored secret', async () => {
const admin = await seedAdmin();
const { secret, token } = await enroll(admin.id);
const res = await request(adminApp)
.post('/api/admin/auth/mfa/disable')
.set('Authorization', `Bearer ${token}`)
.send({ code: authenticator.generate(secret) });
expect(res.status).toBe(200);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(false);
expect(status.body.recoveryCodesRemaining).toBe(0);
const row = await db('admin_users').where({ id: admin.id }).first();
expect(row.two_factor_secret).toBeNull();
expect(row.two_factor_recovery_codes).toBeNull();
});
});
describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
it('an enrolled admin gets mfaRequired + mfaToken, NO session cookie', async () => {
const admin = await seedAdmin();
await enroll(admin.id);
const res = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
expect(res.status).toBe(200);
expect(res.body.mfaRequired).toBe(true);
expect(res.body.mfaToken).toEqual(expect.any(String));
expect(res.body.user).toBeUndefined(); // no completed session
// No admin auth cookie should have been set on the challenge response.
const cookies = res.headers['set-cookie'] || [];
expect(cookies.join(';')).not.toMatch(/adminToken/i);
});
it('a NON-enrolled admin logs in directly (no mfaRequired)', async () => {
const admin = await seedAdmin();
const res = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
expect(res.status).toBe(200);
expect(res.body.mfaRequired).toBeUndefined();
expect(res.body.user).toBeDefined();
expect(res.body.user.username).toBe(admin.username);
});
it('login/mfa with a valid TOTP completes the session', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
const challenge = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const { mfaToken } = challenge.body;
const res = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken, code: authenticator.generate(secret) });
expect(res.status).toBe(200);
expect(res.body.user).toBeDefined();
expect(res.body.user.id).toBe(admin.id);
});
it('login/mfa with a wrong code is 401 MFA_INVALID', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
const challenge = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const valid = authenticator.generate(secret);
const wrong = valid === '000000' ? '111111' : '000000';
const res = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: challenge.body.mfaToken, code: wrong });
expect(res.status).toBe(401);
expect(res.body.code).toBe('MFA_INVALID');
expect(res.body.user).toBeUndefined();
});
it('a recovery code logs in and is then single-use (second use fails)', async () => {
const admin = await seedAdmin();
const { recoveryCodes } = await enroll(admin.id);
const recovery = recoveryCodes[0];
// First challenge + recovery-code exchange succeeds.
const c1 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const first = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: c1.body.mfaToken, code: recovery });
expect(first.status).toBe(200);
expect(first.body.user).toBeDefined();
// recoveryCodesRemaining dropped by one.
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${mintAdminToken(admin.id)}`);
expect(status.body.recoveryCodesRemaining).toBe(9);
// Second use of the SAME recovery code must fail.
const c2 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const second = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: c2.body.mfaToken, code: recovery });
expect(second.status).toBe(401);
expect(second.body.code).toBe('MFA_INVALID');
});
it('login/mfa rejects a non-mfa_pending token (e.g. a normal admin JWT)', async () => {
const admin = await seedAdmin();
await enroll(admin.id);
const res = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: mintAdminToken(admin.id), code: '123456' });
expect(res.status).toBe(401);
});
});
@@ -1,127 +0,0 @@
/**
* Regression test for GHSA-9hmx-68vc-qpqw — share-link login must not bypass
* the gallery password.
*
* POST /auth/gallery/share-login validates only the share token. For a
* password-protected gallery it previously minted a full `type:'gallery'`
* access token on the share token alone, letting anyone holding the share URL
* read the gallery without the password. The fix: when the gallery requires a
* password, return `{ requires_password: true }` with NO token and NO cookie.
*/
const express = require('express');
const request = require('supertest');
process.env.JWT_SECRET = 'share-login-test-secret';
const events = [];
jest.mock('../../src/database/db', () => {
function dbFn(table) {
if (table === 'events') {
let filter = () => true;
return {
where(criteria) {
filter = (row) => Object.entries(criteria).every(([k, v]) => {
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
return row[k] === v;
});
return this;
},
async first() { return events.find(filter); },
};
}
return { where() { return this; }, async first() { return undefined; } };
}
dbFn.raw = async () => {};
return { db: dbFn, logActivity: async () => {} };
});
// Share token is stored plainly on the fake event row.
jest.mock('../../src/services/shareLinkService', () => ({
getEventShareToken: (event) => event.share_token,
resolveShareIdentifier: async () => ({ event: null }),
}));
const mockSetGalleryAuthCookies = jest.fn();
jest.mock('../../src/utils/tokenUtils', () => ({
setGalleryAuthCookies: (...args) => mockSetGalleryAuthCookies(...args),
clearGalleryAuthCookies: jest.fn(),
getGalleryTokenFromRequest: jest.fn(),
setAdminAuthCookies: jest.fn(),
}));
jest.mock('../../src/utils/authSecurity', () => ({
trackFailedAttempt: jest.fn(async () => {}),
trackSuccessfulLogin: jest.fn(async () => {}),
checkAccountLockout: jest.fn(async () => ({ isLocked: false })),
resetLockout: jest.fn(async () => {}),
}));
// Collaborators the router imports at load but the share-login path doesn't hit.
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: async () => true }));
jest.mock('../../src/services/mfaService', () => ({}));
jest.mock('../../src/middleware/sessionTimeout', () => ({ endSession: jest.fn(), sessionTimeoutMiddleware: (req, res, next) => next() }));
jest.mock('../../src/utils/tokenRevocation', () => ({ revokeToken: jest.fn(async () => {}), isTokenRevoked: async () => false }));
const authRouter = require('../../src/routes/auth');
function makeApp() {
const app = express();
app.use(express.json());
app.use('/auth', authRouter);
return app;
}
const SHARE_TOKEN = 'a'.repeat(64);
beforeEach(() => {
events.length = 0;
mockSetGalleryAuthCookies.mockClear();
});
describe('POST /auth/gallery/share-login password enforcement', () => {
it('does NOT mint a token for a password-protected gallery', async () => {
events.push({
id: 1, slug: 'private-gallery', is_active: 1, is_archived: 0,
require_password: 1, share_token: SHARE_TOKEN, event_name: 'Private',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'private-gallery', token: SHARE_TOKEN });
expect(res.status).toBe(200);
expect(res.body.requires_password).toBe(true);
expect(res.body.token).toBeUndefined();
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
});
it('mints a token for a public (no-password) gallery', async () => {
events.push({
id: 2, slug: 'public-gallery', is_active: 1, is_archived: 0,
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'public-gallery', token: SHARE_TOKEN });
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
expect(res.body.event).toBeDefined();
expect(mockSetGalleryAuthCookies).toHaveBeenCalledTimes(1);
});
it('rejects a wrong share token regardless of password setting', async () => {
events.push({
id: 3, slug: 'public-gallery', is_active: 1, is_archived: 0,
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'public-gallery', token: 'b'.repeat(64) });
expect(res.status).toBe(401);
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
});
});
@@ -1,119 +0,0 @@
/**
* Regression test for GHSA-4j34-x562-5vfq — broken access control in the legacy
* /api/events router.
*
* The legacy router exposed create/list/update/delete/extend guarded by
* adminAuth ALONE (no requirePermission, no requireEventOwnership), so any
* back-office account — down to a read-only viewer — could read every gallery's
* password_hash/share_token and take over any gallery. The fix removes that
* router entirely and migrates its one UI-used route (POST /:id/extend) to the
* canonical /api/admin/events mount, where it inherits the permission +
* ownership guards.
*
* This test pins two invariants:
* 1. The legacy source file is gone (nothing can re-mount it).
* 2. The migrated extend route enforces ownership — a non-owning editor gets
* 403, the owner succeeds.
*/
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-legacy-acl-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'legacy-acl-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
async function insertEvent(db, ownerId, over = {}) {
const base = {
slug: `ev-${Math.random().toString(16).slice(2)}`,
event_type: 'wedding',
event_name: 'Owner Gallery',
event_date: '2026-05-29',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
share_token: `st-${Math.random().toString(16).slice(2)}`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_by: ownerId,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('GHSA-4j34: legacy /api/events router removed + extend guarded', () => {
it('the legacy events router source file no longer exists', () => {
expect(fs.existsSync(path.join(__dirname, '../../src/routes/events.js'))).toBe(false);
});
describe('POST /api/admin/events/:id/extend ownership enforcement', () => {
let db; let cleanup; let app;
let ownerId; let ownerToken;
let editorId; let editorToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: ownerId } = await seedMinimal(db));
await assignAdminRole(db, ownerId, 'super_admin');
ownerToken = mintAdminToken(ownerId);
// A second, non-owning account with the low-trust editor role.
[editorId] = await db('admin_users').insert({
username: 'editor1', email: 'editor1@example.com',
password_hash: 'x', is_active: 1,
}).returning('id');
editorId = editorId?.id ?? editorId;
await assignAdminRole(db, editorId, 'editor');
editorToken = mintAdminToken(editorId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
afterAll(async () => { await cleanup(); });
it('lets the owner extend their own gallery', async () => {
const id = await insertEvent(db, ownerId, { expires_at: '2026-06-01T00:00:00.000Z' });
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ days: 10 });
expect(res.status).toBe(200);
expect(new Date(res.body.expires_at).toISOString()).toBe('2026-06-11T00:00:00.000Z');
});
it('403s a non-owning editor trying to extend someone else\'s gallery', async () => {
const id = await insertEvent(db, ownerId); // owned by the super_admin
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${editorToken}`)
.send({ days: 30 });
expect(res.status).toBe(403); // requireEventOwnership blocks it
});
it('validates the days field', async () => {
const id = await insertEvent(db, ownerId);
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ days: 9999 });
expect(res.status).toBe(400);
});
});
});
@@ -1,52 +0,0 @@
const path = require('path');
const os = require('os');
const fs = require('fs').promises;
// Point storage at a throwaway temp dir before requiring the service so the
// module-level getStoragePath() picks it up if evaluated.
process.env.STORAGE_PATH = path.join(os.tmpdir(), `picpeak-chunk-test-${process.pid}`);
const chunkedUpload = require('../../src/services/chunkedUploadService');
describe('chunkedUploadService.initializeUpload filename sanitisation (GHSA-pc72-jf53-w28j)', () => {
afterAll(async () => {
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }).catch(() => {});
});
it('strips directory-traversal components from the stored filename', async () => {
const { uploadId } = await chunkedUpload.initializeUpload({
filename: '../../uploads/logos/evil.svg',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
});
const meta = chunkedUpload.getUploadStatus(uploadId);
// basename('../../uploads/logos/evil.svg') === 'evil.svg' — the traversal
// is gone, so path.join(tempDir, filename) can no longer escape tempDir.
expect(meta.filename).toBe('evil.svg');
});
it('keeps a normal filename intact', async () => {
const { uploadId } = await chunkedUpload.initializeUpload({
filename: 'clip.mp4',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
});
expect(uploadId).toBeTruthy();
});
it('rejects a filename that collapses to nothing', async () => {
await expect(
chunkedUpload.initializeUpload({
filename: '../',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
})
).rejects.toThrow(/Invalid filename/);
});
});
@@ -1,58 +0,0 @@
/**
* Regression tests for the Docker update instructions (environmentService).
*
* A production install (docker-compose.production.yml) must get `-f
* docker-compose.production.yml` in every update command — bare `docker compose`
* targets docker-compose.yml, a different build-based stack that also starts the
* dev-only mailhog, which left production users stranded on the old version
* (reported against 3.44.0 → 3.45.2).
*/
const { detectEnvironment, generateUpdateInstructions } = require('../../src/services/environmentService');
describe('detectEnvironment — production compose detection', () => {
const orig = process.env.PICPEAK_RELEASE_CHANNEL;
afterEach(() => {
if (orig === undefined) delete process.env.PICPEAK_RELEASE_CHANNEL;
else process.env.PICPEAK_RELEASE_CHANNEL = orig;
});
it('flags isProductionCompose when PICPEAK_RELEASE_CHANNEL is set', async () => {
process.env.PICPEAK_RELEASE_CHANNEL = 'stable';
const env = await detectEnvironment();
expect(env.isProductionCompose).toBe(true);
});
it('does not flag it when the var is absent (default docker-compose.yml)', async () => {
delete process.env.PICPEAK_RELEASE_CHANNEL;
const env = await detectEnvironment();
expect(env.isProductionCompose).toBe(false);
});
});
describe('generateUpdateInstructions — Docker commands', () => {
const cmds = (env) => generateUpdateInstructions(env, '3.45.2').steps.map((s) => s.command);
it('targets docker-compose.production.yml for a production install', () => {
const commands = cmds({ isDocker: true, isProductionCompose: true });
expect(commands).toEqual([
'docker compose -f docker-compose.production.yml pull',
'docker compose -f docker-compose.production.yml up -d',
'docker compose -f docker-compose.production.yml logs -f backend',
]);
// And the warning tells them where to run it.
const { warnings } = generateUpdateInstructions({ isDocker: true, isProductionCompose: true }, '3.45.2');
expect(warnings.join(' ')).toMatch(/docker-compose\.production\.yml/);
});
it('uses bare commands + a hint when not a production compose', () => {
const commands = cmds({ isDocker: true, isProductionCompose: false });
expect(commands).toEqual([
'docker compose pull',
'docker compose up -d',
'docker compose logs -f backend',
]);
const { warnings } = generateUpdateInstructions({ isDocker: true, isProductionCompose: false }, '3.45.2');
// Still nudges production users to add -f in case detection missed.
expect(warnings.join(' ')).toMatch(/-f docker-compose\.production\.yml/);
});
});
@@ -1,193 +0,0 @@
/**
* Unit tests for mfaService — admin TOTP MFA (#738).
*
* Pure unit: no DB, no Express. Exercises the crypto/verification surface
* directly. JWT_SECRET is set at the top so getEncryptionKey()'s scrypt
* derivation has key material (the service derives the AES key from
* MFA_ENCRYPTION_KEY, falling back to JWT_SECRET).
*/
// Must be set BEFORE the service is required — the key is derived lazily per
// call, but keep it explicit and stable so encrypt/decrypt round-trips.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-unit-test-secret';
delete process.env.MFA_ENCRYPTION_KEY; // ensure we derive from JWT_SECRET
const { authenticator } = require('otplib');
const mfaService = require('../../src/services/mfaService');
describe('mfaService — secret encryption (AES-256-GCM)', () => {
it('round-trips encrypt → decrypt to the original secret', () => {
const secret = mfaService.generateSecret();
const blob = mfaService.encryptSecret(secret);
expect(blob).toEqual(expect.any(String));
expect(blob).not.toContain(secret); // stored form is not plaintext
expect(blob.split('.')).toHaveLength(3); // iv.tag.ciphertext
expect(mfaService.decryptSecret(blob)).toBe(secret);
});
it('produces a different ciphertext each time (random IV) but decrypts identically', () => {
const secret = mfaService.generateSecret();
const a = mfaService.encryptSecret(secret);
const b = mfaService.encryptSecret(secret);
expect(a).not.toBe(b);
expect(mfaService.decryptSecret(a)).toBe(secret);
expect(mfaService.decryptSecret(b)).toBe(secret);
});
it('throws when decrypting a malformed blob (wrong segment count)', () => {
expect(() => mfaService.decryptSecret('garbage')).toThrow();
expect(() => mfaService.decryptSecret('only.two')).toThrow();
});
it('throws when the auth tag / ciphertext is tampered with', () => {
const secret = mfaService.generateSecret();
const [iv, tag, ct] = mfaService.encryptSecret(secret).split('.');
// Flip a character in the ciphertext → GCM auth check must fail.
const tampered = ct.slice(0, -2) + (ct.slice(-2) === 'AA' ? 'BB' : 'AA');
expect(() => mfaService.decryptSecret([iv, tag, tampered].join('.'))).toThrow();
});
});
describe('mfaService — TOTP verification', () => {
it('accepts a freshly generated code for the plaintext secret', () => {
const secret = mfaService.generateSecret();
const code = authenticator.generate(secret);
expect(mfaService.verifyTotp(code, secret)).toBe(true);
});
it('tolerates whitespace in the submitted code', () => {
const secret = mfaService.generateSecret();
const code = authenticator.generate(secret);
expect(mfaService.verifyTotp(` ${code} `, secret)).toBe(true);
});
it('rejects a wrong code', () => {
const secret = mfaService.generateSecret();
const code = authenticator.generate(secret);
const wrong = code === '000000' ? '111111' : '000000';
expect(mfaService.verifyTotp(wrong, secret)).toBe(false);
});
it('returns false for empty inputs rather than throwing', () => {
const secret = mfaService.generateSecret();
expect(mfaService.verifyTotp('', secret)).toBe(false);
expect(mfaService.verifyTotp('123456', '')).toBe(false);
expect(mfaService.verifyTotp(null, secret)).toBe(false);
});
it('verifies through the encrypted blob (verifyTotpEncrypted)', () => {
const secret = mfaService.generateSecret();
const stored = mfaService.encryptSecret(secret);
const code = authenticator.generate(secret);
expect(mfaService.verifyTotpEncrypted(code, stored)).toBe(true);
const wrong = code === '000000' ? '111111' : '000000';
expect(mfaService.verifyTotpEncrypted(wrong, stored)).toBe(false);
});
it('verifyTotpEncrypted returns false (no throw) for a corrupt blob', () => {
const secret = mfaService.generateSecret();
const code = authenticator.generate(secret);
expect(mfaService.verifyTotpEncrypted(code, 'not-a-valid-blob')).toBe(false);
});
});
describe('mfaService — otpauth URI / QR', () => {
it('builds an otpauth:// URI containing issuer, account and secret', () => {
const secret = mfaService.generateSecret();
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
expect(uri).toMatch(/^otpauth:\/\/totp\//);
expect(uri).toContain(encodeURIComponent(mfaService.ISSUER));
expect(uri).toContain(`secret=${secret}`);
});
it('builds a PNG data-URL QR for the URI', async () => {
const secret = mfaService.generateSecret();
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
const qr = await mfaService.buildQrDataUrl(uri);
expect(qr).toMatch(/^data:image\/png;base64,/);
});
});
describe('mfaService — recovery codes', () => {
it('generates 10 distinct plaintext codes and 10 distinct hashes', async () => {
const { plain, hashed } = await mfaService.generateRecoveryCodes();
expect(plain).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
expect(hashed).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
expect(new Set(plain).size).toBe(10);
expect(new Set(hashed).size).toBe(10);
// Hashes are bcrypt, not the plaintext.
hashed.forEach((h) => expect(h).toMatch(/^\$2[aby]\$/));
plain.forEach((p) => expect(hashed).not.toContain(p));
});
it('formats a raw code into 4-char groups', () => {
expect(mfaService.formatRecoveryCode('abcdefghij')).toBe('abcd-efgh-ij');
});
it('consumes a valid recovery code once and removes it (single-use)', async () => {
const { plain, hashed } = await mfaService.generateRecoveryCodes();
const target = plain[3];
const first = await mfaService.consumeRecoveryCode(target, hashed);
expect(first.matched).toBe(true);
expect(first.remainingHashes).toHaveLength(9);
// Reusing the same code against the reduced set must now fail.
const reuse = await mfaService.consumeRecoveryCode(target, first.remainingHashes);
expect(reuse.matched).toBe(false);
expect(reuse.remainingHashes).toHaveLength(9);
});
it('matches case-insensitively and trims whitespace', async () => {
const { plain, hashed } = await mfaService.generateRecoveryCodes();
const res = await mfaService.consumeRecoveryCode(` ${plain[0].toUpperCase()} `, hashed);
expect(res.matched).toBe(true);
});
it('rejects a wrong code and leaves the hash set unchanged', async () => {
const { hashed } = await mfaService.generateRecoveryCodes();
const res = await mfaService.consumeRecoveryCode('zzzz-zzzz-zz', hashed);
expect(res.matched).toBe(false);
expect(res.remainingHashes).toHaveLength(10);
});
it('handles empty / missing input safely', async () => {
const { hashed } = await mfaService.generateRecoveryCodes();
const res = await mfaService.consumeRecoveryCode('', hashed);
expect(res.matched).toBe(false);
expect(res.remainingHashes).toBe(hashed);
const noHashes = await mfaService.consumeRecoveryCode('abcd-efgh-ij', null);
expect(noHashes.matched).toBe(false);
expect(noHashes.remainingHashes).toEqual([]);
});
});
describe('mfaService — parseRecoveryCodes', () => {
it('parses a JSON string array', () => {
expect(mfaService.parseRecoveryCodes(JSON.stringify(['a', 'b']))).toEqual(['a', 'b']);
});
it('passes an already-array through', () => {
expect(mfaService.parseRecoveryCodes(['a', 'b'])).toEqual(['a', 'b']);
});
it('returns [] for null / garbage / non-array JSON', () => {
expect(mfaService.parseRecoveryCodes(null)).toEqual([]);
expect(mfaService.parseRecoveryCodes('{not json')).toEqual([]);
expect(mfaService.parseRecoveryCodes(JSON.stringify({ a: 1 }))).toEqual([]);
});
});
describe('mfaService — isEnrolled coercion', () => {
it('treats true / 1 / "1" as enrolled', () => {
expect(mfaService.isEnrolled({ two_factor_enabled: true })).toBe(true);
expect(mfaService.isEnrolled({ two_factor_enabled: 1 })).toBe(true);
expect(mfaService.isEnrolled({ two_factor_enabled: '1' })).toBe(true);
});
it('treats false / 0 / null / missing as not enrolled', () => {
expect(mfaService.isEnrolled({ two_factor_enabled: false })).toBe(false);
expect(mfaService.isEnrolled({ two_factor_enabled: 0 })).toBe(false);
expect(mfaService.isEnrolled({ two_factor_enabled: null })).toBe(false);
expect(mfaService.isEnrolled({})).toBe(false);
expect(mfaService.isEnrolled(null)).toBe(false);
});
});
@@ -1,111 +0,0 @@
/**
* Regression tests for reinjectCurrentAdmin — the operator-preservation step of
* the .picpeak restore (GHSA-qxfx-4493-4v8f follow-up). Runs against a real
* in-memory SQLite DB so the UNIQUE(email)/UNIQUE(username) constraints behave
* as in production. Reconciliation is non-destructive (update-in-place / rename,
* never delete) so restored rows referenced by FKs keep their ids.
*/
const knex = require('knex');
let db;
let reinjectCurrentAdmin;
beforeAll(() => {
jest.doMock('../../knexfile', () => ({ client: 'sqlite3' }), { virtual: false });
reinjectCurrentAdmin = require('../../src/services/picpeakImportService').reinjectCurrentAdmin;
});
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('username').notNullable().unique();
t.string('email').notNullable().unique();
t.string('password_hash');
t.boolean('is_active').defaultTo(true);
t.boolean('must_change_password').defaultTo(false);
t.integer('role_id');
t.integer('created_by');
t.boolean('two_factor_enabled').defaultTo(false);
t.string('two_factor_secret');
t.text('two_factor_recovery_codes');
});
});
afterEach(async () => { await db.destroy(); });
const operator = {
id: 1, username: 'admin', email: 'op@example.com',
password_hash: 'OP_HASH', is_active: 1, must_change_password: 0, role_id: 1, created_by: 99,
two_factor_enabled: 1, two_factor_secret: 'OP_SECRET', two_factor_recovery_codes: '["a","b"]',
};
test('restores login + MFA in place, keeping the row id and its FK columns (FK-safe)', async () => {
await db('admin_users').insert({
id: 7, username: 'someoneelse', email: 'OP@example.com',
password_hash: 'ATTACKER', is_active: 1, must_change_password: 0, role_id: 4, created_by: 5,
two_factor_enabled: 0, two_factor_secret: 'ATTACKER_SECRET', two_factor_recovery_codes: null,
});
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
const rows = await db('admin_users');
expect(rows).toHaveLength(1);
const row = rows[0];
expect(row.id).toBe(7); // id preserved → FK refs hold
expect(row.username).toBe('admin');
expect(row.password_hash).toBe('OP_HASH');
expect(Boolean(row.two_factor_enabled)).toBe(true);
expect(row.two_factor_secret).toBe('OP_SECRET'); // attacker MFA secret gone
expect(row.two_factor_recovery_codes).toBe('["a","b"]');
// Relationship/audit FKs are NOT forced from the operator snapshot (avoids
// dangling role_id/created_by on a cross-instance restore) — the restored
// row keeps its own already-valid values.
expect(row.role_id).toBe(4);
expect(row.created_by).toBe(5);
});
test('renames (not deletes) a different row holding the operator username', async () => {
await db('admin_users').insert({
id: 3, username: 'admin', email: 'other@instance.test',
password_hash: 'OTHER', is_active: 1, role_id: 4,
});
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // the other admin survives (FK-safe)
const other = rows.find((r) => r.id === 3);
expect(other.username).toBe('admin__restored_3'); // renamed, id kept
expect(other.email).toBe('other@instance.test');
const op = rows.find((r) => r.username === 'admin');
expect(op.password_hash).toBe('OP_HASH');
});
test('reconciles email and username colliding with DIFFERENT rows without deleting either', async () => {
await db('admin_users').insert([
{ id: 4, username: 'someoneelse', email: 'op@example.com', password_hash: 'A', role_id: 4 },
{ id: 5, username: 'admin', email: 'other@instance.test', password_hash: 'B', role_id: 4 },
]);
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // both rows survive
const opRow = rows.find((r) => r.id === 4); // email match updated in place
expect(opRow.username).toBe('admin');
expect(opRow.password_hash).toBe('OP_HASH');
const renamed = rows.find((r) => r.id === 5); // username holder renamed, not deleted
expect(renamed.username).toBe('admin__restored_5');
});
test('inserts the operator with a non-colliding id when neither key exists in the backup', async () => {
await db('admin_users').insert({
id: 9, username: 'backupadmin', email: 'backup@instance.test', password_hash: 'B', role_id: 1,
});
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // backup admin untouched
const opRow = rows.find((r) => r.username === 'admin');
expect(opRow.password_hash).toBe('OP_HASH');
expect(opRow.id).toBe(10); // max(9)+1, no collision
expect(opRow.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
});
@@ -1,48 +0,0 @@
/**
* Regression tests for clampIntOrUndefined — the slideshow-seed NaN bug.
*
* The event-create route seeds show_interval_ms/show_transition_ms from
* app_settings via an int-parse-and-clamp. The old inline guard
* (`Number.isFinite(+v) ? parseInt(v) : undefined`) disagreed with itself
* for null/''/true: `+null` is 0 (finite) but `parseInt(null)` is NaN, so
* NaN flowed through Math.min/Math.max into the INSERT. PostgreSQL
* rejects NaN for integer columns ("invalid input syntax for type
* integer: NaN") while SQLite silently stores NULL — so POST
* /api/admin/events 500'd on PG whenever the slideshow settings rows
* were absent (getAppSetting returns its null default).
*/
const { clampIntOrUndefined } = require('../../src/utils/numericHelpers');
describe('clampIntOrUndefined', () => {
it('returns undefined for null (the getAppSetting missing-row default)', () => {
expect(clampIntOrUndefined(null, 1000, 120000)).toBeUndefined();
});
it('returns undefined for undefined, empty string, and booleans', () => {
expect(clampIntOrUndefined(undefined, 1000, 120000)).toBeUndefined();
expect(clampIntOrUndefined('', 1000, 120000)).toBeUndefined();
expect(clampIntOrUndefined(true, 1000, 120000)).toBeUndefined();
expect(clampIntOrUndefined(false, 1000, 120000)).toBeUndefined();
});
it('returns undefined for non-numeric garbage', () => {
expect(clampIntOrUndefined('fast', 1000, 120000)).toBeUndefined();
expect(clampIntOrUndefined({}, 1000, 120000)).toBeUndefined();
});
it('never returns NaN for any of the failure-mode inputs', () => {
for (const v of [null, undefined, '', true, false, 'x', {}, []]) {
const out = clampIntOrUndefined(v, 100, 5000);
expect(Number.isNaN(out)).toBe(false);
}
});
it('parses and clamps valid values', () => {
expect(clampIntOrUndefined('2500', 1000, 120000)).toBe(2500);
expect(clampIntOrUndefined(2500, 1000, 120000)).toBe(2500);
expect(clampIntOrUndefined('500', 1000, 120000)).toBe(1000);
expect(clampIntOrUndefined(999999, 1000, 120000)).toBe(120000);
expect(clampIntOrUndefined('2500.9', 1000, 120000)).toBe(2500);
});
});
@@ -1,41 +0,0 @@
const path = require('path');
const { assertZipEntriesWithin } = require('../../src/utils/safePath');
describe('assertZipEntriesWithin (ZIP-slip guard, GHSA-jfhw-fj23-fx6x)', () => {
const root = path.join('/tmp', 'picpeak-extract-root');
it('accepts entries that stay within the extraction root', () => {
const entries = [
{ name: 'photo.jpg' },
{ name: 'category/nested/photo.png' },
{ name: 'photos_manifest.json' },
{ name: 'subdir/' },
];
expect(() => assertZipEntriesWithin(entries, root)).not.toThrow();
});
it('rejects a parent-traversal entry', () => {
const entries = [{ name: '../../uploads/logos/evil.svg' }];
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
});
it('rejects an absolute-path entry', () => {
const entries = [{ name: '/etc/cron.d/evil' }];
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
});
it('rejects when a safe entry is mixed with a traversal entry', () => {
const entries = [{ name: 'ok.jpg' }, { name: '../escape.txt' }];
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
});
it('tolerates empty / nameless entries', () => {
expect(() => assertZipEntriesWithin([{}, { name: '' }, null], root)).not.toThrow();
});
it('does not treat a sibling prefix directory as inside the root', () => {
// root is .../picpeak-extract-root; ../picpeak-extract-root-evil must not pass
const entries = [{ name: '../picpeak-extract-root-evil/x' }];
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
});
});
@@ -1,58 +0,0 @@
/**
* Migration 151: admin MFA (TOTP) enrollment support — issue #738.
*
* The `admin_users.two_factor_enabled` / `two_factor_secret` columns already
* exist from the legacy migration 016 but were never wired to any code. This
* migration adds the two columns the real TOTP flow needs on top of them:
*
* - two_factor_recovery_codes: JSON array of one-time backup codes, stored
* HASHED (never plaintext), so a locked-out admin can log in without the
* authenticator. Consumed on use.
* - two_factor_enrolled_at: when the admin completed enrollment (audit /
* display only).
*
* The TOTP secret itself continues to live in the existing `two_factor_secret`
* column, but is now stored ENCRYPTED at rest (AES-256-GCM) by mfaService —
* the column type is unchanged (the encrypted blob is short).
*
* Additive and idempotent: only adds columns, guarded by hasColumn, so it is
* safe to re-run and touches no existing data.
*/
exports.up = async function (knex) {
const hasRecovery = await knex.schema.hasColumn('admin_users', 'two_factor_recovery_codes');
const hasEnrolledAt = await knex.schema.hasColumn('admin_users', 'two_factor_enrolled_at');
const hasEnabled = await knex.schema.hasColumn('admin_users', 'two_factor_enabled');
const hasSecret = await knex.schema.hasColumn('admin_users', 'two_factor_secret');
await knex.schema.alterTable('admin_users', (t) => {
// Backfill the legacy columns too, in case an install somehow lacks them
// (016 is a legacy migration; guard defensively).
if (!hasEnabled) {
t.boolean('two_factor_enabled').defaultTo(false);
}
if (!hasSecret) {
t.string('two_factor_secret').nullable();
}
if (!hasRecovery) {
t.text('two_factor_recovery_codes').nullable();
}
if (!hasEnrolledAt) {
t.timestamp('two_factor_enrolled_at').nullable();
}
});
};
exports.down = async function (knex) {
const hasRecovery = await knex.schema.hasColumn('admin_users', 'two_factor_recovery_codes');
const hasEnrolledAt = await knex.schema.hasColumn('admin_users', 'two_factor_enrolled_at');
await knex.schema.alterTable('admin_users', (t) => {
// Only drop what THIS migration added; leave the legacy 016 columns.
if (hasRecovery) {
t.dropColumn('two_factor_recovery_codes');
}
if (hasEnrolledAt) {
t.dropColumn('two_factor_enrolled_at');
}
});
};
@@ -1,52 +0,0 @@
/**
* Migration 152: make events.hero_logo_visible NULL-able so NULL means
* "inherit the global branding_logo_display_hero setting" (#756).
*
* Before: hero_logo_visible was `boolean NOT NULL DEFAULT true`, and every
* event got a concrete true/false snapshotted at creation. The global
* "Show logo in hero section" toggle (branding_logo_display_hero) was only a
* creation-time default and never affected existing galleries — so disabling
* it did nothing to already-published galleries.
*
* After: NULL = inherit. gallery read-resolution falls back to the global
* setting when the per-event value is NULL, so the global toggle controls
* every gallery that hasn't been deliberately overridden per-event.
*
* Data backfill: NULL out the DEFAULTED `true` rows so they start inheriting
* the global. A deliberate per-gallery hide (`false`) is kept — we can't tell a
* defaulted-true from a chosen-true, but `false` is almost always a conscious
* "hide it here", and nulling it could silently re-show a hidden logo.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasColumn('events', 'hero_logo_visible'))) return;
const client = (knex.client.config.client || '').toLowerCase();
if (client === 'pg' || client === 'postgresql') {
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible DROP DEFAULT');
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible DROP NOT NULL');
} else {
// SQLite (and others): knex recreates the table without the NOT NULL/default.
await knex.schema.alterTable('events', (t) => {
t.boolean('hero_logo_visible').nullable().alter();
});
}
// Existing defaulted-`true` galleries now inherit the global toggle.
await knex('events').where('hero_logo_visible', true).update({ hero_logo_visible: null });
};
exports.down = async function (knex) {
if (!(await knex.schema.hasColumn('events', 'hero_logo_visible'))) return;
// Re-materialise NULLs as the old default (true) before restoring NOT NULL.
await knex('events').whereNull('hero_logo_visible').update({ hero_logo_visible: true });
const client = (knex.client.config.client || '').toLowerCase();
if (client === 'pg' || client === 'postgresql') {
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible SET DEFAULT true');
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible SET NOT NULL');
} else {
await knex.schema.alterTable('events', (t) => {
t.boolean('hero_logo_visible').notNullable().defaultTo(true).alter();
});
}
};
@@ -1,51 +0,0 @@
/**
* Migration 153: make events.hero_logo_size NULL-able so NULL means "inherit
* the global branding_logo_size" (#756 follow-up — the size counterpart of 152).
*
* Before: hero_logo_size was `varchar NOT NULL DEFAULT 'medium'`, snapshotted
* from the global branding_logo_size at creation. The two gallery render paths
* then disagreed — GalleryLayout read the global size live, while the
* hero-header path used the per-event snapshot — so a hero logo could render at
* different sizes on different layouts, and changing the global size didn't
* update hero-header galleries.
*
* After: NULL = inherit. gallery read-resolution falls back to
* branding_logo_size when the per-event value is NULL, and both render paths
* consume that resolved size.
*
* Data backfill: NULL out ALL existing hero_logo_size so every gallery inherits
* the global size going forward. Unlike a boolean we can't tell a defaulted
* value from a chosen one — but nulling is the safe choice here: it restores the
* live-global behaviour GalleryLayout already had, and the per-event size can be
* re-set from the event's edit page.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasColumn('events', 'hero_logo_size'))) return;
const client = (knex.client.config.client || '').toLowerCase();
if (client === 'pg' || client === 'postgresql') {
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size DROP DEFAULT');
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size DROP NOT NULL');
} else {
await knex.schema.alterTable('events', (t) => {
t.string('hero_logo_size', 20).nullable().alter();
});
}
await knex('events').update({ hero_logo_size: null });
};
exports.down = async function (knex) {
if (!(await knex.schema.hasColumn('events', 'hero_logo_size'))) return;
await knex('events').whereNull('hero_logo_size').update({ hero_logo_size: 'medium' });
const client = (knex.client.config.client || '').toLowerCase();
if (client === 'pg' || client === 'postgresql') {
await knex.raw("ALTER TABLE events ALTER COLUMN hero_logo_size SET DEFAULT 'medium'");
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size SET NOT NULL');
} else {
await knex.schema.alterTable('events', (t) => {
t.string('hero_logo_size', 20).notNullable().defaultTo('medium').alter();
});
}
};
@@ -1,52 +0,0 @@
/**
* Messages Phase 2 — additional inbound mailboxes + captured message bodies.
*
* `mail_accounts` holds inbound mailboxes BEYOND the primary accounting IMAP
* that already lives in `email_configs` (e.g. the customer `hello@` mailbox).
* The intake poller (emailIntakeService) polls the accounting mailbox AND every
* enabled row here; customer mail is logged with its body but not routed to the
* accounting inbox.
*
* The new `received_emails` columns capture the parsed message so the Messages
* reading pane can show it: `account_key` tags which mailbox it came from,
* `body_html`/`body_text` hold the (server-sanitized) body, `to_address` the
* envelope recipient. All additive + guarded.
*/
exports.up = async function up(knex) {
const hasAccounts = await knex.schema.hasTable('mail_accounts');
if (!hasAccounts) {
await knex.schema.createTable('mail_accounts', (t) => {
t.increments('id').primary();
t.string('account_key', 64).notNullable().unique(); // e.g. 'customers'
t.string('label', 120);
t.string('imap_host', 255);
t.integer('imap_port').defaultTo(993);
t.boolean('imap_secure').defaultTo(true);
t.string('imap_user', 255);
t.string('imap_pass', 512);
t.string('imap_folder', 255).defaultTo('INBOX');
t.boolean('enabled').defaultTo(false);
t.timestamp('created_at').defaultTo(knex.fn.now());
t.timestamp('updated_at').defaultTo(knex.fn.now());
});
}
const cols = [
['account_key', (t) => t.string('account_key', 64)],
['to_address', (t) => t.string('to_address', 512)],
['body_html', (t) => t.text('body_html')],
['body_text', (t) => t.text('body_text')],
];
for (const [name, add] of cols) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn('received_emails', name);
// eslint-disable-next-line no-await-in-loop
if (!has) await knex.schema.alterTable('received_emails', add);
}
};
exports.down = async function down(knex) {
// Non-destructive on the audit log: leave the added columns in place (they're
// nullable and harmless). Only drop the new table.
await knex.schema.dropTableIfExists('mail_accounts');
};
@@ -1,25 +0,0 @@
/**
* Messages Phase 3 — distinguish human-composed sends from system mail.
*
* `origin` is 'system' for everything the app queues automatically (invoices,
* reminders, gallery notices — the Automated stream) and 'manual' for emails an
* admin composed/edited in the Messages composer (replies + document messages —
* the Customers ▸ Sent stream). Existing rows default to 'system'.
*/
exports.up = async function up(knex) {
const has = await knex.schema.hasColumn('email_queue', 'origin');
if (!has) {
await knex.schema.alterTable('email_queue', (t) => {
t.string('origin', 16).defaultTo('system');
});
}
};
exports.down = async function down(knex) {
const has = await knex.schema.hasColumn('email_queue', 'origin');
if (has) {
await knex.schema.alterTable('email_queue', (t) => {
t.dropColumn('origin');
});
}
};
@@ -1,34 +0,0 @@
/**
* Messages Phase 3 follow-up — outgoing (SMTP) settings per mail account.
*
* The customer mailbox (hello@) needs BOTH incoming (IMAP, migration 154) and
* outgoing (SMTP) config, so replies to customers send from hello@ instead of
* the global no-reply@ identity. All additive/guarded.
*/
exports.up = async function up(knex) {
const cols = [
['smtp_host', (t) => t.string('smtp_host', 255)],
['smtp_port', (t) => t.integer('smtp_port')],
['smtp_secure', (t) => t.boolean('smtp_secure').defaultTo(false)],
['smtp_user', (t) => t.string('smtp_user', 255)],
['smtp_pass', (t) => t.string('smtp_pass', 512)],
['from_email', (t) => t.string('from_email', 255)],
['from_name', (t) => t.string('from_name', 120)],
];
for (const [name, add] of cols) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn('mail_accounts', name);
// eslint-disable-next-line no-await-in-loop
if (!has) await knex.schema.alterTable('mail_accounts', add);
}
};
exports.down = async function down(knex) {
const cols = ['smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_pass', 'from_email', 'from_name'];
for (const name of cols) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn('mail_accounts', name);
// eslint-disable-next-line no-await-in-loop
if (has) await knex.schema.alterTable('mail_accounts', (t) => t.dropColumn(name));
}
};
@@ -1,33 +0,0 @@
/**
* Messages — Archive / Delete (trash) support.
*
* `mailbox_state` on both mail tables: 'active' (normal folders), 'archived'
* (Archived folder), or 'deleted' (Deleted/trash folder). Delete is soft — the
* row moves to 'deleted' and is only removed for good when purged FROM the
* Deleted folder. Legacy rows have NULL, treated as 'active'. Additive/guarded.
*/
exports.up = async function up(knex) {
for (const table of ['email_queue', 'received_emails']) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn(table, 'mailbox_state');
// eslint-disable-next-line no-await-in-loop
if (!has) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable(table, (t) => {
t.string('mailbox_state', 16).defaultTo('active');
});
}
}
};
exports.down = async function down(knex) {
for (const table of ['email_queue', 'received_emails']) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn(table, 'mailbox_state');
// eslint-disable-next-line no-await-in-loop
if (has) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable(table, (t) => { t.dropColumn('mailbox_state'); });
}
}
};
+2 -72
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "3.80.0-beta.0",
"version": "3.74.0-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "3.80.0-beta.0",
"version": "3.74.0-beta.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -40,7 +40,6 @@
"node-cron": "^3.0.2",
"node-stream-zip": "^1.15.0",
"nodemailer": "^9.0.1",
"otplib": "^12.0.1",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
@@ -2704,56 +2703,6 @@
"node": ">=10"
}
},
"node_modules/@otplib/core": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
"license": "MIT"
},
"node_modules/@otplib/plugin-crypto": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1"
}
},
"node_modules/@otplib/plugin-thirty-two": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"thirty-two": "^1.0.2"
}
},
"node_modules/@otplib/preset-default": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@otplib/preset-v11": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
@@ -9422,17 +9371,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/otplib": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/preset-default": "^12.0.1",
"@otplib/preset-v11": "^12.0.1"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -11730,14 +11668,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/thirty-two": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
"engines": {
"node": ">=0.2.6"
}
},
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
+4 -5
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.45.3",
"version": "3.79.1-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -46,11 +46,9 @@
"node-cron": "^3.0.2",
"node-stream-zip": "^1.15.0",
"nodemailer": "^9.0.1",
"otplib": "^12.0.1",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
"postcss": "8.5.10",
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
@@ -59,10 +57,11 @@
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"swissqrbill": "^4.3.0",
"tar": ">=7.5.16",
"uuid": "^11.1.1",
"winston": "^3.8.2",
"zxcvbn": "^4.4.2"
"zxcvbn": "^4.4.2",
"postcss": "8.5.10",
"tar": ">=7.5.16"
},
"devDependencies": {
"eslint": "^8.40.0",
-109
View File
@@ -1,109 +0,0 @@
#!/usr/bin/env node
/**
* reset-admin-mfa.js — disable two-factor auth for a locked-out admin (#738).
*
* Break-glass recovery for when an admin loses their authenticator AND their
* recovery codes. Clears the MFA state so the admin can log in with just their
* password and re-enroll from Settings.
*
* Usage (inside the running backend container):
* docker compose exec backend node scripts/reset-admin-mfa.js --email admin@example.com
* docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
*
* Flags:
* --email <addr> target a single admin by email (or --username <name>)
* --all reset MFA for EVERY admin (full lockout / break-glass)
* --yes non-interactive (skip the confirmation prompt)
*/
const readline = require('readline');
const { db, logActivity } = require('../src/database/db');
const args = process.argv.slice(2);
const hasFlag = (f) => args.includes(f);
const getOption = (name) => {
const i = args.indexOf(`--${name}`);
return i !== -1 && i + 1 < args.length ? args[i + 1] : null;
};
const force = hasFlag('--yes') || hasFlag('--force') || hasFlag('--non-interactive');
const all = hasFlag('--all');
const email = getOption('email');
const username = getOption('username');
const MFA_CLEAR = {
two_factor_enabled: false,
two_factor_secret: null,
two_factor_recovery_codes: null,
two_factor_enrolled_at: null,
updated_at: new Date(),
};
function ask(prompt) {
if (force) return Promise.resolve('yes');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => rl.question(prompt, (a) => { rl.close(); resolve(a); }));
}
async function main() {
console.log('\n========================================');
console.log('PicPeak Admin MFA Reset Tool');
console.log('========================================\n');
if (!all && !email && !username) {
console.error('❌ Specify a target: --email <addr>, --username <name>, or --all');
console.log(' e.g. node scripts/reset-admin-mfa.js --email admin@example.com');
process.exit(1);
}
// Resolve target admins.
let targets;
if (all) {
targets = await db('admin_users').select('id', 'username', 'email', 'two_factor_enabled');
} else {
const q = db('admin_users');
if (email) q.where({ email });
if (username) q.where({ username });
targets = await q.select('id', 'username', 'email', 'two_factor_enabled');
}
if (targets.length === 0) {
console.error('❌ No matching admin user found.');
process.exit(1);
}
const enrolled = targets.filter((t) => t.two_factor_enabled === true || t.two_factor_enabled === 1);
console.log(`Matched ${targets.length} admin(s); ${enrolled.length} currently have MFA enabled:`);
for (const t of targets) {
const flag = (t.two_factor_enabled === true || t.two_factor_enabled === 1) ? 'MFA ON' : 'mfa off';
console.log(` - ${t.username} <${t.email}> [${flag}]`);
}
const confirm = await ask('\nDisable MFA for the above? (yes/no): ');
const normalized = String(confirm).trim().toLowerCase();
if (normalized !== 'yes' && normalized !== 'y') {
console.log('\n❌ Cancelled. No changes made.');
process.exit(0);
}
const ids = targets.map((t) => t.id);
const updated = await db('admin_users').whereIn('id', ids).update(MFA_CLEAR);
for (const t of targets) {
try {
await logActivity('admin_mfa_reset_cli',
{ admin_id: t.id, via: 'cli' },
null,
{ type: 'system', id: 0, name: 'reset-admin-mfa.js' }
);
} catch (_) { /* activity log is best-effort */ }
}
console.log(`\n✅ MFA disabled for ${updated} admin(s). They can now log in with just their password and re-enroll from Settings → Security.`);
process.exit(0);
}
main().catch((err) => {
console.error('❌ Failed to reset MFA:', err.message);
process.exit(1);
});
+3 -1
View File
@@ -38,6 +38,7 @@ const {
// Import routes
const authRoutes = require('./src/routes/auth');
const eventRoutes = require('./src/routes/events');
const galleryRoutes = require('./src/routes/gallery');
const adminRoutes = require('./src/routes/admin');
const adminAuthRoutes = require('./src/routes/adminAuth');
@@ -694,7 +695,8 @@ app.get('/health', async (req, res) => {
// Routes
app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup)
app.use('/api/auth', authRoutes);
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
app.use('/api/events', eventRoutes);
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
// Gallery routes - main routes first, then feedback routes
app.use('/api/gallery', galleryRoutes);
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
@@ -343,34 +343,12 @@ describe('isSocialCrawler — extended bot coverage (#521)', () => {
// 3rd-party preview services used by business-messaging stacks
'LinkPreview/1.0',
'Slack-ImgProxy/1.0',
// Viber + broader crawler set (#699 follow-up)
'Mozilla/5.0 (compatible; Viber)',
'Mozilla/5.0 (compatible; Bluesky Cardyb/1.1)',
'facebookcatalog/1.0',
'kakaotalk-scrap/1.0',
'Mozilla/5.0 (compatible; Synapse/1.98)',
'Rocket.Chat/6.0',
];
for (const ua of knownBots) {
expect(isSocialCrawler(ua)).toBe(true);
}
});
it('does NOT match human in-app-browser UAs (our OG response is meta-only, no redirect)', () => {
// These share a token with a preview bot but are also sent by real users
// browsing inside the app's webview — matching them would serve a human
// the bare OG stub. Deliberately excluded; guard against re-adding them.
const inAppBrowsers = [
'Mozilla/5.0 (iPhone) AppleWebKit MicroMessenger/8.0.0', // WeChat in-app
'Mozilla/5.0 (iPhone) AppleWebKit Line/13.0.0', // LINE in-app
'Mozilla/5.0 (Linux; Android) Zalo', // Zalo in-app
'Mozilla/5.0 (Macintosh) Chrome/120.0 Safari/537.36 boxing', // "XING" substring trap
];
for (const ua of inAppBrowsers) {
expect(isSocialCrawler(ua)).toBe(false);
}
});
it('does not match a regular browser UA', () => {
const browsers = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
@@ -101,7 +101,6 @@ describe('verifyGalleryAccess — customer-minted JWT with active assignment', (
it('allows access when the event_customer_assignments row exists', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery',
eventId: 42,
via: 'customer',
customerId: 7,
@@ -132,7 +131,6 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
it('returns 403 CUSTOMER_ASSIGNMENT_REVOKED when the junction row is gone', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery',
eventId: 42,
via: 'customer',
customerId: 7,
@@ -162,7 +160,6 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
// and start 403'ing per-event-password sessions.
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery',
eventId: 42,
customerId: 7,
// intentionally no `via` claim
@@ -194,7 +191,6 @@ describe('verifyGalleryAccess — per-event-password JWT', () => {
it('does NOT touch event_customer_assignments and passes through', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({
type: 'gallery',
eventId: 42,
// No via, no customerId — this is the legacy per-event-password
// flow where every guest mints their own JWT after entering the
+1 -3
View File
@@ -18,7 +18,6 @@ async function adminAuth(req, res, next) {
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'picpeak-auth',
complete: true
});
@@ -141,7 +140,6 @@ async function galleryAuth(req, res, next) {
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'picpeak-auth',
complete: true
});
@@ -211,7 +209,7 @@ async function photoAuth(req, res, next) {
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
-1
View File
@@ -34,7 +34,6 @@ async function customerAuth(req, res, next) {
let decoded;
try {
const verified = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'picpeak-auth',
complete: true,
});
+2 -13
View File
@@ -66,29 +66,18 @@ async function verifyGalleryAccess(req, res, next) {
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'picpeak-auth'
});
} catch (error) {
// If verification fails with issuer, try without issuer (backward compatibility)
if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) {
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
decoded = jwt.verify(token, process.env.JWT_SECRET);
} else {
throw error;
}
}
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
// Only gallery-scoped tokens grant gallery access. Every legitimate
// path (password login, share link, client access, customer-minted,
// slideshow) mints type:'gallery'. Reject anything else — e.g. a guest
// identity token (type:'guest', for feedback attribution) that carries a
// matching eventId — instead of relying on other token types incidentally
// lacking an eventId to fail the id match below.
if (decoded.type !== 'gallery') {
return res.status(403).json({ error: 'Invalid token type for gallery access' });
}
// If we have a slug in the URL params or from pre-middleware, verify it matches
if (requestedSlug) {
// Verify by slug and ensure it matches the token's event
-1
View File
@@ -23,7 +23,6 @@ async function resolveGuest(req, res, next) {
let decoded;
try {
const verified = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'picpeak-auth',
complete: true,
});
+1 -33
View File
@@ -32,36 +32,4 @@ function requireEventOwnership(req, res, next) {
});
}
/**
* Return the subset of `eventIds` the admin may act on, mirroring
* requireEventOwnership for bulk routes that can't use it (they take an
* array in the body, not an :id param). super_admin gets everything;
* other roles get events they created plus ownerless legacy/system
* events (created_by IS NULL). Ids that are foreign OR non-existent both
* land in `denied` — deliberately indistinguishable, so bulk routes
* don't become an ownership/existence oracle.
*
* @returns {Promise<{allowed: Array, denied: Array}>}
*/
async function filterOwnedEventIds(admin, eventIds) {
if (admin.roleName === 'super_admin') {
return { allowed: [...eventIds], denied: [] };
}
const rows = await db('events')
.whereIn('id', eventIds)
.andWhere((q) => q.whereNull('created_by').orWhere('created_by', admin.id))
.select('id');
const allowedSet = new Set(rows.map((r) => r.id));
const allowed = [];
const denied = [];
for (const id of eventIds) {
if (allowedSet.has(id) || allowedSet.has(Number(id))) {
allowed.push(id);
} else {
denied.push(id);
}
}
return { allowed, denied };
}
module.exports = { requireEventOwnership, filterOwnedEventIds };
module.exports = { requireEventOwnership };
+12 -25
View File
@@ -28,13 +28,12 @@ async function photoAuth(req, res, next) {
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'picpeak-auth'
});
} catch (issuerError) {
// If verification fails with issuer, try without issuer (backward compatibility)
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
decoded = jwt.verify(token, process.env.JWT_SECRET);
} else {
throw issuerError;
}
@@ -44,36 +43,24 @@ async function photoAuth(req, res, next) {
if (decoded.type === 'gallery') {
// For thumbnails, we need to verify the token is for a valid event
if (!eventSlug) {
// Resolve the token's event (by id, or legacy slug fallback)...
let event = null;
// Extract event ID from the decoded token
if (decoded.eventId) {
event = await db('events')
const event = await db('events')
.where({ id: decoded.eventId, is_active: formatBoolean(true) })
.first();
}
if (!event && decoded.eventSlug) {
event = await db('events')
.where({ slug: decoded.eventSlug, is_active: formatBoolean(true) })
.first();
}
// ...then confirm the REQUESTED thumbnail actually belongs to
// that event. Thumbnails are stored flat (thumbnails/thumb_<name>)
// with deterministic, enumerable filenames derived from the
// public event name + a sequential counter. Without this
// ownership check any holder of a gallery token for any event
// could enumerate and fetch another (password-protected) event's
// entire thumbnail set, defeating the gallery password. A
// traversal or foreign filename simply fails to match → denied.
if (event) {
const requestedKey = `thumbnails${req.path}`;
const ownsThumbnail = await db('photos')
.where({ event_id: event.id, thumbnail_path: requestedKey })
.first();
if (ownsThumbnail) {
if (event) {
req.event = event;
return next();
}
}
// Fallback to slug
const event = await db('events')
.where({ slug: decoded.eventSlug, is_active: formatBoolean(true) })
.first();
if (event) {
req.event = event;
return next();
}
}
// For regular photos, check if token matches the event
else if (decoded.eventSlug === eventSlug) {
+2 -2
View File
@@ -87,7 +87,7 @@ async function sessionTimeoutMiddleware(req, res, next) {
try {
// Verify token is valid
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Check if this is an admin token
if (!decoded.id) {
@@ -128,7 +128,7 @@ async function sessionTimeoutMiddleware(req, res, next) {
for (const [oldToken, _] of sessions.entries()) {
if (oldToken !== token) {
try {
const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET, { algorithms: ['HS256'] });
const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET);
if (oldDecoded.id === userId) {
sessions.delete(oldToken);
}
-11
View File
@@ -9,7 +9,6 @@ const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver');
const StreamZip = require('node-stream-zip');
const { requireEventOwnership } = require('../middleware/ownership');
const { assertZipEntriesWithin } = require('../utils/safePath');
const logger = require('../utils/logger');
const { getPagination } = require('../utils/routeHelpers');
const router = express.Router();
@@ -184,16 +183,6 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
const entries = Object.values(await zip.entries());
logger.info(`Archive contains ${entries.length} entries`);
// Reject ZIP-slip entries before writing anything to disk — extract()
// does not neutralise `../` in entry names (GHSA-jfhw-fj23-fx6x).
try {
assertZipEntriesWithin(entries, eventDir);
} catch (slipErr) {
await zip.close();
logger.warn(`Refusing archive restore — unsafe entry path: ${slipErr.message}`);
return res.status(400).json({ error: 'Archive contains invalid entry paths' });
}
// Stream-extract everything to disk
await zip.extract(null, eventDir);
await zip.close();
-172
View File
@@ -10,7 +10,6 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
const { setAdminAuthCookie } = require('../utils/tokenUtils');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
const mfaService = require('../services/mfaService');
const router = express.Router();
// Get admin profile
@@ -185,175 +184,4 @@ router.post('/logout', adminAuth, handleAsync(async (req, res) => {
successResponse(res, { message: 'Logged out successfully' });
}));
// ---------------------------------------------------------------------------
// Multi-factor authentication (TOTP) — issue #738.
//
// All endpoints operate on the AUTHENTICATED admin's own account
// (req.admin.id) — enrollment is per-user and works for every role,
// super_admin included (closes #735). The TOTP secret is stored encrypted
// at rest and recovery codes are hashed; see services/mfaService.js.
// ---------------------------------------------------------------------------
const isMfaEnabled = mfaService.isEnrolled;
// Current MFA state for the logged-in admin.
router.get('/mfa/status', adminAuth, handleAsync(async (req, res) => {
const admin = await db('admin_users').where('id', req.admin.id).first();
if (!admin) throw new NotFoundError('Admin user');
const enabled = isMfaEnabled(admin);
res.json({
enabled,
enrolledAt: enabled ? admin.two_factor_enrolled_at || null : null,
recoveryCodesRemaining: enabled
? mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes).length
: 0
});
}));
// Begin enrollment: mint a provisional secret, store it encrypted (NOT yet
// enabled), and return the otpauth URI + QR for the authenticator app. Calling
// this again before /enable simply regenerates the provisional secret.
router.post('/mfa/setup', adminAuth, handleAsync(async (req, res) => {
const admin = await db('admin_users').where('id', req.admin.id).first();
if (!admin) throw new NotFoundError('Admin user');
if (isMfaEnabled(admin)) {
throw new ConflictError('Two-factor authentication is already enabled');
}
const secret = mfaService.generateSecret();
await db('admin_users').where('id', admin.id).update({
two_factor_secret: mfaService.encryptSecret(secret),
two_factor_enabled: false,
two_factor_recovery_codes: null,
two_factor_enrolled_at: null,
updated_at: new Date()
});
const accountName = admin.email || admin.username;
const otpauthUri = mfaService.buildOtpauthUri(accountName, secret);
const qr = await mfaService.buildQrDataUrl(otpauthUri);
res.json({
// `secret` is returned for manual entry when a QR can't be scanned.
secret,
otpauthUri,
qr,
issuer: mfaService.ISSUER,
account: accountName
});
}));
// Complete enrollment: verify a code against the provisional secret, enable
// MFA, and return one-time recovery codes (shown exactly once).
router.post('/mfa/enable', [
adminAuth,
body('code').notEmpty().withMessage('Verification code is required')
], handleAsync(async (req, res) => {
validateRequest(req);
const admin = await db('admin_users').where('id', req.admin.id).first();
if (!admin) throw new NotFoundError('Admin user');
if (isMfaEnabled(admin)) {
throw new ConflictError('Two-factor authentication is already enabled');
}
if (!admin.two_factor_secret) {
throw new ValidationError('Start setup before enabling two-factor authentication');
}
if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) {
throw new ValidationError('Invalid verification code');
}
const { plain, hashed } = await mfaService.generateRecoveryCodes();
await db('admin_users').where('id', admin.id).update({
two_factor_enabled: true,
two_factor_enrolled_at: new Date(),
two_factor_recovery_codes: JSON.stringify(hashed),
updated_at: new Date()
});
await logActivity('admin_mfa_enabled',
{ admin_id: admin.id },
null,
{ type: 'admin', id: admin.id, name: admin.username }
);
successResponse(res, {
message: 'Two-factor authentication enabled',
recoveryCodes: plain
});
}));
// Disable MFA. Requires a fresh TOTP or recovery code so a hijacked session
// can't silently strip the second factor.
router.post('/mfa/disable', [
adminAuth,
body('code').notEmpty().withMessage('A current code is required to disable 2FA')
], handleAsync(async (req, res) => {
validateRequest(req);
const admin = await db('admin_users').where('id', req.admin.id).first();
if (!admin) throw new NotFoundError('Admin user');
if (!isMfaEnabled(admin)) {
throw new ValidationError('Two-factor authentication is not enabled');
}
const totpOk = mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret);
let recoveryOk = false;
if (!totpOk) {
const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes);
recoveryOk = (await mfaService.consumeRecoveryCode(req.body.code, stored)).matched;
}
if (!totpOk && !recoveryOk) {
throw new ValidationError('Invalid verification code');
}
await db('admin_users').where('id', admin.id).update({
two_factor_enabled: false,
two_factor_secret: null,
two_factor_recovery_codes: null,
two_factor_enrolled_at: null,
updated_at: new Date()
});
await logActivity('admin_mfa_disabled',
{ admin_id: admin.id },
null,
{ type: 'admin', id: admin.id, name: admin.username }
);
successResponse(res, { message: 'Two-factor authentication disabled' });
}));
// Regenerate recovery codes (invalidates the old set). Requires a fresh TOTP
// code. Returns the new codes once.
router.post('/mfa/recovery-codes', [
adminAuth,
body('code').notEmpty().withMessage('A current authenticator code is required')
], handleAsync(async (req, res) => {
validateRequest(req);
const admin = await db('admin_users').where('id', req.admin.id).first();
if (!admin) throw new NotFoundError('Admin user');
if (!isMfaEnabled(admin)) {
throw new ValidationError('Two-factor authentication is not enabled');
}
if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) {
throw new ValidationError('Invalid verification code');
}
const { plain, hashed } = await mfaService.generateRecoveryCodes();
await db('admin_users').where('id', admin.id).update({
two_factor_recovery_codes: JSON.stringify(hashed),
updated_at: new Date()
});
await logActivity('admin_mfa_recovery_regenerated',
{ admin_id: admin.id },
null,
{ type: 'admin', id: admin.id, name: admin.username }
);
successResponse(res, {
message: 'Recovery codes regenerated',
recoveryCodes: plain
});
}));
module.exports = router;
-98
View File
@@ -2,8 +2,6 @@ const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { clearAdminAuthCookie } = require('../utils/tokenUtils');
const { revokeToken } = require('../utils/tokenRevocation');
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
const logger = require('../utils/logger');
const { errorResponse, getPagination } = require('../utils/routeHelpers');
@@ -131,102 +129,6 @@ router.post('/run', adminAuth, requirePermission('backup.create'), async (req, r
}
});
// Generate + download a portable ".picpeak" export — an engine-neutral logical
// snapshot (DB rows as NDJSON + PDFs/business-docs) that can be re-uploaded to
// another instance via the web UI. `?includePhotos=true` also bundles original
// gallery photos (larger); otherwise the admin re-uploads them per gallery.
//
// SECURITY: the file contains plaintext secrets (SMTP password, admin password
// hashes, API keys). The download UI must warn before offering it. We surface
// the flag as a response header too so the client can double-confirm.
router.get('/picpeak/export', adminAuth, requirePermission('backup.create'), async (req, res) => {
const fsSync = require('fs');
try {
const includePhotos = req.query.includePhotos === 'true' || req.query.includePhotos === '1';
const { createPicpeak } = require('../services/picpeakExportService');
const { filePath } = await createPicpeak({ includePhotos });
const filename = path.basename(filePath);
res.setHeader('X-Picpeak-Contains-Secrets', 'true');
res.download(filePath, filename, (err) => {
// Best-effort cleanup of the temp .picpeak (and its temp dir) after send.
fsSync.rm(path.dirname(filePath), { recursive: true, force: true }, () => {});
if (err) logger.error('[picpeak-export] download failed', { error: err.message });
});
} catch (error) {
logger.error('[picpeak-export] failed to create export', { error: error.message });
if (!res.headersSent) res.status(500).json({ error: 'Failed to create .picpeak export' });
}
});
// Multipart upload for .picpeak restore — streamed to a temp file. Runs AFTER
// auth so an unauthenticated request can't push a large file to disk.
const os = require('os');
const multer = require('multer');
const picpeakUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, os.tmpdir()),
filename: (req, file, cb) => cb(null, `picpeak-upload-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.picpeak`),
}),
limits: { fileSize: 5 * 1024 * 1024 * 1024 }, // 5 GB — .picpeak with photos can be large
});
// Upload + restore a .picpeak onto THIS instance. DESTRUCTIVE: full override of
// all data except the current logged-in account (the client shows an explicit
// confirmation before calling this). Returns `usesExternalMedia` so the UI can
// prompt the admin to reconfigure the external-media mount afterwards.
router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), picpeakUpload.single('backup'), async (req, res) => {
const fsSync = require('fs');
if (!req.file) return res.status(400).json({ error: 'No backup file uploaded' });
const picpeakPath = req.file.path;
try {
const { importFromPicpeak } = require('../services/picpeakImportService');
// adminAuth populates req.admin, not req.user. Passing req.user.id here
// left currentAdminId undefined, so reinjectCurrentAdmin() had no account
// to preserve and the admin_users table was fully replaced by the backup —
// letting a crafted .picpeak take over every admin account (GHSA-qxfx-4493-4v8f).
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.admin && req.admin.id });
// The restore rewrote admin_users, so ids may have shifted. The operator's
// current JWT is bound only to the pre-restore admin id (adminAuth trusts
// `decoded.id` — IP is logged, not enforced, and the backup controls
// password_changed_at), which could now resolve to a DIFFERENT restored
// account and silently grant its permissions. Force a fresh login instead
// of trusting the old session: revoke the token and clear the cookie.
// Clearing the cookie is the guarantee — it drops the operator's browser
// session unconditionally. Revocation is the extra layer that also kills a
// Bearer-header copy of the JWT; revokeToken() swallows DB errors and
// returns false, so check the result and log loudly if the denylist write
// didn't land (the operator should still re-login, which the cookie clear
// forces).
let tokenRevoked = false;
try {
if (req.token) {
tokenRevoked = await revokeToken(req.token, 'picpeak-import', { adminId: req.admin && req.admin.id });
}
} catch (revokeErr) {
logger.warn('[picpeak-import] failed to revoke session token after restore', { error: revokeErr.message });
}
if (req.token && !tokenRevoked) {
logger.warn('[picpeak-import] session token was NOT added to the revocation denylist after restore; relying on cookie clear to force re-login');
}
clearAdminAuthCookie(res);
res.json({
success: true,
tables: result.tables,
filesRestored: result.filesRestored,
usesExternalMedia: result.usesExternalMedia,
sessionInvalidated: true,
});
} catch (error) {
const status = error.statusCode || 500;
logger.error('[picpeak-import] restore failed', { error: error.message });
res.status(status).json({ error: error.message || 'Restore failed', validation: error.validation });
} finally {
fsSync.unlink(picpeakPath, () => {});
}
});
// Get backup run details
router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
+3 -303
View File
@@ -4,10 +4,6 @@ const { body, query, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
// Gate the NEW Messages routes on the `messaging` flag (per-route, NOT the whole
// /email mount — the pre-existing config/queue/received endpoints stay ungated).
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
const messagingGate = requireFeatureFlag('messaging');
const { wrapEmailHtml, processEmailQueue } = require('../services/emailProcessor');
const { errorResponse } = require('../utils/routeHelpers');
const logger = require('../utils/logger');
@@ -264,196 +260,16 @@ router.get('/received', adminAuth, requirePermission('email.view'), async (req,
try {
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize, 10) || 25));
const account = req.query.account ? String(req.query.account) : null;
// mailbox_state filter: no param → active (+ legacy NULL); else exact.
const state = ['archived', 'deleted'].includes(String(req.query.state)) ? String(req.query.state) : 'active';
// Optional full-table search (sender / subject) so results aren't truncated
// to the first page before matching.
const q = req.query.q ? String(req.query.q).trim().slice(0, 255) : '';
// 'accounting' matches legacy rows too (account_key was NULL before mig 154).
const applyAccount = (qb) => {
if (account === 'accounting') qb.where((b) => b.where('account_key', 'accounting').orWhereNull('account_key'));
else if (account) qb.where('account_key', account);
if (state === 'active') qb.where((b) => b.where('mailbox_state', 'active').orWhereNull('mailbox_state'));
else qb.where('mailbox_state', state);
if (q) qb.where((b) => b.where('from_address', 'like', `%${q}%`).orWhere('subject', 'like', `%${q}%`));
return qb;
};
const countRow = await applyAccount(db('received_emails')).count({ c: '*' }).first();
const base = db('received_emails');
const countRow = await base.clone().count({ c: '*' }).first();
const total = parseInt(countRow?.c || 0, 10);
// Bodies are excluded from the list (can be large); fetched per-message.
const items = await applyAccount(db('received_emails'))
.select('id', 'message_id', 'account_key', 'from_address', 'to_address', 'subject',
'received_at', 'attachment_count', 'status', 'inbound_document_id', 'error')
.orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize);
const items = await base.clone().orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize);
res.json({ items, pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) } });
} catch (error) {
errorResponse(res, error, 500, 'Failed to fetch received emails');
}
});
// Single received email WITH its captured (server-sanitized) body — Messages
// reading pane. body_html was already sanitized on ingest; the viewer renders
// it in a script-less sandboxed iframe as well.
router.get('/received/:id', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
const row = await db('received_emails').where({ id }).first();
if (!row) return res.status(404).json({ error: 'Email not found' });
res.json(row);
} catch (error) {
errorResponse(res, error, 500, 'Failed to fetch email');
}
});
// Move an email between mailbox states: Archive / Delete (soft) or Restore
// (back to active). kind = 'queue' | 'received'. Delete is a soft move to the
// trash; the row is only removed for good by the DELETE handler below.
router.post('/item/:kind/:id/state', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
try {
const table = req.params.kind === 'received' ? 'received_emails' : req.params.kind === 'queue' ? 'email_queue' : null;
if (!table) return res.status(400).json({ error: 'Invalid kind' });
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
const state = String(req.body?.state || '');
if (!['active', 'archived', 'deleted'].includes(state)) return res.status(400).json({ error: 'Invalid state' });
const n = await db(table).where({ id }).update({ mailbox_state: state });
if (!n) return res.status(404).json({ error: 'Not found' });
res.json({ ok: true });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update email');
}
});
// Permanently delete an email row — only offered from the Deleted folder.
router.delete('/item/:kind/:id', adminAuth, messagingGate, requirePermission('email.edit'), async (req, res) => {
try {
const table = req.params.kind === 'received' ? 'received_emails' : req.params.kind === 'queue' ? 'email_queue' : null;
if (!table) return res.status(400).json({ error: 'Invalid kind' });
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
await db(table).where({ id }).del();
res.json({ ok: true });
} catch (error) {
errorResponse(res, error, 500, 'Failed to delete email');
}
});
// Additional inbound mailboxes (beyond the primary accounting IMAP in
// email_configs) — e.g. the customer hello@ box. Passwords are masked out.
router.get('/accounts', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
try {
const rows = await db('mail_accounts').orderBy('id');
res.json({ items: rows.map((a) => ({
...a,
imap_pass: a.imap_pass ? '********' : '',
smtp_pass: a.smtp_pass ? '********' : '',
})) });
} catch (error) {
errorResponse(res, error, 500, 'Failed to load mail accounts');
}
});
// Resolved sender/mailbox addresses for the Messages UI — so the sidebar shows
// the REAL configured addresses instead of hardcoded placeholders. Accounting =
// the primary IMAP login (rechnungen@); customers = the hello@ mailbox; the
// automated stream sends from the global SMTP from-address.
router.get('/identities', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
try {
const cfg = await db('email_configs').first();
let customers = null;
try {
const cust = await db('mail_accounts').where({ account_key: 'customers' }).first();
customers = cust?.imap_user || cust?.from_email || null;
} catch (_) { customers = null; }
res.json({
automated: cfg?.from_email || null,
accounting: cfg?.imap_user || null,
customers,
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to load mail identities');
}
});
// Upsert a mailbox by account_key. A masked password ('********') keeps the
// stored value so the admin never has to re-type it.
router.post('/accounts', adminAuth, messagingGate, requirePermission('email.edit'), async (req, res) => {
try {
const b = req.body || {};
if (!b.account_key) return res.status(400).json({ error: 'account_key is required' });
// SSRF guard — mirror /config + /incoming-config: neither the IMAP nor the
// SMTP host may point at a private/internal address.
const { isPrivateIP } = require('../utils/networkValidation');
if (b.imap_host && isPrivateIP(b.imap_host)) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
if (b.smtp_host && isPrivateIP(b.smtp_host)) {
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
}
const patch = {
label: b.label || null,
imap_host: b.imap_host || null,
imap_port: b.imap_port ? parseInt(b.imap_port, 10) : 993,
imap_secure: b.imap_secure !== false,
imap_user: b.imap_user || null,
imap_folder: b.imap_folder || 'INBOX',
// Outgoing (SMTP) identity — replies from this mailbox send from here.
smtp_host: b.smtp_host || null,
smtp_port: b.smtp_port ? parseInt(b.smtp_port, 10) : 587,
smtp_secure: b.smtp_secure === true,
smtp_user: b.smtp_user || null,
from_email: b.from_email || null,
from_name: b.from_name || null,
enabled: !!b.enabled,
updated_at: new Date(),
};
if (b.imap_pass && b.imap_pass !== '********') patch.imap_pass = b.imap_pass;
if (b.smtp_pass && b.smtp_pass !== '********') patch.smtp_pass = b.smtp_pass;
const existing = await db('mail_accounts').where({ account_key: b.account_key }).first();
if (existing) {
await db('mail_accounts').where({ account_key: b.account_key }).update(patch);
} else {
await db('mail_accounts').insert({
account_key: b.account_key,
imap_pass: (b.imap_pass && b.imap_pass !== '********') ? b.imap_pass : '',
smtp_pass: (b.smtp_pass && b.smtp_pass !== '********') ? b.smtp_pass : '',
created_at: new Date(),
...patch,
});
}
res.json({ ok: true });
} catch (error) {
errorResponse(res, error, 500, 'Failed to save mail account');
}
});
// Test an inbound mailbox's IMAP connection (before or after saving). Resolves
// a masked/blank password from the stored row for the given account_key.
router.post('/accounts/test', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
try {
const b = req.body || {};
const { isPrivateIP } = require('../utils/networkValidation');
if (b.imap_host && isPrivateIP(b.imap_host)) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
let pass = b.imap_pass;
if ((!pass || pass === '********') && b.account_key) {
const stored = await db('mail_accounts').where({ account_key: b.account_key }).first();
pass = stored?.imap_pass || '';
}
const emailIntakeService = require('../services/emailIntakeService');
const result = await emailIntakeService.testConnection({
host: b.imap_host, port: b.imap_port, secure: b.imap_secure,
user: b.imap_user, pass, folder: b.imap_folder || 'INBOX',
});
res.json(result);
} catch (error) {
res.status(422).json({ ok: false, error: `Mailbox test failed (${error.message}).` });
}
});
// Test email configuration
router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => {
try {
@@ -622,8 +438,6 @@ router.post('/flush-queue', adminAuth, requirePermission('email.send'), async (r
router.get('/queue', adminAuth, requirePermission('email.view'), [
query('status').optional({ values: 'falsy' }).isIn(['pending', 'sent', 'failed']),
query('emailType').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
query('origin').optional({ values: 'falsy' }).isIn(['system', 'manual']),
query('state').optional({ values: 'falsy' }).isIn(['active', 'archived', 'deleted']),
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
query('from').optional({ values: 'falsy' }).isISO8601(),
query('to').optional({ values: 'falsy' }).isISO8601(),
@@ -642,13 +456,6 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
const applyFilters = (qb) => {
if (req.query.status) qb.where('email_queue.status', req.query.status);
if (req.query.emailType) qb.where('email_queue.email_type', req.query.emailType);
// 'system' includes legacy rows (origin was NULL before migration 155).
if (req.query.origin === 'manual') qb.where('email_queue.origin', 'manual');
else if (req.query.origin === 'system') qb.where((b) => b.where('email_queue.origin', 'system').orWhereNull('email_queue.origin'));
// mailbox_state: default active (+ legacy NULL); Archived/Deleted folders pass it explicitly.
const st = ['archived', 'deleted'].includes(String(req.query.state)) ? String(req.query.state) : 'active';
if (st === 'active') qb.where((b) => b.where('email_queue.mailbox_state', 'active').orWhereNull('email_queue.mailbox_state'));
else qb.where('email_queue.mailbox_state', st);
if (req.query.from) qb.where('email_queue.created_at', '>=', new Date(req.query.from));
if (req.query.to) qb.where('email_queue.created_at', '<=', new Date(req.query.to));
if (req.query.q) {
@@ -677,7 +484,6 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
'email_queue.sent_at',
'email_queue.error_message',
'email_queue.retry_count',
'email_queue.origin',
'email_queue.event_id',
'events.event_name as event_name',
'events.slug as event_slug'
@@ -697,7 +503,6 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
sentAt: r.sent_at,
errorMessage: r.error_message,
retryCount: r.retry_count,
origin: r.origin || 'system',
eventId: r.event_id,
eventName: r.event_name || null,
eventSlug: r.event_slug || null,
@@ -713,111 +518,6 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
}
});
// Single queued/sent email WITH its rendered body — powers the Messages
// reading pane. `rendered_html` is the exact HTML that was sent (migration
// 119); rows sent before that migration have none. Attachment disk paths in
// `email_data` are never exposed — only the filenames, so the pane can list
// attachments without leaking storage paths (same PII posture as the list).
router.get('/queue/:id', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
const row = await db('email_queue')
.leftJoin('events', 'events.id', 'email_queue.event_id')
.select('email_queue.*', 'events.event_name as event_name', 'events.slug as event_slug')
.where('email_queue.id', id)
.first();
if (!row) return res.status(404).json({ error: 'Email not found' });
let cc = null;
let attachments = [];
try {
const data = row.email_data ? JSON.parse(row.email_data) : {};
if (data.cc) cc = Array.isArray(data.cc) ? data.cc.join(', ') : String(data.cc);
if (Array.isArray(data.attachments)) {
attachments = data.attachments
.filter((a) => a && a.filename)
.map((a) => ({ filename: a.filename, contentType: a.contentType || null }));
}
} catch (_) { /* malformed email_data → no cc/attachments, still return the body */ }
res.json({
id: row.id,
recipientEmail: row.recipient_email,
emailType: row.email_type,
status: row.status,
createdAt: row.created_at,
scheduledAt: row.scheduled_at,
sentAt: row.sent_at,
errorMessage: row.error_message,
retryCount: row.retry_count,
eventId: row.event_id,
eventName: row.event_name || null,
eventSlug: row.event_slug || null,
renderedHtml: row.rendered_html || null,
cc,
attachments,
});
} catch (error) {
logger.error('Get email queue item error:', error);
res.status(500).json({ error: 'Failed to load email', details: error.message });
}
});
// Send a human-composed email from the Messages composer. The admin already
// edited the body (reply or document message), so it is sent as-is — no
// template render — after a sanitize pass. Recorded in email_queue as a
// 'manual' send so it surfaces under Customers > Sent.
router.post('/send', adminAuth, messagingGate, requirePermission('email.send'), async (req, res) => {
try {
const b = req.body || {};
const to = String(b.to || '').trim();
const subject = String(b.subject || '').trim();
if (!to || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) {
return res.status(400).json({ error: 'A valid recipient email is required.' });
}
if (!subject) return res.status(400).json({ error: 'A subject is required.' });
const sanitizeHtml = require('sanitize-html');
// Match the stricter inbound sanitizeBody allowlist: no <style> tag, no
// data: scheme — inline style/class attributes are enough for composed mail.
const html = sanitizeHtml(String(b.html || ''), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt', 'width', 'height'],
'*': ['style', 'class'],
},
allowedSchemes: ['http', 'https', 'mailto', 'cid'],
});
const cc = b.cc ? String(b.cc).trim() : null;
const accountKey = b.accountKey ? String(b.accountKey) : undefined;
const emailProcessor = require('../services/emailProcessor');
const result = await emailProcessor.sendRawEmail({ to, cc, subject, html, accountKey });
await db('email_queue').insert({
recipient_email: to,
email_type: 'manual_message',
email_data: JSON.stringify({
subject,
cc: cc || undefined,
replyToReceivedId: b.replyToReceivedId || undefined,
messageId: result.messageId,
}),
status: 'sent',
origin: 'manual',
rendered_html: html,
created_at: new Date(),
sent_at: new Date(),
});
res.json({ ok: true });
} catch (error) {
logger.error('Manual send error:', error);
res.status(500).json({ error: 'Failed to send message', details: error.message });
}
});
// Helper: parse variables JSON safely
function parseVariables(template) {
try {
+2 -3
View File
@@ -7,7 +7,6 @@ const express = require('express');
const { body, validationResult } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership');
const eventRenameService = require('../services/eventRenameService');
const logger = require('../utils/logger');
const router = express.Router();
@@ -16,7 +15,7 @@ const router = express.Router();
* POST /api/admin/events/:eventId/rename
* Rename an event
*/
router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [
body('newEventName')
.trim()
.isLength({ min: 3, max: 100 })
@@ -61,7 +60,7 @@ router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), req
* POST /api/admin/events/:eventId/validate-rename
* Validate a potential rename without executing it
*/
router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), [
body('newEventName')
.trim()
.isLength({ min: 3, max: 100 })
+18 -37
View File
@@ -10,7 +10,7 @@ const { requirePermission } = require('../../middleware/permissions');
const { archiveEvent } = require('../../services/archiveService');
const logger = require('../../utils/logger');
const { errorResponse } = require('../../utils/routeHelpers');
const { requireEventOwnership, filterOwnedEventIds } = require('../../middleware/ownership');
const { requireEventOwnership } = require('../../middleware/ownership');
const { deleteEventCascade } = require('./helpers');
@@ -74,39 +74,25 @@ module.exports = (router) => {
}
const { eventIds } = req.body;
if (eventIds.length === 0) {
return res.status(400).json({ error: 'No events selected for archiving' });
}
// Ownership scope: a non-super_admin may only archive events they own.
// Foreign/non-existent ids are dropped and reported as failures so this
// route can't archive another admin's events (the single-event
// /:id/archive route enforces the same via requireEventOwnership).
const { allowed: allowedIds, denied: deniedIds } = await filterOwnedEventIds(req.admin, eventIds);
// Get all events to archive
const events = await db('events')
.whereIn('id', eventIds)
.where('is_archived', formatBoolean(false));
if (events.length === 0) {
return res.status(400).json({ error: 'No valid events found to archive' });
}
const results = {
successful: [],
failed: deniedIds.map((id) => ({ id, name: null, error: 'Access denied or event not found' }))
failed: []
};
// Get all events to archive
const events = allowedIds.length
? await db('events')
.whereIn('id', allowedIds)
.where('is_archived', formatBoolean(false))
: [];
if (events.length === 0) {
if (results.failed.length > 0) {
return res.json({
message: `Bulk archive completed: 0 succeeded, ${results.failed.length} failed`,
results
});
}
return res.status(400).json({ error: 'No valid events found to archive' });
}
// Process each event
for (const event of events) {
try {
@@ -165,21 +151,16 @@ module.exports = (router) => {
const { eventIds } = req.body;
// Ownership scope: a non-super_admin may only delete events they own.
// The single-event DELETE /:id route enforces this via
// requireEventOwnership; this bulk route must match it, otherwise an
// admin/editor scoped to their own events could cascade-delete any
// event by id. Foreign/non-existent ids are dropped and reported as
// failures (indistinguishable, to avoid an existence oracle).
const { allowed: allowedIds, denied: deniedIds } = await filterOwnedEventIds(req.admin, eventIds);
// Editor-role events.delete permission is already gated by the route
// middleware. We do NOT additionally filter to created_by here because
// the per-event delete-cascade is global (matches DELETE /:id which
// also has no role-based filter — that's why events.delete is a
// sensitive permission).
const results = {
successful: [],
failed: deniedIds.map((id) => ({ id, name: null, error: 'Access denied or event not found' }))
};
const results = { successful: [], failed: [] };
const adminContext = { id: req.admin.id, username: req.admin.username };
for (const eventId of allowedIds) {
for (const eventId of eventIds) {
try {
const deleted = await deleteEventCascade(eventId, adminContext);
results.successful.push(deleted);
+8 -75
View File
@@ -24,7 +24,6 @@ const { normaliseEventTimeTriple } = require('../../services/eventService');
const { hasColumnCached } = require('../../utils/schemaCache');
const { requireEventOwnership } = require('../../middleware/ownership');
const { getAppSetting } = require('../../utils/appSettings');
const { clampIntOrUndefined } = require('../../utils/numericHelpers');
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
const downloadZipService = require('../../services/downloadZipService');
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
@@ -95,7 +94,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_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
body('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']),
@@ -339,16 +338,8 @@ module.exports = (router) => {
// Get branding defaults for hero logo settings (Feature 7: Branding Inheritance)
const brandingDefaults = await getBrandingDefaults();
// 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
? formatBoolean(hero_logo_visible)
: null;
// NULL = inherit the global branding_logo_size (#756), resolved at read
// time. Only an explicit per-event size overrides it.
const effectiveHeroLogoSize = req.body.hero_logo_size || null;
const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined ? hero_logo_visible : brandingDefaults.hero_logo_visible;
const effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size;
const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position;
// Inherit "Detect dev tools" from the global Image Security setting unless
@@ -378,13 +369,7 @@ module.exports = (router) => {
let slideshowSeed = {};
if (await hasColumnCached('events', 'show_interval_ms')) {
try {
// parseInt-first: the previous `Number.isFinite(+v)` pre-check let
// NaN through for null/''/true (+null is 0, parseInt(null) is NaN),
// producing show_interval_ms=NaN in the INSERT — PG rejects that
// with "invalid input syntax for type integer" while SQLite
// silently stores NULL, so event creation 500'd on PG whenever the
// slideshow app_settings rows were absent.
const intP = (v, min, max) => clampIntOrUndefined(v, min, max);
const intP = (v, min, max) => (Number.isFinite(+v) ? Math.min(max, Math.max(min, parseInt(v, 10))) : undefined);
const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined);
const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000);
const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS);
@@ -433,8 +418,7 @@ module.exports = (router) => {
allow_presigned_download: formatBoolean(allow_presigned_download === true || allow_presigned_download === 'true'),
require_password: formatBoolean(requirePassword),
css_template_id: css_template_id || null,
// Already formatBoolean-coerced above, or null = inherit global (#756).
hero_logo_visible: effectiveHeroLogoVisible,
hero_logo_visible: formatBoolean(effectiveHeroLogoVisible),
hero_logo_size: effectiveHeroLogoSize,
hero_logo_position: effectiveHeroLogoPosition,
header_style: effectiveHeaderStyle || 'standard',
@@ -1225,7 +1209,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_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
body('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']),
@@ -1433,13 +1417,9 @@ module.exports = (router) => {
updates.expires_at = null;
}
// Format hero logo settings if provided. null = inherit the global
// branding_logo_display_hero toggle (#756); only an explicit true/false
// is a per-event override.
// Format hero logo settings if provided
if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) {
updates.hero_logo_visible = updates.hero_logo_visible === null
? null
: formatBoolean(updates.hero_logo_visible);
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
}
// Per-event opt-in for hero-photo OG share image (#474). Coerce so
@@ -1596,51 +1576,4 @@ module.exports = (router) => {
}
});
// Extend a gallery's expiration. Migrated from the legacy /api/events router
// (removed — GHSA-4j34-x562-5vfq), now on the canonical mount with the same
// permission + ownership guards as every other gallery mutation, so a
// non-owning editor/viewer can no longer touch a gallery they don't own.
router.post('/:id/extend', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
body('days').isInt({ min: 1, max: 365 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { days } = req.body;
let eventQuery = db('events').where('id', id);
// Editor role can only touch their own events (defence in depth alongside
// requireEventOwnership).
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const newExpiration = new Date(event.expires_at);
newExpiration.setDate(newExpiration.getDate() + days);
await db('events').where('id', id).update({
expires_at: newExpiration,
is_active: formatBoolean(true) // reactivate if it had expired
});
await logActivity('event_expiration_extended',
{ eventName: event.event_name, days },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ expires_at: newExpiration });
} catch (error) {
errorResponse(res, error, 500, 'Failed to extend expiration');
}
});
};
+1 -2
View File
@@ -3,7 +3,6 @@ const path = require('path');
const fs = require('fs').promises;
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership');
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
const { db, logActivity } = require('../database/db');
const sharp = require('sharp');
@@ -49,7 +48,7 @@ async function walkDir(dir, baseDir) {
// POST /api/admin/events/:id/import-external
// Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } }
router.post('/events/:id/import-external', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
router.post('/events/:id/import-external', adminAuth, requirePermission('photos.upload'), async (req, res) => {
try {
const eventId = parseInt(req.params.id);
const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {};
+4 -10
View File
@@ -600,18 +600,12 @@ router.post(
const photo = await db('photos').where({ id: req.params.photoId }).first();
if (!photo) return res.status(404).json({ error: 'Photo not found' });
// Ownership scope: any non-super_admin may only retry photos in events
// they own — matching requireEventOwnership (which scopes both the
// admin and editor roles; only super_admin bypasses). Previously this
// checked the editor role alone, leaving admin-role users able to
// reprocess another admin's photos.
if (req.admin.roleName !== 'super_admin') {
// Editor role: only allow retry on photos in events they own.
if (req.admin.roleName === 'editor') {
const event = await db('events')
.where({ id: photo.event_id })
.where({ id: photo.event_id, created_by: req.admin.id })
.first();
if (event && event.created_by && event.created_by !== req.admin.id) {
return res.status(404).json({ error: 'Photo not found' });
}
if (!event) return res.status(404).json({ error: 'Photo not found' });
}
if (photo.processing_status !== 'failed') {
-13
View File
@@ -254,19 +254,6 @@ router.patch('/:id/enabled', requirePermission('workflows.manage'), async (req,
// enabled state on the next SEED_VERSION bump (review nit #1).
if (await hasColumnCached('workflows', 'admin_toggled_at')) patch.admin_toggled_at = db.fn.now();
await db('workflows').where({ id }).update(patch);
// Turning dunning ON enrolls existing open/unpaid invoices (anchored to
// their due date) so it starts chasing current debtors, not only invoices
// sent after enabling (#750). Scoped to this flow's id so the backfill only
// enrolls dunning, not any custom invoice.sent flow. Best-effort — never
// fail the toggle over it.
if (enabled && wf.builtin_key === 'invoice_dunning') {
try {
const n = await require('../services/workflows').backfillDunningRuns(id);
require('../utils/logger').info('[workflow] dunning enabled — enrolled existing invoices', { enrolled: n });
} catch (e) {
require('../utils/logger').warn('[workflow] dunning backfill failed', { error: e.message });
}
}
res.json({ id, enabled });
} catch (e) { next(e); }
});
+39 -177
View File
@@ -2,10 +2,9 @@ const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
const mfaService = require('../services/mfaService');
const {
trackFailedAttempt,
trackSuccessfulLogin,
@@ -15,7 +14,6 @@ const {
} = require('../utils/authSecurity');
const { endSession } = require('../middleware/sessionTimeout');
const { revokeToken } = require('../utils/tokenRevocation');
const { timingSafeEqualStr } = require('../utils/timingSafe');
const logger = require('../utils/logger');
const { errorResponse } = require('../utils/routeHelpers');
const {
@@ -35,49 +33,6 @@ const {
} = require('../utils/passwordValidation');
const router = express.Router();
/**
* Finish a successful admin login: reset the lockout counter, stamp
* last_login, mint the 24h admin JWT, set the HttpOnly cookie, and return the
* user payload. Shared by the direct (no-MFA) path and the MFA-verify path so
* both produce an identical session. `lockoutKey` is the identifier the user
* typed (username or email) so success/failure tracking stays in one bucket.
*/
async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) {
await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent);
await db('admin_users').where('id', admin.id).update({
last_login: new Date(),
last_login_ip: ipAddress
});
const token = jwt.sign({
id: admin.id,
username: admin.username,
type: 'admin',
role: admin.role_name,
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
setAdminAuthCookie(res, token);
return res.json({
user: {
id: admin.id,
username: admin.username,
email: admin.email,
mustChangePassword: admin.must_change_password || false,
role: admin.role_name ? {
name: admin.role_name,
displayName: admin.role_display_name
} : null
}
});
}
// Admin login with enhanced security
router.post('/admin/login', [
body('username').notEmpty().trim(),
@@ -140,131 +95,48 @@ router.post('/admin/login', [
return res.status(401).json({ error: getGenericAuthError() });
}
// Second factor: if this admin has TOTP enabled, do NOT complete the login
// yet. Issue a short-lived, single-purpose mfa_pending token and require the
// code via /admin/login/mfa. We deliberately don't reset the lockout counter
// (trackSuccessfulLogin) or stamp last_login until the second factor passes,
// so MFA brute-force is still gated by the account lockout. `loginId` carries
// the typed identifier so the verify step tracks the same lockout bucket.
if (mfaService.isEnrolled(admin)) {
const mfaToken = jwt.sign({
// Successful login
await trackSuccessfulLogin(username, ipAddress, userAgent);
// Update last login and login metadata
await db('admin_users').where('id', admin.id).update({
last_login: new Date(),
last_login_ip: ipAddress
});
// Generate token with additional claims including role
const token = jwt.sign({
id: admin.id,
username: admin.username,
type: 'admin',
role: admin.role_name, // Add role to JWT
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
setAdminAuthCookie(res, token);
// Token is delivered via HttpOnly cookie only (not in response body)
res.json({
user: {
id: admin.id,
username: admin.username,
type: 'mfa_pending',
loginId: username
}, process.env.JWT_SECRET, {
expiresIn: '5m',
issuer: 'picpeak-auth'
});
return res.json({ mfaRequired: true, mfaToken });
}
return await completeAdminLogin(req, res, admin, ipAddress, userAgent, username);
email: admin.email,
mustChangePassword: admin.must_change_password || false,
role: admin.role_name ? {
name: admin.role_name,
displayName: admin.role_display_name
} : null
}
});
} catch (error) {
errorResponse(res, error, 500, 'Login failed');
}
});
// Second-factor verification. Exchanges the short-lived mfa_pending token
// (from /admin/login) plus a TOTP or recovery code for a full admin session.
router.post('/admin/login/mfa', [
body('mfaToken').notEmpty(),
body('code').notEmpty().trim()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { mfaToken, code } = req.body;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
let decoded;
try {
decoded = jwt.verify(mfaToken, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'picpeak-auth'
});
} catch (err) {
return res.status(401).json({
error: 'Your verification session expired. Please sign in again.',
code: 'MFA_SESSION_EXPIRED'
});
}
if (decoded.type !== 'mfa_pending') {
return res.status(401).json({ error: getGenericAuthError() });
}
const lockoutKey = decoded.loginId || decoded.username;
const lockoutStatus = await checkAccountLockout(lockoutKey);
if (lockoutStatus.isLocked) {
return res.status(423).json({
error: 'Account temporarily locked due to too many failed attempts',
retryAfter: lockoutStatus.remainingTime
});
}
const admin = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', decoded.id)
.select(
'admin_users.*',
'roles.name as role_name',
'roles.display_name as role_display_name'
)
.first();
if (!admin || !admin.is_active || !mfaService.isEnrolled(admin)) {
return res.status(401).json({ error: getGenericAuthError() });
}
// TOTP first, then a one-time recovery code.
let ok = mfaService.verifyTotpEncrypted(code, admin.two_factor_secret);
let usedRecovery = false;
let remainingHashes = null;
if (!ok) {
const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes);
const result = await mfaService.consumeRecoveryCode(code, stored);
if (result.matched) {
ok = true;
usedRecovery = true;
remainingHashes = result.remainingHashes;
}
}
if (!ok) {
await trackFailedAttempt(lockoutKey, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid verification code', code: 'MFA_INVALID' });
}
if (usedRecovery) {
await db('admin_users').where('id', admin.id).update({
two_factor_recovery_codes: JSON.stringify(remainingHashes),
updated_at: new Date()
});
await logActivity('admin_mfa_recovery_used',
{ admin_id: admin.id, remaining: remainingHashes.length },
null,
{ type: 'admin', id: admin.id, name: admin.username }
);
}
await logActivity('admin_mfa_login',
{ admin_id: admin.id, method: usedRecovery ? 'recovery_code' : 'totp' },
null,
{ type: 'admin', id: admin.id, name: admin.username }
);
return await completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey);
} catch (error) {
logger.error('MFA verification error:', error);
res.status(500).json({ error: 'Verification failed' });
}
});
// Logout endpoint
router.post('/logout', async (req, res) => {
try {
@@ -538,23 +410,11 @@ router.post('/gallery/share-login', [
const expectedToken = getEventShareToken(event);
if (!expectedToken || !timingSafeEqualStr(token, expectedToken)) {
if (!expectedToken || token !== expectedToken) {
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid or expired share link' });
}
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
// The share link only proves the holder was given the link — it is NOT the
// gallery password. For a password-protected gallery, minting a full
// `type:'gallery'` token here would let anyone with the share URL bypass
// the password entirely (GHSA-9hmx-68vc-qpqw). Signal that a password is
// still required and return WITHOUT a token/cookie; the client then goes
// through POST /gallery/verify, which does check the password.
if (requiresPassword) {
return res.json({ requires_password: true });
}
const jwtToken = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
@@ -569,6 +429,8 @@ router.post('/gallery/share-login', [
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
setGalleryAuthCookies(res, jwtToken, event.slug);
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({
token: jwtToken,
event: {
+443
View File
@@ -0,0 +1,443 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { slugify } = require('../utils/slug');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { adminAuth } = require('../middleware/auth');
const fs = require('fs').promises;
const path = require('path');
const router = express.Router();
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
const logger = require('../utils/logger');
// Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
// Whether the global "phone field" toggle (#322) is enabled. Same shape as
// the helper in adminEvents.js — kept local so this route doesn't import
// from a sibling route file.
const isPhoneFieldEnabled = async () => {
try {
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
if (!row) return false;
let value = row.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
return value === true;
} catch {
return false;
}
};
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
return event;
}
const {
host_name,
host_email,
customer_name,
customer_email,
...rest
} = event;
return {
...rest,
customer_name: customer_name ?? host_name ?? null,
customer_email: customer_email ?? host_email ?? null
};
};
let customerColumnCache = null;
const hasCustomerContactColumns = async () => {
if (customerColumnCache === true) {
return true;
}
try {
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
if (hasColumn) {
customerColumnCache = true;
}
return hasColumn;
} catch (error) {
return false;
}
};
// Create new event
router.post('/', adminAuth, [
body('event_type').notEmpty().trim().custom(async (value) => {
const isValid = await eventTypeService.isValidEventType(value);
if (!isValid) {
throw new Error('Invalid event type');
}
return true;
}),
body('event_name').notEmpty(),
body('event_date').isDate(),
body('customer_name').notEmpty().trim(),
body('customer_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
body('customer_phone').optional({ nullable: true, checkFalsy: true })
.isString().trim()
.isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'),
body('admin_email').isEmail(),
body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
const requirePassword = parseBooleanInput(req.body.require_password, true);
if (!requirePassword) {
return true;
}
if (typeof value !== 'string' || value.trim().length < 6) {
throw new Error('Password must be at least 6 characters long');
}
return true;
}),
body('expiration_days').isInt({ min: 1, max: 365 }).optional()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const {
event_type,
event_name,
event_date,
admin_email,
password,
require_password: requirePasswordInput = true,
welcome_message,
color_theme,
expiration_days = 30
} = req.body;
const customerEmail = getCustomerEmailFromPayload(req.body);
const customerName = getCustomerNameFromPayload(req.body);
if (!customerName || !customerEmail) {
return res.status(400).json({ error: 'customer_name and customer_email are required' });
}
const customerColumnsAvailable = await hasCustomerContactColumns();
const phoneEnabled = await isPhoneFieldEnabled();
const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null;
const requirePassword = parseBooleanInput(requirePasswordInput, true);
if (requirePassword) {
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
if (!passwordValidation.valid) {
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
}
// Generate unique slug — slugify() handles accents (see #525).
const baseSlug = `${event_type}-${slugify(event_name)}-${event_date}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) {
slug = `${baseSlug}-${counter}`;
counter++;
}
// Generate share link variants (auto-detects short URL preference)
const shareToken = crypto.randomBytes(16).toString('hex');
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password (or placeholder when not required)
const password_hash = requirePassword
? await bcrypt.hash(password, getBcryptRounds())
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date (days after event date)
const expires_at = new Date(event_date);
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
// Create folder structure
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const eventPath = path.join(storagePath, 'events/active', slug);
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
// Insert into database
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
event_date,
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
...(customerPhone ? { customer_phone: customerPhone } : {}),
host_name: customerName,
host_email: customerEmail,
admin_email,
password_hash,
welcome_message,
color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at,
require_password: formatBoolean(requirePassword)
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
const eventId = insertResult[0]?.id || insertResult[0];
// Queue creation email
const { queueEmail } = require('../services/emailProcessor');
await queueEmail(eventId, customerEmail, 'gallery_created', {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName,
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
});
// WhatsApp gallery_ready notification (#647 follow-up). Mirrors the
// adminEvents.js path: fires when the customer supplied a phone, the
// feature is enabled, and a config exists. Non-fatal — a queue failure
// must never block gallery creation.
if (customerPhone) {
try {
const { queueWhatsapp, getWhatsAppConfig } = require('../services/whatsappProcessor');
const waConfig = await getWhatsAppConfig();
if (waConfig && waConfig.enabled) {
await queueWhatsapp(eventId, customerPhone, 'gallery_created', {
customer_name: customerName || '',
event_name,
gallery_link: shareUrl,
gallery_password: requirePassword ? password : '',
expiry_date: expires_at ? expires_at.toISOString() : null,
language: null,
});
}
} catch (waError) {
logger.warn('Failed to queue WhatsApp notification on create', waError.message);
}
}
// Webhook lifecycle (#327). Legacy public endpoint — events go live
// immediately so created + published fire together. Payload uses the
// canonical event subject (#341) — every event.* webhook now includes
// customer contact + share_token.
try {
const webhookService = require('../services/webhookService');
const eventSubject = webhookService.buildEventSubject({
id: eventId,
slug,
event_name,
event_type,
event_date,
share_url: shareUrl,
share_token: shareToken,
customer_name: customerName,
customer_email: customerEmail,
customer_phone: customerPhone,
});
await webhookService.fire('event.created', { event: eventSubject });
await webhookService.fire('event.published', { event: eventSubject });
} catch (e) { /* non-fatal */ }
res.json({
id: eventId,
slug,
share_link: shareUrl,
expires_at,
require_password: requirePassword,
customer_name: customerName,
customer_email: customerEmail
});
} catch (error) {
logger.error(error);
res.status(500).json({ error: 'Failed to create event' });
}
});
// Get all events (admin)
router.get('/', adminAuth, async (req, res) => {
try {
const { status = 'all' } = req.query;
let query = db('events').select('*');
if (status === 'active') {
query = query.where('is_active', formatBoolean(true));
} else if (status === 'archived') {
query = query.where('is_archived', formatBoolean(true));
}
const events = await query.orderBy('created_at', 'desc');
// Add photo counts
for (const event of events) {
const photoCount = await db('photos').where('event_id', event.id).count('id as count').first();
event.photo_count = photoCount.count;
}
res.json(events.map(mapEventForApi));
} catch (error) {
res.status(500).json({ error: 'Failed to fetch events' });
}
});
// Update event
router.put('/:id', adminAuth, [
body('customer_name').optional().trim().notEmpty(),
body('customer_email').optional().isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
body('require_password').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const updates = { ...req.body };
const customerColumnsAvailable = await hasCustomerContactColumns();
// Don't allow updating certain fields
delete updates.id;
delete updates.slug;
delete updates.created_at;
delete updates.password_confirmation;
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
}
if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) {
const nextName = getCustomerNameFromPayload(updates);
if (nextName) {
if (customerColumnsAvailable) {
updates.customer_name = nextName;
} else {
delete updates.customer_name;
}
updates.host_name = nextName;
} else {
delete updates.customer_name;
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) {
const nextEmail = getCustomerEmailFromPayload(updates);
if (nextEmail) {
if (customerColumnsAvailable) {
updates.customer_email = nextEmail;
} else {
delete updates.customer_email;
}
updates.host_email = nextEmail;
} else {
delete updates.customer_email;
}
}
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
let requirePasswordUpdate;
if (hasRequirePasswordUpdate) {
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
updates.require_password = formatBoolean(requirePasswordUpdate);
}
let newPasswordPlain;
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
if (updates.password === undefined || updates.password === null || updates.password === '') {
delete updates.password;
} else {
newPasswordPlain = updates.password;
delete updates.password;
}
}
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const currentRequirePassword = parseBooleanInput(event.require_password, true);
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
}
if (newPasswordPlain) {
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
}
await db('events').where('id', id).update(updates);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to update event' });
}
});
// Delete event (mark as inactive)
router.delete('/:id', adminAuth, async (req, res) => {
try {
const { id } = req.params;
await db('events').where('id', id).update({ is_active: formatBoolean(false) });
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to delete event' });
}
});
// Extend expiration
router.post('/:id/extend', adminAuth, [
body('days').isInt({ min: 1, max: 365 })
], async (req, res) => {
try {
const { id } = req.params;
const { days } = req.body;
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const newExpiration = new Date(event.expires_at);
newExpiration.setDate(newExpiration.getDate() + days);
await db('events').where('id', id).update({
expires_at: newExpiration,
is_active: formatBoolean(true) // Reactivate if expired
});
res.json({ expires_at: newExpiration });
} catch (error) {
res.status(500).json({ error: 'Failed to extend expiration' });
}
});
module.exports = router;
+5 -21
View File
@@ -2,21 +2,9 @@ const express = require('express');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getAppSetting } = require('../utils/appSettings');
const archiver = require('archiver');
const path = require('path');
const router = express.Router();
// #756: a NULL per-event hero_logo_visible means "inherit the global
// branding_logo_display_hero toggle". Only an explicit true/false is a
// per-gallery override. `globalDefault` is branding_logo_display_hero
// (defaults true when unset).
function resolveHeroLogoVisible(perEvent, globalDefault) {
if (perEvent === null || perEvent === undefined) {
return globalDefault !== false;
}
return perEvent !== false && perEvent !== 0 && perEvent !== '0';
}
const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const { verifyGalleryAccess, denySlideshowToken, isAdminPreview } = require('../middleware/gallery');
@@ -194,8 +182,6 @@ router.get('/:slug/info', async (req, res) => {
}
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
res.json({
event_name: event.event_name,
@@ -213,9 +199,8 @@ router.get('/:slug/info', async (req, res) => {
watermark_text: event.watermark_text,
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible),
// #756: NULL per-event size inherits the global branding_logo_size.
hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium',
hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0',
hero_logo_size: event.hero_logo_size || 'medium',
hero_logo_position: event.hero_logo_position || 'top',
hero_logo_url: event.hero_logo_url || null,
header_style: event.header_style || 'standard',
@@ -650,8 +635,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// selection back to source files. Tied to the same toggle as downloads —
// one switch controls both surfaces.
const useOriginalFilenames = await getUseOriginalFilenames();
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
res.json({
event: {
@@ -670,8 +654,8 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
watermark_text: req.event.watermark_text,
enable_devtools_protection: req.event.enable_devtools_protection === true,
use_canvas_rendering: req.event.use_canvas_rendering === true,
hero_logo_visible: resolveHeroLogoVisible(req.event.hero_logo_visible, globalHeroLogoVisible),
hero_logo_size: req.event.hero_logo_size || globalLogoSize || 'medium',
hero_logo_visible: req.event.hero_logo_visible !== false && req.event.hero_logo_visible !== 0 && req.event.hero_logo_visible !== '0',
hero_logo_size: req.event.hero_logo_size || 'medium',
hero_logo_position: req.event.hero_logo_position || 'top',
hero_logo_url: req.event.hero_logo_url || null,
header_style: req.event.header_style || 'standard',
+1
View File
@@ -1,5 +1,6 @@
const express = require('express');
const router = express.Router();
const { photoAuth } = require('../middleware/photoAuth');
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const { resolveGuest } = require('../middleware/guestAuth');
+2 -3
View File
@@ -9,7 +9,6 @@ const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/ph
const { withLocalCopy } = require('../services/imageProcessor');
const crypto = require('crypto');
const logger = require('../utils/logger');
const { timingSafeEqualStr } = require('../utils/timingSafe');
const router = express.Router();
@@ -34,9 +33,9 @@ function verifyImageToken(token) {
const decoded = Buffer.from(data, 'base64').toString();
const [photoId, expires] = decoded.split(':');
// Verify signature (constant-time — avoids leaking the HMAC byte-by-byte)
// Verify signature
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
if (!timingSafeEqualStr(signature, expectedSignature)) {
if (signature !== expectedSignature) {
return null;
}
+2 -3
View File
@@ -1,6 +1,6 @@
const express = require('express');
const { db } = require('../database/db');
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService');
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger');
@@ -23,7 +23,7 @@ router.post('/:slug/generate-token', async (req, res, next) => {
// Add slug to request for verifyGalleryAccess
req.requestedSlug = req.params.slug;
next();
}, verifyGalleryAccess, denySlideshowToken, async (req, res) => {
}, verifyGalleryAccess, async (req, res) => {
try {
const { photoId, accessType = 'view' } = req.body;
@@ -273,7 +273,6 @@ router.get('/:slug/secure-download/:photoId/:token',
next();
},
verifyGalleryAccess,
denySlideshowToken,
async (req, res) => {
try {
const { photoId, token } = req.params;
+2 -5
View File
@@ -31,10 +31,7 @@ function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
{ node_key: 'waitDue', type: 'wait', config: { untilVar: 'dueDate' }, pos_x: 240, pos_y: 110 },
// Anchor the grace period to the invoice's due date (dueDate + firstDays),
// not "now + firstDays" — so an already-overdue invoice enrolled via backfill
// duns on its real timeline instead of restarting a fresh grace clock (#750).
{ node_key: 'waitGrace', type: 'wait', config: { untilVar: 'dueDate', delayDays: firstDays }, pos_x: 240, pos_y: 220 },
{ node_key: 'waitGrace', type: 'wait', config: { delayDays: firstDays }, pos_x: 240, pos_y: 220 },
{ node_key: 'loop', type: 'loop', config: { maxIterations: maxReminders }, pos_x: 240, pos_y: 330 },
{ node_key: 'checkPaid', type: 'condition', config: { condition: 'invoice_paid' }, pos_x: 240, pos_y: 440 },
{ node_key: 'paymentCheck', type: 'action', config: { action: 'queue_payment_check' }, pos_x: 240, pos_y: 550 },
@@ -221,7 +218,7 @@ function buildGalleryExpiredGraph() {
const BUILTINS = [
{
key: DUNNING_KEY,
version: 7,
version: 6,
enabled: false,
name: 'Invoice dunning (built-in)',
trigger_type: 'invoice.sent',
+2 -12
View File
@@ -30,16 +30,6 @@ async function initializeUpload(options) {
totalChunks
} = options;
// Strip any directory components from the client-supplied filename. It is
// later joined onto the temp merge dir (path.join(tempDir, filename)), and
// path.join does NOT neutralise `../` — a filename like `../../uploads/
// logos/evil.svg` would escape the temp dir and overwrite arbitrary files
// (GHSA-pc72-jf53-w28j). basename() collapses it to the leaf name only.
const safeFilename = path.basename(String(filename || ''));
if (!safeFilename || safeFilename === '.' || safeFilename === '..') {
throw new Error('Invalid filename');
}
// Generate unique upload ID
const uploadId = crypto.randomUUID();
@@ -53,7 +43,7 @@ async function initializeUpload(options) {
// Store upload metadata
const uploadMeta = {
uploadId,
filename: safeFilename,
filename,
fileSize,
mimeType,
eventId,
@@ -69,7 +59,7 @@ async function initializeUpload(options) {
logger.info('Initialized chunked upload', {
uploadId,
filename: safeFilename,
filename,
fileSize,
expectedChunks,
eventId
+23 -101
View File
@@ -16,7 +16,6 @@ const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getStoragePath } = require('../config/storage');
const expenseService = require('./expenseService');
const sanitizeHtml = require('sanitize-html');
const { isUniqueViolation } = require('../utils/dbErrors');
const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png'];
@@ -231,36 +230,14 @@ async function roundTripTest({ timeoutMs = 30000, intervalMs = 3000 } = {}) {
}
}
// Sanitize an inbound HTML body before storing it. Inbound mail is untrusted,
// so this strips scripts/handlers/unknown schemes (the viewer ALSO renders it
// in a script-less sandboxed iframe — defense in depth). Remote images are kept
// (many legit emails embed them) but that is the only tracking-vector allowed.
function sanitizeBody(html) {
if (!html) return null;
try {
return sanitizeHtml(html, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt', 'width', 'height'],
'*': ['style'],
},
allowedSchemes: ['http', 'https', 'mailto', 'cid'],
});
} catch (_) {
return null;
}
}
/** Poll the mailbox once. Safe to call repeatedly; self-skips when busy/off. */
async function pollOnce() {
if (polling) return { skipped: 'busy' };
if (!(await isEnabled())) return { skipped: 'disabled' };
const cfg = await getImapConfig();
if (!cfg) return { skipped: 'unconfigured' };
/**
* Poll ONE mailbox once and return the count of newly-processed messages.
* `opts.accountKey` tags each received_emails row; `opts.routeToExpenses`
* controls whether PDF/image attachments are dropped into the accounting inbox
* (true for the primary rechnungen@ mailbox) or only logged with the body
* (customer mail, e.g. hello@). The claim/dedup/stale-recovery logic is
* identical for every mailbox.
*/
async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses = true } = {}) {
polling = true;
const client = makeImapClient(cfg);
let processed = 0;
try {
@@ -327,7 +304,6 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
try {
await db('received_emails').insert({
message_id: claimKey,
account_key: accountKey,
status: 'processing',
attachment_count: 0,
received_at: new Date(),
@@ -339,47 +315,37 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
throw ce;
}
// Attachment handling. The accounting mailbox drops PDF/image
// attachments into the incoming-invoices inbox (isolated so one bad
// file can't prevent the audit row). Customer mailboxes only COUNT
// attachments — they aren't supplier invoices.
// Ingest attachments. Isolate each so one bad file can't prevent the
// audit row (the symptom: doc lands in Incoming invoices but the
// email never shows under Received).
const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType));
let inboundId = null;
let count = 0;
const attErrors = [];
if (routeToExpenses) {
const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType));
for (const att of atts) {
try {
const filePath = await saveAttachment(att);
const doc = await expenseService.recordInboundDocument({ source: 'email', filePath, originalFilename: att.filename || 'attachment', mimeType: att.contentType }, null);
inboundId = doc.id; count += 1;
} catch (ae) {
attErrors.push(ae.message);
logger.error?.(`emailIntake: attachment "${att.filename}" failed: ${ae.message}`);
}
for (const att of atts) {
try {
const filePath = await saveAttachment(att);
const doc = await expenseService.recordInboundDocument({ source: 'email', filePath, originalFilename: att.filename || 'attachment', mimeType: att.contentType }, null);
inboundId = doc.id; count += 1;
} catch (ae) {
attErrors.push(ae.message);
logger.error?.(`emailIntake: attachment "${att.filename}" failed: ${ae.message}`);
}
} else {
count = (parsed.attachments || []).length;
}
// A malformed Date: header yields an Invalid Date, which throws on a
// Postgres timestamp insert — coerce to now.
const receivedAt = (parsed.date instanceof Date && !Number.isNaN(parsed.date.getTime())) ? parsed.date : new Date();
const status = routeToExpenses
? (count > 0 ? 'ingested' : (attErrors.length ? 'error' : 'no_attachment'))
: 'received';
const status = count > 0 ? 'ingested' : (attErrors.length ? 'error' : 'no_attachment');
// Finalise the claimed row — every processed message ends up in the
// Received log with its (sanitized) body, even attachment-less ones.
// Received tab, even attachment-less ones.
await db('received_emails').where({ message_id: claimKey }).update({
from_address: ((parsed.from && parsed.from.text) || '').slice(0, 512) || null,
to_address: ((parsed.to && parsed.to.text) || '').slice(0, 512) || null,
subject: parsed.subject || null,
received_at: receivedAt,
attachment_count: count,
status,
inbound_document_id: inboundId,
body_html: sanitizeBody(parsed.html || null),
body_text: parsed.text || null,
error: attErrors.length ? attErrors.join('; ').slice(0, 2000) : null,
});
await client.messageFlagsAdd(cand.uid, ['\\Seen'], { uid: true });
@@ -394,7 +360,7 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
await db('received_emails').where({ message_id: claimKey })
.update({ status: 'error', error: String(e.message).slice(0, 2000) });
} else {
await db('received_emails').insert({ message_id: `err-${cand.uid}-${Date.now()}`, account_key: accountKey, status: 'error', error: e.message, attachment_count: 0, received_at: new Date(), created_at: new Date() });
await db('received_emails').insert({ message_id: `err-${cand.uid}-${Date.now()}`, status: 'error', error: e.message, attachment_count: 0, received_at: new Date(), created_at: new Date() });
}
} catch (ie) {
logger.error?.(`emailIntake: could not even write the error row (received_emails insert failing): ${ie.message}`);
@@ -407,55 +373,11 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
/* eslint-enable no-await-in-loop */
await client.logout();
} catch (e) {
logger.error?.(`emailIntake: poll failed (${accountKey}): ${e.message}`);
logger.error?.(`emailIntake: poll failed: ${e.message}`);
try { await client.close(); } catch (_e) { /* ignore */ }
}
return processed;
}
/**
* Poll ALL configured inbound mailboxes once: the primary accounting IMAP
* (email_configs) plus every enabled row in mail_accounts (e.g. hello@).
* Safe to call repeatedly; self-skips when busy/off.
*/
async function pollOnce() {
if (polling) return { skipped: 'busy' };
if (!(await isEnabled())) return { skipped: 'disabled' };
polling = true;
let processed = 0;
let anyConfigured = false;
try {
// 1) Primary accounting mailbox — routes attachments to the invoices inbox.
const acctCfg = await getImapConfig();
if (acctCfg) {
anyConfigured = true;
processed += await pollAccountOnce(acctCfg, { accountKey: 'accounting', routeToExpenses: true });
}
// 2) Additional mailboxes (customers/hello@) — body captured, no expense
// routing. Guarded so a pre-migration DB simply polls the accounting box.
let extras = [];
try {
if (await db.schema.hasTable('mail_accounts')) {
extras = await db('mail_accounts').where({ enabled: true });
}
} catch (_) { extras = []; }
for (const a of extras) {
if (!a.imap_host || !a.imap_user) continue;
anyConfigured = true;
const cfg = {
host: a.imap_host,
port: a.imap_port || 993,
secure: a.imap_secure !== false && a.imap_secure !== 0,
auth: { user: a.imap_user, pass: a.imap_pass || '' },
folder: a.imap_folder || 'INBOX',
};
// eslint-disable-next-line no-await-in-loop
processed += await pollAccountOnce(cfg, { accountKey: a.account_key, routeToExpenses: false });
}
} finally {
polling = false;
}
if (!anyConfigured) return { skipped: 'unconfigured' };
return { processed };
}
+4 -64
View File
@@ -726,12 +726,9 @@ async function sendTemplateEmail(to, templateKey, variables) {
throw new Error('Email configuration not found');
}
// Determine recipient language. An explicit `__language` in the email data
// wins (CRM/billing emails set it to the customer/invoice language so a
// gallery event's language can't override a dunning notice — see #760);
// otherwise fall back to the event-first recipient resolution.
const language = variables.__language || await getRecipientLanguage(to, variables.eventId || null);
// Determine recipient language (pass eventId if available in variables)
const language = await getRecipientLanguage(to, variables.eventId || null);
// Process template with variables
const { subject, htmlBody, textBody } = await processTemplate(template, variables, language);
@@ -775,62 +772,6 @@ async function sendTemplateEmail(to, templateKey, variables) {
}
}
/**
* Send a fully-composed email (subject + HTML the admin already edited in the
* Messages composer) WITHOUT a template. Used for replies + human-sent document
* messages. Uses the configured SMTP identity + from address. Returns
* { messageId, html } so the caller can persist rendered_html for the record.
*/
async function sendRawEmail({ to, cc, subject, html, text, attachments, accountKey } = {}) {
let tx = null;
let fromEmail = null;
let fromName = null;
// Prefer a per-account outgoing identity (e.g. hello@) when the mail account
// has its own SMTP config, so customer replies send from that address instead
// of the global no-reply@. Falls back to the global SMTP transport.
if (accountKey) {
const acct = await db('mail_accounts').where({ account_key: accountKey }).first();
if (acct && acct.smtp_host && (acct.smtp_user || acct.from_email)) {
const nodemailer = require('nodemailer');
tx = nodemailer.createTransport({
host: acct.smtp_host,
port: parseInt(acct.smtp_port, 10) || 587,
secure: acct.smtp_secure === true || acct.smtp_secure === 1,
auth: acct.smtp_user && acct.smtp_pass ? { user: acct.smtp_user, pass: acct.smtp_pass } : undefined,
tls: { rejectUnauthorized: true },
});
fromEmail = acct.from_email || acct.smtp_user;
fromName = acct.from_name || '';
}
}
if (!tx) {
tx = await initializeTransporter();
if (!tx) throw new Error('Email service not configured');
const config = await db('email_configs').first();
if (!config || !config.from_email) throw new Error('Email service not configured');
fromEmail = config.from_email;
fromName = config.from_name;
}
const ccList = Array.isArray(cc) ? cc.filter(Boolean) : (cc ? [cc] : undefined);
const atts = Array.isArray(attachments)
? attachments.filter((a) => a && (a.contentPath || a.path || a.content))
.map((a) => ({ filename: a.filename, path: a.contentPath || a.path, content: a.content, contentType: a.contentType }))
: undefined;
const info = await tx.sendMail({
from: `${fromName || 'picpeak'} <${fromEmail}>`,
to,
cc: ccList,
subject,
html,
text: text || htmlToText(html),
attachments: atts,
});
logger.info(`Manual email sent: ${info.messageId}`);
return { messageId: info.messageId, html };
}
/**
* Render a queued email's HTML WITHOUT sending it. Used by the Project
* Overview cockpit to preview emails that predate the rendered_html column
@@ -842,7 +783,7 @@ async function sendRawEmail({ to, cc, subject, html, text, attachments, accountK
async function renderQueuedEmail(templateKey, variables = {}, to = '') {
const template = await db('email_templates').where('template_key', templateKey).first();
if (!template) return null;
const language = variables.__language || await getRecipientLanguage(to, variables.eventId || null);
const language = await getRecipientLanguage(to, variables.eventId || null);
const { subject, htmlBody } = await processTemplate(template, variables, language);
return { subject, html: htmlBody };
}
@@ -1164,7 +1105,6 @@ module.exports = {
initializeTransporter,
startEmailQueueProcessor,
sendTemplateEmail,
sendRawEmail,
renderQueuedEmail,
processEmailQueue,
queueEmail,
+4 -27
View File
@@ -47,23 +47,11 @@ async function detectEnvironment() {
type = 'standalone';
}
// Detect a production compose install. The backend runs INSIDE a container and
// cannot see the host's compose files (the image only carries backend/), so we
// can't stat docker-compose.production.yml. Instead we key off an env var the
// production compose sets in the backend environment (PICPEAK_RELEASE_CHANNEL)
// and the default docker-compose.yml does not. When present, the update
// instructions must target that file explicitly — bare `docker compose`
// operates on docker-compose.yml, a different (build-based) stack that also
// starts the dev-only mailhog and leaves the real production containers on the
// old version.
const isProductionCompose = Boolean(process.env.PICPEAK_RELEASE_CHANNEL);
return {
type,
isDocker,
isGit,
hasDockerCompose,
isProductionCompose,
platform: process.platform,
nodeVersion: process.version,
appVersion
@@ -106,36 +94,25 @@ function generateUpdateInstructions(env, targetVersion) {
if (env.isDocker) {
instructions.environmentName = 'Docker';
// Production installs use docker-compose.production.yml (the file the README
// documents and the only one with pinned GHCR images + no dev-only mailhog).
// Bare `docker compose` targets docker-compose.yml instead, so a production
// user who runs it stays on the old version and gets a stray mailhog. When we
// detect a production compose (PICPEAK_RELEASE_CHANNEL set), point every
// command at that file with `-f`.
const composeFile = env.isProductionCompose ? '-f docker-compose.production.yml ' : '';
instructions.steps = [
{
description: 'Pull latest images',
command: `docker compose ${composeFile}pull`,
command: 'docker compose pull',
note: 'Downloads the new version images'
},
{
description: 'Recreate containers with new images',
command: `docker compose ${composeFile}up -d`,
command: 'docker compose up -d',
note: 'Restarts containers with new version'
},
{
description: 'Watch logs for startup (optional)',
command: `docker compose ${composeFile}logs -f backend`,
command: 'docker compose logs -f backend',
note: 'Press Ctrl+C to exit logs',
optional: true
}
];
if (env.isProductionCompose) {
instructions.warnings.push('Run these from the directory containing your docker-compose.production.yml file.');
} else {
instructions.warnings.push('Make sure you are in the directory containing your compose file. If you installed with docker-compose.production.yml, add `-f docker-compose.production.yml` to each command.');
}
instructions.warnings.push('Make sure you are in the directory containing your docker-compose.yml file');
} else if (env.isGit) {
instructions.environmentName = 'Git (Development)';
instructions.steps = [
+1 -23
View File
@@ -34,29 +34,7 @@ const SOCIAL_CRAWLER_PATTERNS = [
// messaging stacks (Twilio, LinkPreview.net, etc.). Match the
// canonical lowercase substring; the /i flag handles case.
/LinkPreview/i,
/Slack-ImgProxy/i,
// Viber's link-preview fetcher — was never detected, so shared links
// showed no rich preview in Viber (#699 follow-up). Keep in sync with the
// UA list in frontend/nginx.conf.
/Viber/i,
// Broader crawler coverage (#699 follow-up, from alexvaltchev's field list).
// IMPORTANT: only CRAWLER-EXCLUSIVE tokens are added here. Our OG response is
// meta-only (no client redirect), so a UA shared with a real human in-app
// browser would serve that human the bare stub. That rules out WeChat
// (MicroMessenger), LINE (Line/), Zalo, and generic strings like
// "InAppBrowser"/"preview"/"unfurl" — deliberately NOT added.
/Cardyb/i, // Bluesky's link-card service (the actual fetcher UA)
/facebookcatalog/i, // Facebook catalog crawler
/Signal/i, // Signal link preview
/Misskey/i, // fediverse (server-side preview fetch)
/Pleroma/i, // fediverse
/Synapse/i, // Matrix homeserver URL preview
/Nextcloud/i, // Nextcloud Talk/News link crawler
/Rocket\.Chat/i, // Rocket.Chat server preview
/kakaotalk-scrap/i, // KakaoTalk's scraper (NOT the in-app browser UA)
/Google-PageRenderer/i, // Google Chat previews (not Search)
/OdklBot/i, // Odnoklassniki
/ZoomBot/i // Zoom Team Chat
/Slack-ImgProxy/i
];
function isSocialCrawler(userAgent) {
+1 -6
View File
@@ -185,8 +185,6 @@ async function queueInvoicePaidAdminNotification({
|| [customer?.first_name, customer?.last_name].filter(Boolean).join(' ')
|| customer?.email || '',
event_name: invoice.event_name || '',
// Keep the body language consistent with the locale-formatted amounts.
__language: locale,
total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale),
paid_amount: formatMajor(paidTotalMinor, invoice.currency, locale),
payment_method: paymentMethod || '',
@@ -280,16 +278,13 @@ async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {})
: null;
await emailProcessor.queueEmail(invoice.event_id || null, adminContact.email,
'invoice_payment_check', {
'invoice_payment_check_admin', {
invoice_number: invoice.invoice_number,
customer_name: customer?.company_name
|| customer?.display_name
|| [customer?.first_name, customer?.last_name].filter(Boolean).join(' ')
|| customer?.email || '',
event_name: invoice.event_name || '',
// Keep the body language consistent with the locale the amounts are
// formatted in, instead of event-first resolution (admin-facing gate).
__language: locale,
due_date: formatShortDate(invoice.due_date),
total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale),
paid_amount: formatMajor(paidMinor, invoice.currency, locale),
+1 -3
View File
@@ -141,7 +141,7 @@ async function applyReminder(invoice, lineItems, level, adminId) {
const rawDaysOverdue = Math.floor((Date.now() - new Date(invoice.due_date).getTime()) / 86400000);
const daysOverdue = Math.max(1, rawDaysOverdue);
const templateKey = level === 1 ? 'invoice_reminder_first' : 'invoice_reminder_second';
const locale = ctx.locale || customer.preferred_language || invoice.language || 'de';
const locale = ctx.locale || invoice.language || 'de';
const outstandingMinor = Math.max(0, newTotal - Number(invoice.paid_amount_minor || 0));
// Attach the (unchanged) original invoice PDF + the new Mahnung.
@@ -154,8 +154,6 @@ async function applyReminder(invoice, lineItems, level, adminId) {
const { to: reminderTo, cc: reminderCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email);
try {
await emailProcessor.queueEmail(invoice.event_id || null, reminderTo, templateKey, {
// Render in the customer/invoice language, not the gallery event's (#760).
__language: locale,
invoice_number: invoice.invoice_number,
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale),
-5
View File
@@ -127,9 +127,6 @@ async function sendInvoice(id, adminId) {
installment_label: invoice.installment_label || '',
installment_index: invoice.installment_index + 1,
installment_total: invoice.installment_total,
// Send in the customer's language (matches the ctx.locale-formatted amounts
// above) rather than the event-first default resolution.
__language: ctx.locale,
cc: invoiceCc,
attachments: [{
filename: `${invoice.invoice_number}.pdf`,
@@ -371,8 +368,6 @@ async function sendStorno(stornoId, adminId) {
original_issue_date: originalRow?.issue_date ? formatShortDate(originalRow.issue_date) : '',
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
total_amount: formatMajor(Math.abs(storno.total_amount_minor), storno.currency, ctx.locale),
// Match the customer's language (as with the ctx.locale-formatted amount).
__language: ctx.locale,
cc: stornoCc,
attachments: [{
filename: `${storno.invoice_number}.pdf`,
-183
View File
@@ -1,183 +0,0 @@
/**
* mfaService TOTP (RFC 6238) multi-factor auth for admin accounts (#738).
*
* Responsibilities:
* - generate/verify TOTP secrets (otplib, standard SHA1/6-digit/30s so
* Google Authenticator / Authy / 1Password all work);
* - encrypt the secret at rest (AES-256-GCM) so a DB leak alone doesn't
* yield working authenticator seeds;
* - generate/verify one-time recovery codes, hashed (bcrypt) and single-use;
* - build the otpauth:// URI + QR data-URL for enrollment.
*
* The encryption key is derived (scrypt) from MFA_ENCRYPTION_KEY when set,
* otherwise from JWT_SECRET. Rotating either invalidates stored secrets
* the same blast radius as rotating JWT_SECRET already has for sessions, and
* `reset-admin-mfa.js` is the recovery path.
*/
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const { authenticator } = require('otplib');
const QRCode = require('qrcode');
// Standard TOTP params; window:1 tolerates ±1 step (30s) of clock drift.
authenticator.options = { window: 1 };
const ISSUER = 'PicPeak';
const RECOVERY_CODE_COUNT = 10;
const RECOVERY_CODE_BYTES = 10; // ~80 bits of entropy per code
const RECOVERY_BCRYPT_ROUNDS = 10;
const ENC_ALGO = 'aes-256-gcm';
const ENC_SALT = 'picpeak-mfa-secret-v1'; // fixed: derivation must be stable
function getEncryptionKey() {
const material = process.env.MFA_ENCRYPTION_KEY || process.env.JWT_SECRET;
if (!material) {
throw new Error('mfaService: MFA_ENCRYPTION_KEY or JWT_SECRET must be set');
}
return crypto.scryptSync(material, ENC_SALT, 32);
}
/** Generate a fresh base32 TOTP secret. */
function generateSecret() {
return authenticator.generateSecret();
}
/** AES-256-GCM encrypt a secret → "iv.tag.ciphertext" (all base64url). */
function encryptSecret(plainSecret) {
const key = getEncryptionKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(ENC_ALGO, key, iv);
const ct = Buffer.concat([cipher.update(plainSecret, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [iv, tag, ct].map((b) => b.toString('base64url')).join('.');
}
/** Reverse of encryptSecret. Throws on tamper/wrong key. */
function decryptSecret(stored) {
const key = getEncryptionKey();
const [ivB64, tagB64, ctB64] = String(stored).split('.');
if (!ivB64 || !tagB64 || !ctB64) {
throw new Error('mfaService: malformed encrypted secret');
}
const decipher = crypto.createDecipheriv(ENC_ALGO, key, Buffer.from(ivB64, 'base64url'));
decipher.setAuthTag(Buffer.from(tagB64, 'base64url'));
const pt = Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64url')), decipher.final()]);
return pt.toString('utf8');
}
/** Verify a 6-digit TOTP code against the (plaintext) secret. */
function verifyTotp(code, plainSecret) {
if (!code || !plainSecret) return false;
try {
return authenticator.verify({ token: String(code).replace(/\s+/g, ''), secret: plainSecret });
} catch {
return false;
}
}
/** Verify a code against a STORED (encrypted) secret. */
function verifyTotpEncrypted(code, storedSecret) {
try {
return verifyTotp(code, decryptSecret(storedSecret));
} catch {
return false;
}
}
/** otpauth:// URI for an authenticator app. */
function buildOtpauthUri(accountName, plainSecret) {
return authenticator.keyuri(accountName, ISSUER, plainSecret);
}
/** QR code (PNG data URL) for the otpauth URI. */
async function buildQrDataUrl(otpauthUri) {
return QRCode.toDataURL(otpauthUri, { errorCorrectionLevel: 'M', margin: 1, width: 240 });
}
/** Format a raw code as human-friendly groups, e.g. "abcd-efgh-jk". */
function formatRecoveryCode(raw) {
return raw.match(/.{1,4}/g).join('-');
}
/**
* Generate RECOVERY_CODE_COUNT one-time codes. Returns the plaintext codes
* (shown to the admin ONCE) and their bcrypt hashes (persisted).
*/
async function generateRecoveryCodes() {
const plain = [];
const hashed = [];
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
// base32-ish, lowercase, no ambiguous chars
const raw = crypto.randomBytes(RECOVERY_CODE_BYTES)
.toString('base64')
.replace(/[^a-zA-Z0-9]/g, '')
.toLowerCase()
.slice(0, 10);
const code = formatRecoveryCode(raw);
plain.push(code);
hashed.push(await bcrypt.hash(code, RECOVERY_BCRYPT_ROUNDS));
}
return { plain, hashed };
}
function normalizeRecoveryInput(code) {
return String(code || '').trim().toLowerCase();
}
/**
* Check a submitted recovery code against the stored hash array. On match,
* returns the remaining hashes (matched one removed single use). On miss,
* matched:false and the array unchanged.
*
* @param {string[]} storedHashes
* @returns {Promise<{matched: boolean, remainingHashes: string[]}>}
*/
async function consumeRecoveryCode(code, storedHashes) {
const input = normalizeRecoveryInput(code);
const hashes = Array.isArray(storedHashes) ? storedHashes : [];
if (!input) return { matched: false, remainingHashes: hashes };
for (let i = 0; i < hashes.length; i++) {
// eslint-disable-next-line no-await-in-loop
if (await bcrypt.compare(input, hashes[i])) {
const remaining = hashes.slice(0, i).concat(hashes.slice(i + 1));
return { matched: true, remainingHashes: remaining };
}
}
return { matched: false, remainingHashes: hashes };
}
/** True when an admin row has MFA enabled (coerces SQLite/PG boolean shapes). */
function isEnrolled(admin) {
const v = admin && admin.two_factor_enabled;
return v === true || v === 1 || v === '1';
}
/** Parse the DB column (JSON text) into an array of hashes. */
function parseRecoveryCodes(raw) {
if (!raw) return [];
try {
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
module.exports = {
generateSecret,
encryptSecret,
decryptSecret,
verifyTotp,
verifyTotpEncrypted,
buildOtpauthUri,
buildQrDataUrl,
generateRecoveryCodes,
consumeRecoveryCode,
parseRecoveryCodes,
isEnrolled,
formatRecoveryCode,
ISSUER,
RECOVERY_CODE_COUNT,
};
@@ -1,231 +0,0 @@
'use strict';
// Portable ".picpeak" export — a single, self-describing archive that can be
// downloaded from one instance and re-uploaded to another via the web UI only
// (see picpeakImportService for the receiving half).
//
// Deliberately ENGINE-NEUTRAL: instead of a native pg_dump / sqlite .backup
// (which can only ever restore into the same engine and version), each table is
// written as NDJSON. The target rebuilds its own schema by running migrations,
// then loads these rows into it — so an older backup restores cleanly onto a
// newer target (forward-only), and pg↔pg / sqlite↔sqlite both work.
//
// This module is purely additive: it introduces a new artifact and touches no
// existing backup/restore path.
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const archiver = require('archiver');
const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const { getStoragePath } = require('../config/storage');
const logger = require('../utils/logger');
const packageJson = require('../../package.json');
// Bump only on a breaking change to the on-disk layout below.
const PICPEAK_FORMAT_VERSION = 1;
// Never exported as data — the target owns these (its own migrations set them).
const EXCLUDED_TABLES = new Set(['knex_migrations', 'knex_migrations_lock']);
// Storage subdirs holding non-recalculable blobs — always included.
const DOC_DIRS = ['business-docs', 'uploads'];
// Original gallery photos — only when includePhotos is true (large; otherwise
// the admin re-uploads originals per gallery and previews are re-rendered).
const PHOTO_DIRS = ['events/active', 'events/archived'];
const isPostgres = () => knexConfig.client === 'pg';
// db.raw returns `{ rows: [...] }` on Postgres and a bare array on SQLite.
const rawRows = (result) => (isPostgres() ? result.rows : result);
// All user tables, minus knex bookkeeping. Introspected at runtime so the
// export never rots as tables are added (no hardcoded list to maintain).
async function listDataTables() {
let names;
if (isPostgres()) {
const result = await db.raw(`
SELECT table_name AS name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name
`);
names = rawRows(result).map((r) => r.name);
} else {
const result = await db.raw(`
SELECT name FROM sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
`);
names = rawRows(result).map((r) => r.name);
}
return names.filter((n) => !EXCLUDED_TABLES.has(n));
}
// The latest applied migration — recorded in the manifest so the importer can
// refuse a backup that is NEWER than the target (forward-only guarantee).
async function getLatestMigration() {
try {
const rows = await db('knex_migrations').orderBy('id', 'desc').limit(1);
return rows[0]?.name || null;
} catch (_) {
return null;
}
}
// Write one table to <dataDir>/<table>.ndjson (one JSON object per line).
// Returns { rowCount, checksum } for the manifest. JSON.stringify serialises
// Dates to ISO strings, which re-import cleanly on both engines.
//
// Uses a plain select rather than knex `.stream()`: streaming on Postgres pulls
// in the optional `pg-query-stream` dependency (not bundled), so it throws on
// pg. A select works on both engines with no extra dependency. Rows are DB
// metadata (blobs live on disk under files/), so holding a table in memory is
// fine for the instance sizes PicPeak targets.
async function writeTableNdjson(table, dataDir) {
const outPath = path.join(dataDir, `${table}.ndjson`);
const hash = crypto.createHash('sha256');
const rows = await db(table).select('*');
const lines = rows.map((row) => {
const line = JSON.stringify(row);
hash.update(`${line}\n`);
return line;
});
await fsp.writeFile(outPath, lines.length ? `${lines.join('\n')}\n` : '', 'utf8');
return { rowCount: rows.length, checksum: hash.digest('hex') };
}
// Recursively collect files under a storage subdir as { abs, rel } where rel is
// relative to the storage root (so the importer restores the same layout).
async function collectDir(subdir, storageRoot, acc) {
const abs = path.join(storageRoot, subdir);
let entries;
try {
entries = await fsp.readdir(abs, { withFileTypes: true });
} catch (_) {
return; // subdir may not exist on this install — skip silently
}
for (const entry of entries) {
const childRel = path.join(subdir, entry.name);
if (entry.isDirectory()) {
await collectDir(childRel, storageRoot, acc);
} else if (entry.isFile()) {
acc.push({ abs: path.join(storageRoot, childRel), rel: childRel });
}
}
}
async function collectFiles(includePhotos) {
const storageRoot = getStoragePath();
const dirs = includePhotos ? [...DOC_DIRS, ...PHOTO_DIRS] : [...DOC_DIRS];
const acc = [];
for (const d of dirs) {
await collectDir(d, storageRoot, acc);
}
return acc;
}
/**
* Build a .picpeak archive.
* @param {Object} opts
* @param {boolean} [opts.includePhotos=false] include original gallery photos
* @param {string} [opts.outDir] where to write the file (defaults to a temp dir)
* @returns {Promise<{ filePath: string, manifest: object }>}
*/
async function createPicpeak({ includePhotos = false, outDir } = {}) {
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-export-'));
const dataDir = path.join(staging, 'data');
await fsp.mkdir(dataDir, { recursive: true });
try {
// 1. Dump every table to NDJSON, tracking counts + checksums.
const tables = await listDataTables();
const tableMeta = {};
for (const table of tables) {
tableMeta[table] = await writeTableNdjson(table, dataDir);
}
// 2. Gather the non-recalculable blobs (PDFs, business-docs, uploads, and
// optionally original photos).
const files = await collectFiles(includePhotos);
// 3. Manifest — everything the importer needs to validate + reconstruct.
const manifest = {
format: PICPEAK_FORMAT_VERSION,
kind: 'picpeak-backup',
created_at: new Date().toISOString(),
app_version: packageJson.version || null,
database: {
engine: isPostgres() ? 'pg' : 'sqlite',
latest_migration: await getLatestMigration(),
},
options: { includePhotos: !!includePhotos },
tables: tableMeta,
file_count: files.length,
// NOTE: contains secrets (SMTP password, admin hashes, API keys) in plain
// text — the download surface must warn about this.
contains_secrets: true,
};
await fsp.writeFile(
path.join(staging, 'manifest.json'),
JSON.stringify(manifest, null, 2),
'utf8'
);
// 4. Zip staging (manifest + data/) plus the blobs under files/. The final
// .picpeak lands in outDir (caller-managed) or a fresh temp dir; either
// way the NDJSON scratch (which holds plaintext secrets) is always
// removed in `finally` below.
const targetDir = outDir || (await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-out-')));
await fsp.mkdir(targetDir, { recursive: true });
const stamp = manifest.created_at.replace(/[:.]/g, '-');
const filePath = path.join(targetDir, `picpeak-backup-${stamp}.picpeak`);
try {
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(filePath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', resolve);
output.on('error', reject);
archive.on('error', reject);
// Surface archiver warnings (e.g. a file vanished mid-run) instead of
// silently shipping an incomplete archive.
archive.on('warning', (err) => reject(err));
archive.pipe(output);
archive.file(path.join(staging, 'manifest.json'), { name: 'manifest.json' });
archive.directory(dataDir, 'data');
for (const f of files) {
archive.file(f.abs, { name: path.posix.join('files', f.rel.split(path.sep).join('/')) });
}
archive.finalize();
});
} catch (err) {
// Archiver failed → the partial .picpeak holds plaintext secrets and is
// useless; remove our own temp out dir so it isn't orphaned. A
// caller-supplied outDir is left untouched.
if (!outDir) await fsp.rm(targetDir, { recursive: true, force: true }).catch(() => {});
throw err;
}
logger.info(
`[picpeak-export] wrote ${filePath} (${tables.length} tables, ${files.length} files, includePhotos=${!!includePhotos})`
);
return { filePath, manifest };
} finally {
// Always remove the NDJSON scratch dir — it contains a plaintext dump of
// every table (secrets included). The final .picpeak is elsewhere.
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {});
}
}
module.exports = {
PICPEAK_FORMAT_VERSION,
EXCLUDED_TABLES,
createPicpeak,
// exported for reuse/testing
listDataTables,
collectFiles,
};
@@ -1,337 +0,0 @@
'use strict';
// Receiving half of the GUI-only backup roundtrip: takes a ".picpeak" produced
// by picpeakExportService and restores it onto THIS instance.
//
// Restore semantics (agreed design): FULL OVERRIDE — every table is wiped and
// replaced by the backup's rows — EXCEPT the current logged-in admin account,
// which is preserved so the operator is never locked out. A backup admin whose
// email collides with the current account is overwritten with the current
// account's credentials (so the operator's known password keeps working).
//
// Same-engine only (pg↔pg / sqlite↔sqlite) and forward-only (an older backup
// restores onto a newer instance; a newer backup is refused). The target's own
// schema is used as-is — we never replay the backup's DDL.
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const os = require('os');
const StreamZip = require('node-stream-zip');
const { assertZipEntriesWithin } = require('../utils/safePath');
const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const { getStoragePath } = require('../config/storage');
const { hasColumnCached } = require('../utils/schemaCache');
const logger = require('../utils/logger');
const { PICPEAK_FORMAT_VERSION, EXCLUDED_TABLES, listDataTables } = require('./picpeakExportService');
const isPostgres = () => knexConfig.client === 'pg';
// Compare migrations by their numeric filename prefix (001_, 107_, 129_ …).
function migrationOrder(name) {
const m = String(name || '').match(/^(\d+)/);
return m ? parseInt(m[1], 10) : -1;
}
async function readManifestFromZip(picpeakPath) {
const zip = new StreamZip.async({ file: picpeakPath });
try {
return JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
} finally {
await zip.close();
}
}
// Returns an array of human-readable blockers ([] = OK to restore).
async function validateManifest(manifest) {
const errors = [];
if (!manifest || manifest.kind !== 'picpeak-backup') {
return ['This file is not a PicPeak backup (.picpeak).'];
}
if (Number(manifest.format) > PICPEAK_FORMAT_VERSION) {
errors.push('This backup was created by a newer version of PicPeak. Update this instance first.');
}
const engine = isPostgres() ? 'pg' : 'sqlite';
if (manifest.database && manifest.database.engine && manifest.database.engine !== engine) {
errors.push(`Database engine mismatch: the backup is "${manifest.database.engine}" but this instance is "${engine}". Restore is only supported between matching engines.`);
}
// Forward-only: the target schema must be at least as new as the backup's.
let targetLatest = null;
try {
const applied = await db('knex_migrations').orderBy('id', 'desc').limit(1);
targetLatest = applied[0] ? applied[0].name : null;
} catch (_) {
// No knex_migrations table (e.g. some test harnesses) — skip the check.
}
const backupLatest = manifest.database ? manifest.database.latest_migration : null;
if (backupLatest && targetLatest && migrationOrder(backupLatest) > migrationOrder(targetLatest)) {
errors.push('This backup is from a newer database schema than this instance. Update this instance to at least the backup version before restoring.');
}
return errors;
}
function parseNdjson(filePath) {
if (!fs.existsSync(filePath)) return [];
return fs
.readFileSync(filePath, 'utf8')
.split('\n')
.filter((l) => l.trim().length > 0)
.map((l) => JSON.parse(l));
}
// Re-insert the operator's account inside the restore transaction so they keep
// working credentials after the wipe.
//
// The operator's login + credentials + MFA must be restored, not just the
// password. A crafted backup can carry a row with the operator's email whose
// two_factor_* fields are attacker-chosen — leaving those in place would let
// the backup strip or hijack the operator's MFA, or (cross-instance) pin a TOTP
// secret encrypted with the source instance's key the operator can never
// satisfy. These columns are scalar/text (recovery codes are a JSON string in a
// TEXT column), so writing them needs no special json handling. Relationship/
// audit FKs (role_id, created_by) are deliberately NOT forced from the snapshot
// — see the update branch below.
//
// admin_users has UNIQUE constraints on BOTH email and username, and a restored
// backup can collide with the operator on either — possibly on two DIFFERENT
// rows (one shares the email, another shares the default `admin` username). We
// reconcile WITHOUT deleting any restored row: deleting would fire ON DELETE
// actions (SQLite) or dangle references such as events.created_by (Postgres,
// where replica mode suppresses cascades). Instead:
// - if a row already has the operator's email, overwrite it in place (its id
// is preserved, so every FK pointing at the operator stays valid);
// - if a DIFFERENT row holds the operator's username, rename that row (id
// preserved, its own FKs stay valid) to free the username;
// - only when no row has the operator's email do we insert a fresh row.
async function reinjectCurrentAdmin(trx, currentAdmin) {
if (!currentAdmin) return;
const emailMatch = await trx('admin_users')
.whereRaw('lower(email) = lower(?)', [currentAdmin.email])
.first();
// Free the operator's username if a different row holds it (rename, not delete).
const usernameHolder = await trx('admin_users')
.whereRaw('lower(username) = lower(?)', [currentAdmin.username])
.first();
if (usernameHolder && (!emailMatch || usernameHolder.id !== emailMatch.id)) {
await trx('admin_users')
.where({ id: usernameHolder.id })
.update({ username: `${usernameHolder.username}__restored_${usernameHolder.id}` });
}
if (emailMatch) {
// Update in place — keeps emailMatch.id so restored FKs to the operator
// hold. Write only the AUTH-critical columns (login identity + credentials
// + MFA), never the relationship/audit FKs (role_id → roles, created_by →
// admin_users). Forcing the operator's pre-restore role_id/created_by here
// could reference rows absent from a cross-instance backup and dangle the
// FK (SQLite rolls back at commit); the row already carries the backup's
// own valid values for those. This still closes the MFA-hijack gap — a
// crafted backup can't strip or replace the operator's second factor.
const authUpdate = {};
for (const field of PRESERVED_AUTH_FIELDS) {
if (field in currentAdmin) authUpdate[field] = currentAdmin[field];
}
await trx('admin_users').where({ id: emailMatch.id }).update(authUpdate);
} else {
// The operator's email isn't in the backup, so nothing restored references
// their id — a fresh row can't dangle a reference TO the operator. Null the
// self-referential created_by (its target admin may be absent from this
// backup; ON DELETE SET NULL makes null the correct "unknown inviter"
// value) so the insert itself can't dangle. Use an explicit max(id)+1
// rather than the identity sequence, which batchInsert left unadvanced on
// Postgres (a sequence-based insert could collide with a restored id).
const snapshot = { ...currentAdmin };
delete snapshot.id;
if ('created_by' in snapshot) snapshot.created_by = null;
const maxRow = await trx('admin_users').max({ m: 'id' }).first();
snapshot.id = (Number(maxRow && maxRow.m) || 0) + 1;
await trx('admin_users').insert(snapshot);
}
}
// AUTH-critical admin_users columns preserved when overwriting a restored row
// that shares the operator's email. Deliberately excludes relationship/audit
// FKs (role_id, created_by) — see reinjectCurrentAdmin for why.
const PRESERVED_AUTH_FIELDS = [
'username', 'email', 'password_hash', 'is_active', 'must_change_password',
'two_factor_enabled', 'two_factor_secret', 'two_factor_recovery_codes', 'two_factor_enrolled_at',
];
// The json/jsonb columns of a table (Postgres only). The pg driver returns
// jsonb as parsed JS values, so on re-insert they must be serialised back to
// valid JSON text — otherwise a scalar like the string "PicPeak" is sent
// unquoted and pg rejects it ("invalid input syntax for type json").
async function jsonColumnsFor(trx, table) {
if (!isPostgres()) return new Set();
const res = await trx.raw(
"SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? AND data_type IN ('json', 'jsonb')",
[table]
);
return new Set(res.rows.map((r) => r.column_name));
}
function serialiseJsonColumns(rows, jsonCols) {
if (!jsonCols.size) return rows;
return rows.map((row) => {
const out = { ...row };
for (const col of jsonCols) {
if (out[col] !== undefined && out[col] !== null) out[col] = JSON.stringify(out[col]);
}
return out;
});
}
// Whole-DB replace in one transaction with FK enforcement suspended (pg:
// session_replication_role=replica on the trx connection, reset before commit;
// sqlite: defer_foreign_keys so checks run at commit). knex_migrations is never
// in the data set, so the target's schema/migration state is left intact.
async function replaceAllTables(tables, dataDir, currentAdmin) {
await db.transaction(async (trx) => {
if (isPostgres()) {
try {
await trx.raw("SET session_replication_role = 'replica'");
} catch (_) {
// session_replication_role requires a Postgres SUPERUSER. The bundled
// postgres image's role is one; managed Postgres (RDS / Cloud SQL / …)
// app users usually are not. Fail fast with a clear message BEFORE any
// rows are deleted — the transaction rolls back, so nothing is wiped.
const err = new Error(
'Restore needs a PostgreSQL superuser to suspend foreign-key checks during the full replace, but this instances database user is not a superuser (common on managed Postgres such as RDS or Cloud SQL). Restore onto the bundled Postgres, or grant the role superuser for the restore.'
);
err.statusCode = 400;
throw err;
}
} else {
await trx.raw('PRAGMA defer_foreign_keys = ON');
}
for (const table of tables) {
await trx(table).del();
}
for (const table of tables) {
const rows = parseNdjson(path.join(dataDir, `${table}.ndjson`));
if (!rows.length) continue;
const jsonCols = await jsonColumnsFor(trx, table);
await trx.batchInsert(table, serialiseJsonColumns(rows, jsonCols), 100);
}
await reinjectCurrentAdmin(trx, currentAdmin);
// Reset the pg session flag BEFORE the connection returns to the pool.
if (isPostgres()) await trx.raw("SET session_replication_role = 'origin'");
});
}
// Copy the archive's files/ tree into storage, overwriting existing files.
async function restoreFiles(stagingDir) {
const src = path.join(stagingDir, 'files');
if (!fs.existsSync(src)) return 0;
const storageRoot = getStoragePath();
let count = 0;
async function walk(rel) {
const abs = path.join(src, rel);
for (const entry of await fsp.readdir(abs, { withFileTypes: true })) {
const childRel = path.join(rel, entry.name);
if (entry.isDirectory()) {
await walk(childRel);
} else if (entry.isFile()) {
const dest = path.join(storageRoot, childRel);
await fsp.mkdir(path.dirname(dest), { recursive: true });
await fsp.copyFile(path.join(src, childRel), dest);
count += 1;
}
}
}
await walk('');
return count;
}
// Does the restored data reference an external-media library? If so the caller
// shows a banner telling the admin to (re)configure the external-media mount on
// this instance — those files are NOT in the backup by design.
async function detectExternalMedia() {
try {
if (await hasColumnCached('events', 'external_path')) {
const row = await db('events').whereNotNull('external_path').first();
if (row) return true;
}
if (await hasColumnCached('photos', 'external_relpath')) {
const row = await db('photos').whereNotNull('external_relpath').first();
if (row) return true;
}
} catch (_) {
// Best-effort — a detection miss is not worth failing the restore.
}
return false;
}
/**
* Restore a .picpeak onto this instance.
* @param {Object} opts
* @param {string} opts.picpeakPath path to the uploaded/staged .picpeak
* @param {number} [opts.currentAdminId] admin to preserve across the wipe
* @returns {Promise<{restored:boolean, tables:number, filesRestored:number, usesExternalMedia:boolean, manifest:object}>}
*/
async function importFromPicpeak({ picpeakPath, currentAdminId }) {
const manifest = await readManifestFromZip(picpeakPath);
const blockers = await validateManifest(manifest);
if (blockers.length) {
const err = new Error(blockers[0]);
err.statusCode = 400;
err.validation = blockers;
throw err;
}
const currentAdmin = currentAdminId
? await db('admin_users').where({ id: currentAdminId }).first()
: null;
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-import-'));
try {
const zip = new StreamZip.async({ file: picpeakPath });
try {
// Reject ZIP-slip entries before extracting — a crafted .picpeak could
// otherwise write outside the staging dir via `../` entry names
// (same class as GHSA-jfhw-fj23-fx6x).
assertZipEntriesWithin(Object.values(await zip.entries()), staging);
await zip.extract(null, staging);
} finally {
await zip.close();
}
const dataDir = path.join(staging, 'data');
// Only touch tables that (a) the uploaded manifest lists AND (b) actually
// exist as real tables in THIS database. listDataTables() already excludes
// knex_migrations/_lock (EXCLUDED_TABLES), so a crafted or corrupted
// .picpeak can never make the restore delete the migration bookkeeping — or
// any table that isn't a genuine data table here.
const dbTables = new Set(await listDataTables());
const manifestTables = Object.keys(manifest.tables || {});
const tables = manifestTables.filter((tbl) => dbTables.has(tbl) && !EXCLUDED_TABLES.has(tbl));
const skipped = manifestTables.filter((tbl) => !tables.includes(tbl));
if (skipped.length) {
logger.warn(`[picpeak-import] ignoring ${skipped.length} backup table(s) not present in this DB (or protected): ${skipped.join(', ')}`);
}
await replaceAllTables(tables, dataDir, currentAdmin);
const filesRestored = await restoreFiles(staging);
const usesExternalMedia = await detectExternalMedia();
logger.info(
`[picpeak-import] restored ${tables.length} tables, ${filesRestored} files (externalMedia=${usesExternalMedia})`
);
return { restored: true, tables: tables.length, filesRestored, usesExternalMedia, manifest };
} finally {
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {});
}
}
module.exports = {
importFromPicpeak,
readManifestFromZip,
validateManifest,
reinjectCurrentAdmin,
};
+2 -55
View File
@@ -46,18 +46,11 @@ function outEdge(edges, fromNode, handle) {
function computeWakeAt(config = {}, vars = {}) {
const cfg = config || {};
if (cfg.untilVar && vars[cfg.untilVar]) return new Date(vars[cfg.untilVar]).toISOString();
const ms = (Number(cfg.delayDays || 0) * 86400000)
+ (Number(cfg.delayHours || 0) * 3600000)
+ (Number(cfg.delayMinutes || 0) * 60000);
// Anchor to a context var when given (e.g. dueDate), plus any delay offset —
// so `{ untilVar: 'dueDate', delayDays: 7 }` means "due date + 7 days"
// (absolute), and an already-past anchor resumes immediately. Backward
// compatible: untilVar-only → the var; delay-only → now + delay. Behaviour
// change for the both-fields case (untilVar + delay): previously the delay was
// ignored and only the var returned; now they add (this is the intended
// waitGrace semantics — no seeded node relied on the old both-fields path).
const base = (cfg.untilVar && vars[cfg.untilVar]) ? new Date(vars[cfg.untilVar]) : new Date();
return new Date(base.getTime() + ms).toISOString();
return new Date(Date.now() + ms).toISOString();
}
function gateTimeout(config = {}) {
@@ -428,51 +421,6 @@ async function isBuiltinFlowActive(builtinKey) {
}
}
/**
* Enroll every open, unpaid invoice into the dunning flow by emitting
* `invoice.sent` for it called when the dunning built-in is turned ON so it
* starts chasing invoices that were already sent, not only new ones (#750).
* Idempotent: emitWorkflowEvent's per-(flow, entity) dedup means at most one
* run per invoice, so re-enabling is safe. Paired with the due-date-anchored
* grace wait, already-overdue invoices dun on their real timeline immediately.
*
* Scoped to `targetWorkflowId` (the dunning flow being enabled) so the backfill
* only enrolls invoices into dunning never into unrelated custom `invoice.sent`
* flows an admin may have built, which would fire their actions for every
* historical invoice.
*/
async function backfillDunningRuns(targetWorkflowId) {
let enrolled = 0;
try {
if (!(await db.schema.hasTable('invoices'))) return 0;
const invoices = await db('invoices')
.whereIn('status', ['sent', 'overdue'])
.whereNotNull('due_date')
.whereRaw('COALESCE(paid_amount_minor, 0) < total_amount_minor');
for (const inv of invoices) {
const ids = await emitWorkflowEvent('invoice.sent', {
entityType: 'invoice',
entityId: inv.id,
targetWorkflowId,
payload: {
invoiceId: inv.id,
invoiceNumber: inv.invoice_number,
eventId: inv.event_id || null,
customerAccountId: inv.customer_account_id,
dueDate: inv.due_date,
issueDate: inv.issue_date,
totalMinor: inv.total_amount_minor,
currency: inv.currency,
},
});
if (ids && ids.length) enrolled += 1;
}
} catch (e) {
logger.error('[workflow] dunning backfill failed', { error: e.message });
}
return enrolled;
}
/**
* Emit `event.date_approaching` for events entering an enabled flow's lead
* window. This is the trigger source for the pre-event reminder built-in, so it
@@ -597,7 +545,6 @@ async function testRun(workflowId, { entityType = null, entityId = null, payload
module.exports = {
emitWorkflowEvent,
isBuiltinFlowActive,
backfillDunningRuns,
runDueWaits,
emitDueEventReminders,
recoverStaleRuns,
+1 -17
View File
@@ -31,20 +31,4 @@ function ensureNumber(value, fallback = 0) {
return Number.isFinite(n) ? n : fallback;
}
/**
* Parse a value as an integer clamped to [min, max]; `undefined` on
* anything that doesn't parse (null, undefined, '', booleans, garbage).
*
* Exists because the inline guard `Number.isFinite(+v) ? parseInt(v)`
* disagrees with itself for null/''/true (`+null` is 0 but
* `parseInt(null)` is NaN), which let NaN through Math.min/Math.max
* and into an INSERT PostgreSQL rejects NaN for integer columns
* while SQLite silently stores NULL, so it only failed on PG.
*/
function clampIntOrUndefined(value, min, max) {
const n = parseInt(value, 10);
if (!Number.isFinite(n)) return undefined;
return Math.min(max, Math.max(min, n));
}
module.exports = { ensureInt, ensureNumber, clampIntOrUndefined };
module.exports = { ensureInt, ensureNumber };
+1 -1
View File
@@ -32,7 +32,7 @@ function hasValidAdminToken(req) {
// Critical: Verify token is valid before skipping rate limit
// This prevents invalid tokens from bypassing rate limiting
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Additional validation
if (!decoded || typeof decoded !== 'object') {
-34
View File
@@ -118,41 +118,7 @@ function assertContractPdfPath(filePath) {
]);
}
/**
* ZIP-slip guard. `node-stream-zip`'s `extract(null, root)` writes each entry
* to `path.join(root, entry.name)` without neutralising `../` a crafted
* archive with an entry named `../../uploads/logos/evil.svg` escapes `root`
* and overwrites arbitrary files (GHSA-jfhw-fj23-fx6x). Call this with the
* entry list BEFORE extract() to reject any entry that resolves outside the
* target directory.
*
* Purely lexical (path.resolve, no realpath) because the extraction target
* does not exist on disk yet. Absolute entry names (`/etc/passwd`) resolve
* away from `root` and are caught too. Throws AppError 400 on the first
* offending entry so the whole archive is refused.
*
* @param {Array<{name?: string}>} entries node-stream-zip entry objects
* @param {string} extractRoot directory extract() will write into
*/
function assertZipEntriesWithin(entries, extractRoot) {
const rootResolved = path.resolve(extractRoot);
const prefix = rootResolved.endsWith(path.sep) ? rootResolved : rootResolved + path.sep;
for (const entry of entries || []) {
const name = entry && entry.name;
if (!name) continue;
const target = path.resolve(rootResolved, name);
if (target !== rootResolved && !target.startsWith(prefix)) {
throw new AppError(
`Archive contains an entry that escapes the extraction directory: ${name}`,
400,
'ZIP_SLIP'
);
}
}
}
module.exports = {
assertPathInside,
assertContractPdfPath,
assertZipEntriesWithin,
};
-22
View File
@@ -1,22 +0,0 @@
const crypto = require('crypto');
/**
* Constant-time string comparison for secrets (share tokens, HMAC
* signatures, etc.). Returns false for non-strings or length mismatch
* without leaking timing beyond the (non-secret) length. Prevents an
* attacker from recovering a token byte-by-byte via response-time
* differences of a naive `a === b`.
*/
function timingSafeEqualStr(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') {
return false;
}
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) {
return false;
}
return crypto.timingSafeEqual(ab, bb);
}
module.exports = { timingSafeEqualStr };
-6
View File
@@ -141,16 +141,10 @@ services:
networks:
- picpeak-network
# Local mail catcher for development/testing only — never wanted in a real
# deployment. Gated behind the `dev` profile so a plain `docker compose up -d`
# does NOT start it; opt in with `docker compose --profile dev up -d`. Nothing
# depends on it (SMTP_HOST comes from .env), so gating is safe.
mailhog:
image: mailhog/mailhog:latest
container_name: picpeak-mailhog
restart: unless-stopped
profiles:
- dev
ports:
- "${MAILHOG_SMTP_PORT:-1025}:1025"
- "${MAILHOG_UI_PORT:-8025}:8025"
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+7 -17
View File
@@ -29,24 +29,14 @@ COPY . .
# Build the application
RUN npm run build
# Production stage (nginx stable 1.30 on Alpine 3.24). The 1.28 base is a
# dead end for the nginx HTTP/2 + rewrite/charset CVEs (CVE-2026-42055 /
# -49975 / -9256 / -48142): nginx.org's nginx-module-* packages pin the exact
# nginx version, so `apk upgrade` can never pull Alpine's patched 1.28.3-r4 —
# nginx fixes have to come via the base image tag, not apk.
FROM nginx:1.30-alpine
# Production stage (Alpine 3.23 with OpenSSL 3.5.5, patched libexpat)
FROM nginx:1.28-alpine
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
# stage's declaration never reached this stage. Consuming it in the RUN below
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
# image always picks up current Alpine security updates. Without this, the
# upgrade layer was cached indefinitely and builds kept shipping curl 8.19.0 /
# c-ares 1.34.6 for weeks after fixed packages landed in the Alpine repo.
ARG CACHEBUST=1
# Upgrade all Alpine packages for security fixes (nginx itself is version-
# pinned by its module packages — see the FROM comment above).
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
# Upgrade all Alpine packages for security fixes. The explicit nginx upgrade
# closes the HTTP/2 + rewrite/charset CVEs (CVE-2026-42055 / -49975 / -9256 /
# -48142, fixed in nginx 1.28.3-r4) and busts any cached layer still carrying
# the vulnerable r1 build.
RUN apk upgrade --no-cache && apk add --no-cache --upgrade nginx
# Install runtime dependencies. `gettext` provides envsubst, used by
# docker-entrypoint.sh for the BRAND_TITLE / BRAND_DESCRIPTION runtime
+2 -31
View File
@@ -237,25 +237,12 @@ server {
# Facebook, Slack, Twitter, etc.) don't run JS, so the SPA's client-side
# meta tags never reach them. Route those UAs to backend's /og handler
# via internal rewrite; humans fall through to the SPA via try_files.
# {0,2} extra path segments so the deeper gallery share shapes match too:
# /gallery/<slug> (public / share-token link)
# /gallery/<slug>/client-access (client-access; token is in ?query)
# /gallery/<slug>/show/<token> (slideshow; token is a PATH segment)
# The slideshow shape has TWO extra segments (show + token) the old
# single-segment `(?:/[^/]+)?` never matched it, so slideshow links fell
# through to the SPA and got only the generic site-wide OG (#699 follow-up).
# NB: the regex is QUOTED because the {0,2} quantifier's braces would
# otherwise be parsed as nginx config block delimiters.
location ~ "^/gallery/(?<gallery_slug>[A-Za-z0-9_-]+)(?:/[^/]+){0,2}/?$" {
location ~ ^/gallery/(?<gallery_slug>[A-Za-z0-9_-]+)(?:/[^/]+)?/?$ {
# Keep this list in sync with SOCIAL_CRAWLER_PATTERNS in
# backend/src/services/galleryOgService.js. WhatsAppBot / wa-bot
# and LinkPreview / Slack-ImgProxy added in #521 to catch
# business-API preview fetchers that aren't the main WhatsApp app.
# Viber + the broader set below added in #699 follow-up. Only
# CRAWLER-EXCLUSIVE tokens the backend OG response is meta-only (no
# redirect), so UAs shared with real human in-app browsers (WeChat's
# MicroMessenger, LINE's "Line/", Zalo, "InAppBrowser") are NOT added.
if ($http_user_agent ~* "(facebookexternalhit|facebookcatalog|facebot|Twitterbot|WhatsApp|WhatsAppBot|wa-bot|Slackbot|Slack-ImgProxy|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|Cardyb|OpenGraph|LinkPreview|Viber|Signal|Misskey|Pleroma|Synapse|Nextcloud|Rocket\.Chat|kakaotalk-scrap|Google-PageRenderer|OdklBot|ZoomBot)") {
if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|WhatsAppBot|wa-bot|Slackbot|Slack-ImgProxy|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph|LinkPreview)") {
rewrite ^ /og/gallery/$gallery_slug last;
}
try_files $uri $uri/ /index.html;
@@ -273,22 +260,6 @@ server {
proxy_set_header X-Forwarded-Proto $real_proto;
}
# Branded URL shortener (#699). The backend `/s/<short_slug>` route both
# 302-redirects humans to the target gallery AND server-renders OG for
# social crawlers. Without this proxy, `/s/...` fell through to the SPA
# (which has no /s/ route) so branded short links were dead for humans
# and crawlers alike. `^~` beats the regex SPA fallback below. The backend
# does its own UA detection, so no crawler `if` is needed here.
location ^~ /s/ {
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_proto;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.45.3",
"version": "3.79.1-beta.0",
"type": "module",
"scripts": {
"dev": "vite",
-8
View File
@@ -42,7 +42,6 @@ import { HoursLoggingPage } from './pages/admin/clients/HoursLoggingPage';
// (carved into its own chunk in vite.config.ts) doesn't ship with the
// main app. Only pages that visit /admin/clients/calendar fetch it.
const CalendarPage = lazy(() => import('./pages/admin/clients/CalendarPage').then((m) => ({ default: m.CalendarPage })));
const MessagesPage = lazy(() => import('./pages/admin/messages/MessagesPage').then((m) => ({ default: m.MessagesPage })));
import { QuoteResponsePage } from './pages/public/QuoteResponsePage';
import { ContractResponsePage } from './pages/public/ContractResponsePage';
import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage';
@@ -242,13 +241,6 @@ function App() {
<Route element={<RequireFeature flag="userManagement" />}>
<Route path="users" element={<UserManagementPage />} />
</Route>
<Route element={<RequireFeature flag="messaging" />}>
<Route path="messages" element={
<Suspense fallback={<Loading />}>
<MessagesPage />
</Suspense>
} />
</Route>
{/* Clients section (#354 follow-up). Parent route
gated by the top-level `clients` flag when off
the sidebar entry is hidden and every /admin/clients/*
@@ -11,7 +11,6 @@ import {
Users,
Briefcase,
Landmark,
Mail,
Workflow,
PanelLeftClose,
PanelLeftOpen,
@@ -65,7 +64,6 @@ const navigation: NavItem[] = [
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard, permission: false },
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' },
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' },
{ nameKey: 'navigation.messages', href: '/admin/messages', icon: Mail, permission: 'email.view', featureFlag: 'messaging' },
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
{ nameKey: 'navigation.systemHealth', href: '/admin/system-health', icon: Activity, permission: 'settings.view' },
@@ -1,163 +0,0 @@
/**
* Customer mailbox (hello@) configuration a second inbound IMAP box beyond
* the accounting rechnungen@ one, stored in `mail_accounts` under the fixed
* account_key 'customers'. Its mail feeds Messages Customers Inbox (body
* captured, attachments NOT routed to accounting). Shown when the `messaging`
* feature flag is on. Styled to match the Incoming Mail card.
*/
import React, { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Save, Server, User, Lock, Eye, EyeOff, PlugZap, Inbox } from 'lucide-react';
import { Button, Card, Input, Loading } from '../common';
import { emailService, type MailAccount } from '../../services/email.service';
import { useMutationWithToast, useModal } from '../../hooks';
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
const selectCls = 'w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark';
const ACCOUNT_KEY = 'customers';
export const CustomerMailboxCard: React.FC = () => {
const { t } = useTranslation();
const { data, isLoading } = useQuery({ queryKey: ['mail-accounts'], queryFn: () => emailService.listMailAccounts() });
const [cfg, setCfg] = useState<MailAccount>({ account_key: ACCOUNT_KEY, imap_host: '', imap_port: 993, imap_secure: true, imap_user: '', imap_pass: '', imap_folder: 'INBOX', enabled: false });
const passwordVisibility = useModal();
useEffect(() => {
if (!data) return;
const row = data.find((a) => a.account_key === ACCOUNT_KEY);
if (row) setCfg({ ...row, imap_pass: row.imap_pass || '' });
}, [data]);
const set = (k: keyof MailAccount, v: any) => setCfg((c) => ({ ...c, [k]: v }));
const save = useMutationWithToast({
mutationFn: () => {
if (!cfg.imap_host || !cfg.imap_port || !cfg.imap_user) {
return Promise.reject(new Error(t('email.customerMailbox.requiredFields', 'Host, port and username are required.')));
}
return emailService.saveMailAccount({ ...cfg, account_key: ACCOUNT_KEY, label: 'Customers' });
},
successMessage: t('email.customerMailbox.savedToast', 'Customer mailbox saved.'),
invalidateKeys: [['mail-accounts']],
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
});
const test = useMutationWithToast({
mutationFn: () => emailService.testMailAccount({ ...cfg, account_key: ACCOUNT_KEY }),
successMessage: (r) => t('email.customerMailbox.testOk', 'Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.', { folder: r.folder, messages: r.messages, unseen: r.unseen }),
errorMessage: (e: any) => e?.response?.data?.error || e.message || t('email.customerMailbox.testFailed', 'Connection failed.'),
});
if (isLoading) return <Loading />;
return (
<Card padding="md" className="mt-6">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
<Inbox className="w-5 h-5 text-neutral-400" />
{t('email.customerMailbox.title', 'Customer mailbox (hello@)')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('email.customerMailbox.subtitle', 'A second inbound mailbox for customer conversations. Its mail appears under Messages → Customers; attachments are not routed to Accounting.')}
</p>
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
<input type="checkbox" checked={!!cfg.enabled} onChange={(e) => set('enabled', e.target.checked)} />
{t('email.customerMailbox.enabled', 'Poll this mailbox every minute')}
</label>
<div>
<label className={labelCls}>{t('email.incoming.host', 'IMAP Host')} <span className="text-red-500">*</span></label>
<Input type="text" value={cfg.imap_host || ''} onChange={(e) => set('imap_host', e.target.value)} placeholder="imap.example.com" leftIcon={<Server className="w-5 h-5 text-neutral-400" />} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelCls}>{t('email.incoming.port', 'Port')} <span className="text-red-500">*</span></label>
<Input type="number" value={cfg.imap_port ?? 993} onChange={(e) => set('imap_port', parseInt(e.target.value, 10) || 0)} placeholder="993" />
</div>
<div>
<label className={labelCls}>{t('email.incoming.security', 'Security')}</label>
<select className={selectCls} value={cfg.imap_secure ? 'ssl' : 'plain'} onChange={(e) => set('imap_secure', e.target.value === 'ssl')}>
<option value="ssl">{t('email.incoming.ssl', 'SSL/TLS')}</option>
<option value="plain">{t('email.incoming.plain', 'None / STARTTLS')}</option>
</select>
</div>
</div>
<div>
<label className={labelCls}>{t('email.incoming.user', 'Username')} <span className="text-red-500">*</span></label>
<Input type="text" value={cfg.imap_user || ''} onChange={(e) => set('imap_user', e.target.value)} autoComplete="off" placeholder="hello@yourdomain.com" leftIcon={<User className="w-5 h-5 text-neutral-400" />} />
</div>
<div>
<label className={labelCls}>{t('email.incoming.pass', 'Password')}</label>
<div className="relative">
<Input type={passwordVisibility.isOpen ? 'text' : 'password'} value={cfg.imap_pass || ''} onChange={(e) => set('imap_pass', e.target.value)} autoComplete="new-password" placeholder={t('email.enterPassword', 'Enter password')} leftIcon={<Lock className="w-5 h-5 text-neutral-400" />} />
<button type="button" onClick={passwordVisibility.toggle} className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600">
{passwordVisibility.isOpen ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div>
<label className={labelCls}>{t('email.incoming.folder', 'Folder')}</label>
<Input type="text" value={cfg.imap_folder || 'INBOX'} onChange={(e) => set('imap_folder', e.target.value)} placeholder="INBOX" />
</div>
<div className="pt-4 mt-1 border-t border-neutral-200 dark:border-neutral-700">
<div className="text-sm font-semibold text-neutral-800 dark:text-neutral-200">
{t('email.customerMailbox.outgoing', 'Outgoing (SMTP)')}
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 mb-3">
{t('email.customerMailbox.outgoingHint', 'Replies from this mailbox send from here. Leave blank to fall back to the global outgoing address.')}
</p>
<div className="space-y-4">
<div>
<label className={labelCls}>{t('email.customerMailbox.fromEmail', 'From address')}</label>
<Input type="text" value={cfg.from_email || ''} onChange={(e) => set('from_email', e.target.value)} placeholder="hello@yourdomain.com" leftIcon={<User className="w-5 h-5 text-neutral-400" />} />
</div>
<div>
<label className={labelCls}>{t('email.customerMailbox.smtpHost', 'SMTP Host')}</label>
<Input type="text" value={cfg.smtp_host || ''} onChange={(e) => set('smtp_host', e.target.value)} placeholder="smtp.example.com" leftIcon={<Server className="w-5 h-5 text-neutral-400" />} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelCls}>{t('email.incoming.port', 'Port')}</label>
<Input type="number" value={cfg.smtp_port ?? 587} onChange={(e) => set('smtp_port', parseInt(e.target.value, 10) || 0)} placeholder="587" />
</div>
<div>
<label className={labelCls}>{t('email.incoming.security', 'Security')}</label>
<select className={selectCls} value={cfg.smtp_secure ? 'ssl' : 'starttls'} onChange={(e) => set('smtp_secure', e.target.value === 'ssl')}>
<option value="ssl">{t('email.customerMailbox.smtpSsl', 'SSL (465)')}</option>
<option value="starttls">{t('email.customerMailbox.smtpStarttls', 'STARTTLS (587)')}</option>
</select>
</div>
</div>
<div>
<label className={labelCls}>{t('email.customerMailbox.smtpUser', 'SMTP Username')}</label>
<Input type="text" value={cfg.smtp_user || ''} onChange={(e) => set('smtp_user', e.target.value)} autoComplete="off" placeholder="hello@yourdomain.com" leftIcon={<User className="w-5 h-5 text-neutral-400" />} />
</div>
<div>
<label className={labelCls}>{t('email.customerMailbox.smtpPass', 'SMTP Password')}</label>
<Input type={passwordVisibility.isOpen ? 'text' : 'password'} value={cfg.smtp_pass || ''} onChange={(e) => set('smtp_pass', e.target.value)} autoComplete="new-password" placeholder={t('email.enterPassword', 'Enter password')} leftIcon={<Lock className="w-5 h-5 text-neutral-400" />} />
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => test.mutate()} isLoading={test.isPending} disabled={!cfg.imap_host || !cfg.imap_user} leftIcon={<PlugZap className="w-5 h-5" />} className="whitespace-nowrap">
{t('email.incoming.test', 'Test connection')}
</Button>
<Button variant="primary" onClick={() => save.mutate()} isLoading={save.isPending} leftIcon={<Save className="w-5 h-5" />} className="flex-1 min-w-[12rem]">
{t('email.customerMailbox.save', 'Save Customer Mailbox')}
</Button>
</div>
</div>
</Card>
);
};
export default CustomerMailboxCard;
@@ -1,227 +0,0 @@
import React, { useRef, useState } from 'react';
import { Download, Upload, AlertTriangle, ShieldAlert, ExternalLink, CheckCircle2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../common';
import { api } from '../../config/api';
// Portable ".picpeak" roundtrip, split across two Backup Manager tabs:
// - PicpeakExportCard → Dashboard (making a backup)
// - PicpeakRestoreCard → Restore (restoring a backup)
// The manifest is bundled inside the .picpeak, so there is no separate
// "manifest only" download here.
interface RestoreResult {
tables: number;
filesRestored: number;
usesExternalMedia: boolean;
sessionInvalidated?: boolean;
}
// ── Download half (Dashboard) ────────────────────────────────────────────────
export const PicpeakExportCard: React.FC = () => {
const { t } = useTranslation();
const [includePhotos, setIncludePhotos] = useState(false);
const [downloading, setDownloading] = useState(false);
const handleDownload = async () => {
setDownloading(true);
try {
const res = await api.get('/admin/backup/picpeak/export', {
params: { includePhotos },
responseType: 'blob',
});
const cd = (res.headers['content-disposition'] as string) || '';
const match = cd.match(/filename="?([^"]+)"?/);
const filename = (match && match[1]) || 'picpeak-backup.picpeak';
const url = window.URL.createObjectURL(res.data as Blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (_) {
toast.error(t('backup.picpeak.downloadFailed', 'Could not create the backup file.'));
} finally {
setDownloading(false);
}
};
return (
<Card padding="lg">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.picpeak.title', 'Portable backup (.picpeak)')}
</h3>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('backup.picpeak.intro', 'Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.')}
</p>
<div className="mt-6">
<label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
<input
type="checkbox"
className="h-4 w-4 rounded border-neutral-300"
checked={includePhotos}
onChange={(e) => setIncludePhotos(e.target.checked)}
/>
{t('backup.picpeak.includePhotos', 'Include original gallery photos (larger file)')}
</label>
<div className="mt-3 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/50 dark:bg-amber-900/20">
<ShieldAlert className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
<p className="text-xs text-amber-800 dark:text-amber-200">
{t('backup.picpeak.secretsWarning', 'This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.')}
</p>
</div>
<Button
variant="outline"
className="mt-3"
isLoading={downloading}
onClick={handleDownload}
leftIcon={<Download className="h-4 w-4" />}
>
{t('backup.picpeak.download', 'Download .picpeak')}
</Button>
</div>
</Card>
);
};
PicpeakExportCard.displayName = 'PicpeakExportCard';
// ── Restore half (Restore tab) ───────────────────────────────────────────────
export const PicpeakRestoreCard: React.FC = () => {
const { t } = useTranslation();
const fileRef = useRef<HTMLInputElement>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [restoring, setRestoring] = useState(false);
const [result, setResult] = useState<RestoreResult | null>(null);
const onFilePick = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) setPendingFile(f);
e.target.value = ''; // let the user re-pick the same file after cancelling
};
const confirmRestore = async () => {
if (!pendingFile) return;
setRestoring(true);
try {
const fd = new FormData();
fd.append('backup', pendingFile);
const res = await api.post<RestoreResult>('/admin/backup/picpeak/import', fd);
setResult(res.data);
setPendingFile(null);
toast.success(t('backup.picpeak.restoreDone', 'Backup restored.'));
// The restore rewrote admin_users and the backend revoked our session
// (ids may have shifted). Send the operator to a fresh login rather than
// letting the now-stale token resolve to a different restored account.
if (res.data?.sessionInvalidated) {
toast.success(t('backup.picpeak.reloginRequired', 'Restore complete — please sign in again.'));
setTimeout(() => { window.location.href = '/admin/login'; }, 1500);
}
} catch (e: any) {
const msg = e.response?.data?.error || t('backup.picpeak.restoreFailed', 'Restore failed.');
toast.error(msg);
setPendingFile(null);
} finally {
setRestoring(false);
}
};
return (
<Card padding="lg">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')}
</h3>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')}
</p>
<input ref={fileRef} type="file" accept=".picpeak,application/zip" className="hidden" onChange={onFilePick} />
<Button
variant="outline"
className="mt-4"
onClick={() => fileRef.current?.click()}
leftIcon={<Upload className="h-4 w-4" />}
>
{t('backup.picpeak.chooseFile', 'Choose .picpeak file…')}
</Button>
{result && (
<div className="mt-4 rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-900/50 dark:bg-green-900/20">
<div className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0 text-green-600 dark:text-green-400" />
<div className="min-w-0">
<p className="text-sm font-medium text-green-800 dark:text-green-200">
{t('backup.picpeak.restoreDone', 'Backup restored.')}
</p>
<p className="mt-0.5 text-xs text-green-700 dark:text-green-300">
{t('backup.picpeak.restoreSummary', '{{tables}} tables and {{files}} files restored.', {
tables: result.tables,
files: result.filesRestored,
})}
</p>
{result.usesExternalMedia && (
<p className="mt-2 flex items-start gap-1 text-xs text-amber-800 dark:text-amber-300">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>
{t('backup.picpeak.externalMediaNote', 'This backup references an external-media library. Make sure external-media routing is configured on this instance.')}{' '}
<a
href="https://github.com/PicPeak/picpeak/blob/main/README.md"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline"
>
{t('backup.picpeak.externalMediaLink', 'Setup guide')}
<ExternalLink className="h-3 w-3" />
</a>
</span>
</p>
)}
<Button variant="primary" size="sm" className="mt-3" onClick={() => window.location.reload()}>
{t('backup.picpeak.reload', 'Reload app')}
</Button>
</div>
</div>
</div>
)}
{/* Destructive confirmation */}
{pendingFile && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-neutral-800">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-6 w-6 flex-shrink-0 text-red-600 dark:text-red-400" />
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.picpeak.confirmTitle', 'Restore will delete all current data')}
</h3>
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-300">
{t('backup.picpeak.confirmBody', 'This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.')}
</p>
<p className="mt-2 truncate text-xs text-neutral-500 dark:text-neutral-400">{pendingFile.name}</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="outline" onClick={() => setPendingFile(null)} disabled={restoring}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
className="!bg-red-600 hover:!bg-red-700"
isLoading={restoring}
onClick={confirmRestore}
>
{t('backup.picpeak.confirmRestore', 'Delete & restore')}
</Button>
</div>
</div>
</div>
)}
</Card>
);
};
PicpeakRestoreCard.displayName = 'PicpeakRestoreCard';
@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PicpeakRestoreCard } from './PicpeakBackupCard';
import {
RefreshCw,
AlertTriangle,
@@ -284,47 +283,12 @@ export const RestoreWizard = ({ onVerifyIntegrity } = {}) => {
)}
{restoreData.source === 'upload' && (
<div className="space-y-4">
{/* Two upload kinds: the working portable .picpeak restore, and the
legacy manifest+files upload (still a stub). */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={() => setRestoreData(prev => ({ ...prev, uploadType: 'picpeak' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.uploadType === 'picpeak'
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
<FileArchive className={`h-10 w-10 mb-2 mx-auto ${restoreData.uploadType === 'picpeak' ? 'text-primary' : 'text-neutral-400'}`} />
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.upload.picpeak.name', '.picpeak backup')}</h4>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.upload.picpeak.description', 'Portable full backup — restores everything (full override, keeps your current account).')}</p>
</button>
<button
onClick={() => setRestoreData(prev => ({ ...prev, uploadType: 'manifest' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.uploadType === 'manifest'
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
<Upload className={`h-10 w-10 mb-2 mx-auto ${restoreData.uploadType === 'manifest' ? 'text-primary' : 'text-neutral-400'}`} />
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.upload.manifest.name', 'Manifest + files')}</h4>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.upload.manifest.description', 'Upload a manifest and its backup files (legacy format).')}</p>
</button>
<Card className="p-4">
<div className="text-center py-8">
<Upload className="h-12 w-12 mx-auto mb-3 text-neutral-400" />
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.source.upload.comingSoon')}</p>
</div>
{restoreData.uploadType === 'picpeak' && <PicpeakRestoreCard />}
{restoreData.uploadType === 'manifest' && (
<Card className="p-4">
<div className="text-center py-8">
<Upload className="h-12 w-12 mx-auto mb-3 text-neutral-400" />
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.source.upload.manifestComingSoon', 'Manifest Upload functionality coming soon')}</p>
</div>
</Card>
)}
</div>
</Card>
)}
</div>
);
@@ -1,153 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { ShieldAlert } from 'lucide-react';
import { Button, Input } from '../common';
import type { FeatureKey } from '../../services/featureFlags.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { emailService, type EmailConfig } from '../../services/email.service';
// Features that need working SMTP to deliver anything.
const EMAIL_FEATURES: FeatureKey[] = ['reminderEmails', 'incomingMail', 'whatsapp', 'bills'];
interface Props {
selectedFeatures: Set<FeatureKey>;
onDone: () => void;
}
// Lean per-feature config, shown after the "How will you use PicPeak?" step.
// Only the sections a selected feature actually needs are rendered; everything
// else keeps its seeded defaults and is tunable later in Settings. Saving is
// best-effort per section — a failure never traps the user on setup.
export const SetupConfigStep: React.FC<Props> = ({ selectedFeatures, onDone }) => {
const { t } = useTranslation();
const showInvoicing = selectedFeatures.has('bills');
const showEmail = EMAIL_FEATURES.some((f) => selectedFeatures.has(f));
const [saving, setSaving] = useState(false);
const [inv, setInv] = useState({
companyName: '', addressLine1: '', postalCode: '', city: '', countryCode: '',
vatId: '', taxId: '', defaultCurrency: 'CHF', iban: '',
});
const [mail, setMail] = useState({
smtp_host: '', smtp_port: '587', smtp_user: '', smtp_pass: '', from_email: '', from_name: '',
});
const invField = (k: keyof typeof inv) => (e: React.ChangeEvent<HTMLInputElement>) =>
setInv((p) => ({ ...p, [k]: e.target.value }));
const mailField = (k: keyof typeof mail) => (e: React.ChangeEvent<HTMLInputElement>) =>
setMail((p) => ({ ...p, [k]: e.target.value }));
const finish = async () => {
setSaving(true);
try {
// Invoicing: only persist if they actually started filling it in.
if (showInvoicing && inv.companyName.trim()) {
await businessProfileService.update({
companyName: inv.companyName.trim(),
addressLine1: inv.addressLine1.trim(),
postalCode: inv.postalCode.trim(),
city: inv.city.trim(),
countryCode: inv.countryCode.trim(),
vatId: inv.vatId.trim(),
taxId: inv.taxId.trim(),
defaultCurrency: inv.defaultCurrency.trim() || 'CHF',
});
if (inv.iban.trim()) {
await businessProfileService.createBankAccount({
iban: inv.iban.replace(/\s+/g, ''),
accountHolder: inv.companyName.trim(),
currency: inv.defaultCurrency.trim() || 'CHF',
isDefault: true,
});
}
}
// Email: only persist if a host was entered.
if (showEmail && mail.smtp_host.trim()) {
const port = parseInt(mail.smtp_port, 10) || 587;
const config: EmailConfig = {
smtp_host: mail.smtp_host.trim(),
smtp_port: port,
smtp_secure: port === 465,
smtp_user: mail.smtp_user.trim(),
smtp_pass: mail.smtp_pass,
from_email: mail.from_email.trim(),
from_name: mail.from_name.trim(),
tls_reject_unauthorized: true,
};
await emailService.updateConfig(config);
}
} catch (_) {
toast.warn(t('setup.config.saveFailed', 'Some settings could not be saved — you can finish them in Settings.'));
} finally {
setSaving(false);
onDone();
}
};
return (
<div className="space-y-8">
<p className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2 text-xs text-neutral-600">
{t('setup.config.intro', 'A few details for the features you picked. Anything you skip keeps its default and can be set later in Settings.')}
</p>
{showInvoicing && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-800">{t('setup.config.invoicing', 'Invoicing details')}</h3>
<div className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3">
<ShieldAlert className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600" />
<p className="text-xs text-amber-800">
{t('setup.config.invoicingDisclaimer', 'Used on your invoices. Bank/IBAN and VAT details are your responsibility — verify them with your bank and Treuhänder/tax advisor.')}
</p>
</div>
<Input placeholder={t('setup.config.companyName', 'Company / legal name')} value={inv.companyName} onChange={invField('companyName')} />
<Input placeholder={t('setup.config.addressLine1', 'Street and number')} value={inv.addressLine1} onChange={invField('addressLine1')} />
<div className="grid grid-cols-3 gap-3">
<Input placeholder={t('setup.config.postalCode', 'Postal code')} value={inv.postalCode} onChange={invField('postalCode')} />
<div className="col-span-2"><Input placeholder={t('setup.config.city', 'City')} value={inv.city} onChange={invField('city')} /></div>
</div>
<div className="grid grid-cols-2 gap-3">
<Input placeholder={t('setup.config.countryCode', 'Country code (e.g. CH)')} value={inv.countryCode} onChange={invField('countryCode')} />
<Input placeholder={t('setup.config.currency', 'Currency (e.g. CHF)')} value={inv.defaultCurrency} onChange={invField('defaultCurrency')} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input placeholder={t('setup.config.vatId', 'VAT ID (or leave blank)')} value={inv.vatId} onChange={invField('vatId')} />
<Input placeholder={t('setup.config.taxId', 'Tax number (or VAT ID)')} value={inv.taxId} onChange={invField('taxId')} />
</div>
<Input placeholder={t('setup.config.iban', 'IBAN (for invoice payments)')} value={inv.iban} onChange={invField('iban')} />
</div>
)}
{showEmail && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-800">{t('setup.config.email', 'Email delivery (SMTP)')}</h3>
<p className="text-xs text-neutral-500">{t('setup.config.emailHint', 'Required to send reminders, invoices and notifications.')}</p>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2"><Input placeholder={t('setup.config.smtpHost', 'SMTP host')} value={mail.smtp_host} onChange={mailField('smtp_host')} /></div>
<Input placeholder={t('setup.config.smtpPort', 'Port')} value={mail.smtp_port} onChange={mailField('smtp_port')} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input placeholder={t('setup.config.smtpUser', 'Username')} value={mail.smtp_user} onChange={mailField('smtp_user')} autoComplete="off" />
<Input type="password" placeholder={t('setup.config.smtpPass', 'Password')} value={mail.smtp_pass} onChange={mailField('smtp_pass')} autoComplete="new-password" />
</div>
<div className="grid grid-cols-2 gap-3">
<Input type="email" placeholder={t('setup.config.fromEmail', 'From address')} value={mail.from_email} onChange={mailField('from_email')} />
<Input placeholder={t('setup.config.fromName', 'From name')} value={mail.from_name} onChange={mailField('from_name')} />
</div>
</div>
)}
<div className="flex gap-3">
<Button type="button" variant="outline" size="lg" onClick={onDone} disabled={saving}>
{t('setup.config.skip', 'Skip for now')}
</Button>
<Button type="button" variant="primary" size="lg" isLoading={saving} className="flex-1" onClick={finish}>
{t('setup.config.finish', 'Finish setup')}
</Button>
</div>
</div>
);
};
SetupConfigStep.displayName = 'SetupConfigStep';
@@ -137,26 +137,18 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
throw new Error('No URL provided');
}
// Build full URL for the image. Only relative paths are app-owned;
// an absolute URL is passed through untouched.
const isRelative = rawUrl.startsWith('/');
// Build full URL for the image
const fullImageUrl = rawUrl.startsWith('/admin')
? buildResourceUrl(`/api${rawUrl}`)
: isRelative
: rawUrl.startsWith('/')
? buildResourceUrl(rawUrl)
: rawUrl;
const headers: Record<string, string> = {};
// Attach the gallery bearer token ONLY to relative (same-app) image
// paths. Never send it to an absolute/external URL — that would leak
// gallery credentials cross-origin. AuthenticatedImage does not
// support external URLs by design.
if (isRelative) {
const slugForRequest = resolveSlug(rawUrl);
const token = getGalleryToken(slugForRequest);
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const slugForRequest = resolveSlug(rawUrl);
const token = getGalleryToken(slugForRequest);
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(fullImageUrl, {
@@ -90,13 +90,6 @@ export const LocalizedDateInput: React.FC<LocalizedDateInputProps> = ({
[d, mo, y] = [a, b, c];
}
if (!/^\d{1,2}$/.test(d) || !/^\d{1,2}$/.test(mo) || !/^\d{4}$/.test(y)) return '';
// Reject impossible calendar dates (day 00, month 13, 31 Feb…) — a partial
// value mid-backspace like "0/07/2026" is otherwise coerced to "2026-07-00",
// which is a valid string but an Invalid Date that crashes date-fns format()
// downstream. Round-trip through Date to confirm the components survive.
const yy = Number(y), mm = Number(mo), dd = Number(d);
const probe = new Date(yy, mm - 1, dd);
if (probe.getFullYear() !== yy || probe.getMonth() !== mm - 1 || probe.getDate() !== dd) return '';
return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`;
};
@@ -1,39 +0,0 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { vi } from 'vitest';
import { LocalizedDateInput } from '../LocalizedDateInput';
// Stub the settings hook so the component doesn't need a QueryClient; falls
// back to the default DD.MM.YYYY display format. The parse/validation under
// test is separator-independent.
vi.mock('../../../hooks/usePublicSettings', () => ({
usePublicSettings: () => ({ settings: {} }),
}));
describe('LocalizedDateInput', () => {
const renderInput = (value = '2026-07-07') => {
const onChange = vi.fn();
render(<LocalizedDateInput value={value} onChange={onChange} />);
const input = screen.getByDisplayValue('07.07.2026') as HTMLInputElement;
return { input, onChange };
};
it('does not commit an impossible date mid-edit (regression: backspacing the day → "2026-07-00" crashed the page)', () => {
const { input, onChange } = renderInput();
// Backspacing a day digit leaves "0.07.2026" — a syntactically complete but
// invalid date. It must NOT propagate (used to coerce to "2026-07-00",
// which crashed date-fns format() downstream).
fireEvent.change(input, { target: { value: '0.07.2026' } });
expect(onChange).not.toHaveBeenCalled();
// Nor may an out-of-range calendar date (31 Feb).
fireEvent.change(input, { target: { value: '31.02.2026' } });
expect(onChange).not.toHaveBeenCalled();
});
it('commits a complete, valid date as ISO', () => {
const { input, onChange } = renderInput();
fireEvent.change(input, { target: { value: '15.08.2026' } });
expect(onChange).toHaveBeenCalledWith('2026-08-15');
});
});
@@ -25,15 +25,6 @@ interface GalleryLayoutProps {
promo_mode?: 'inherit' | 'custom' | 'off';
promo_markdown?: string | null;
};
// Effective hero-logo visibility for THIS gallery, already resolved by the
// backend (per-event override, else the global branding toggle) (#756).
// When provided it wins over brandingSettings.logo_display_hero.
heroLogoVisible?: boolean;
// Effective hero-logo SIZE, resolved the same way (per-event override, else
// the global branding_logo_size) (#756). When provided it wins over
// brandingSettings.logo_size for the hero logo — so both render paths
// (this layout and the hero-header) size the logo identically.
heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge' | 'custom';
brandingSettings?: {
company_name?: string;
company_tagline?: string;
@@ -117,8 +108,6 @@ const HeaderDownloadButton: React.FC<{
export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
event,
brandingSettings,
heroLogoVisible,
heroLogoSize,
showLogout = false,
onLogout,
showDownloadAll = false,
@@ -168,10 +157,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
// Calculate logo size classes based on settings
const getLogoDimensions = (context: 'header' | 'hero'): { className: string; style?: React.CSSProperties } => {
// #756: the hero logo uses the backend-resolved per-event size (override,
// else global) so it matches the hero-header layout; the header logo keeps
// the global size.
const size = (context === 'hero' && heroLogoSize) ? heroLogoSize : (brandingSettings?.logo_size || 'medium');
const size = brandingSettings?.logo_size || 'medium';
const maxHeight = brandingSettings?.logo_max_height || 48;
if (size === 'custom') {
@@ -216,9 +202,6 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
if (context === 'header') {
return brandingSettings?.logo_display_header !== false;
} else {
// #756: prefer the backend-resolved per-event value (override, else
// global); fall back to the global branding toggle if not provided.
if (heroLogoVisible !== undefined) return heroLogoVisible;
return brandingSettings?.logo_display_hero !== false;
}
};
@@ -230,7 +213,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
};
const headerLogoSize = getLogoDimensions('header');
const heroLogoDimensions = getLogoDimensions('hero');
const heroLogoSize = getLogoDimensions('hero');
// Footer overhaul (#441 + #440). All five socials are independent;
// empty string = hide just that icon. Per-event promo override:
@@ -616,9 +599,9 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
'/picpeak-logo-transparent.png'
}
alt={brandingSettings?.company_name || 'PicPeak'}
className={`${heroLogoDimensions.className} w-auto object-contain mx-auto`}
className={`${heroLogoSize.className} w-auto object-contain mx-auto`}
style={{
...(heroLogoDimensions.style || {}),
...(heroLogoSize.style || {}),
// Only apply brightness/invert filter to default logo; custom logos display as-is
filter: brandLogoUrl
? 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
@@ -820,8 +820,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
promo_markdown: (data?.event as { promo_markdown?: string | null })?.promo_markdown,
}}
brandingSettings={brandingSettings}
heroLogoVisible={data?.event?.hero_logo_visible !== false}
heroLogoSize={data?.event?.hero_logo_size || undefined}
headerStyle={data?.event?.header_style || theme.headerStyle}
showLogout={true}
onLogout={logout}
+3 -11
View File
@@ -56,19 +56,11 @@ api.interceptors.request.use(
const pathname = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
// Never attach the gallery token to an absolute URL. Requests to the
// app's own API use relative paths (axios prepends baseURL); an
// absolute URL could point at any origin, and extracting its
// `/gallery/...` pathname would otherwise match below and leak the
// bearer token cross-origin.
const isAbsoluteUrl = /^https?:\/\//i.test(config.url || '');
const isGalleryEndpoint = !isAbsoluteUrl && (
/^\/gallery\//.test(pathname)
const isGalleryEndpoint = /^\/gallery\//.test(pathname)
|| /^\/secure-images\//.test(pathname)
|| /^\/auth\/gallery\//.test(pathname));
|| /^\/auth\/gallery\//.test(pathname);
const isGallerySessionCheck = !isAbsoluteUrl && pathname === '/auth/session'
const isGallerySessionCheck = pathname === '/auth/session'
&& (!!paramSlug || window.location.pathname.startsWith('/gallery/'));
if (isGalleryEndpoint || isGallerySessionCheck) {
@@ -1,320 +0,0 @@
import React, { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { ShieldCheck, ShieldOff, Copy, Download, Check, KeyRound, AlertTriangle } from 'lucide-react';
import { Button, Card, Input, Loading, useConfirm } from '../../../components/common';
import { mfaService } from '../../../services/mfa.service';
// Per-user admin TOTP MFA management (issue #738). Lives on the admin's own
// account surface (Settings → General → Admin Account). Self-service: acts on
// the currently authenticated admin only.
interface RecoveryCodesPanelProps {
codes: string[];
onConfirm: () => void;
}
const RecoveryCodesPanel: React.FC<RecoveryCodesPanelProps> = ({ codes, onConfirm }) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const [acknowledged, setAcknowledged] = useState(false);
const asText = codes.join('\n');
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(asText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error(t('settings.mfa.copyFailed'));
}
};
const handleDownload = () => {
const blob = new Blob([`${asText}\n`], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'picpeak-recovery-codes.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-800 dark:text-amber-200">{t('settings.mfa.recoveryCodesWarning')}</p>
</div>
<div className="grid grid-cols-2 gap-2 p-4 rounded-lg bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 font-mono text-sm text-neutral-900 dark:text-neutral-100">
{codes.map((code) => (
<span key={code} className="select-all">{code}</span>
))}
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" leftIcon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />} onClick={handleCopy}>
{copied ? t('settings.mfa.copied') : t('settings.mfa.copy')}
</Button>
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />} onClick={handleDownload}>
{t('settings.mfa.download')}
</Button>
</div>
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('settings.mfa.recoveryCodesAck')}</span>
</label>
<Button variant="primary" disabled={!acknowledged} onClick={onConfirm}>
{t('settings.mfa.done')}
</Button>
</div>
);
};
export const MfaSettingsCard: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const confirm = useConfirm();
const { data: status, isLoading } = useQuery({
queryKey: ['admin-mfa-status'],
queryFn: () => mfaService.getStatus(),
});
// Enrollment flow state
const [setupData, setSetupData] = useState<Awaited<ReturnType<typeof mfaService.setup>> | null>(null);
const [enableCode, setEnableCode] = useState('');
const [enableError, setEnableError] = useState<string | null>(null);
// Recovery codes to display once (after enable or regenerate)
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
// Regenerate flow state
const [showRegenerate, setShowRegenerate] = useState(false);
const [regenerateCode, setRegenerateCode] = useState('');
const [regenerateError, setRegenerateError] = useState<string | null>(null);
const invalidateStatus = () => queryClient.invalidateQueries({ queryKey: ['admin-mfa-status'] });
const errorMessage = (error: any, fallbackKey: string): string =>
error?.response?.data?.error || t(fallbackKey);
const setupMutation = useMutation({
mutationFn: () => mfaService.setup(),
onSuccess: (data) => {
setSetupData(data);
setEnableCode('');
setEnableError(null);
},
onError: (error) => toast.error(errorMessage(error, 'settings.mfa.setupFailed')),
});
const enableMutation = useMutation({
mutationFn: (code: string) => mfaService.enable(code),
onSuccess: (data) => {
setRecoveryCodes(data.recoveryCodes);
setSetupData(null);
setEnableCode('');
setEnableError(null);
invalidateStatus();
},
onError: (error) => setEnableError(errorMessage(error, 'settings.mfa.enableFailed')),
});
const disableMutation = useMutation({
mutationFn: (code: string) => mfaService.disable(code),
onSuccess: () => {
toast.success(t('settings.mfa.disabledToast'));
invalidateStatus();
},
onError: (error) => toast.error(errorMessage(error, 'settings.mfa.disableFailed')),
});
const regenerateMutation = useMutation({
mutationFn: (code: string) => mfaService.regenerateRecoveryCodes(code),
onSuccess: (data) => {
setRecoveryCodes(data.recoveryCodes);
setShowRegenerate(false);
setRegenerateCode('');
setRegenerateError(null);
invalidateStatus();
},
onError: (error) => setRegenerateError(errorMessage(error, 'settings.mfa.regenerateFailed')),
});
const handleDisable = async () => {
const code = window.prompt(t('settings.mfa.disablePrompt'));
if (code === null) return;
const trimmed = code.trim();
if (!trimmed) {
toast.error(t('settings.mfa.codeRequired'));
return;
}
const ok = await confirm({
title: t('settings.mfa.disableConfirmTitle'),
message: t('settings.mfa.disableConfirmMessage'),
variant: 'danger',
confirmLabel: t('settings.mfa.disableConfirmButton'),
});
if (ok) disableMutation.mutate(trimmed);
};
return (
<Card padding="md">
<div className="flex items-center gap-2 mb-1">
<ShieldCheck className="w-5 h-5 text-neutral-700 dark:text-neutral-300" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('settings.mfa.title')}</h2>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">{t('settings.mfa.description')}</p>
{isLoading ? (
<div className="py-8 flex justify-center">
<Loading size="md" />
</div>
) : recoveryCodes ? (
<RecoveryCodesPanel codes={recoveryCodes} onConfirm={() => setRecoveryCodes(null)} />
) : status?.enabled ? (
/* ---------------- Enrolled ---------------- */
<div className="space-y-4">
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" />
<span className="text-sm text-green-800 dark:text-green-200">{t('settings.mfa.enabledBadge')}</span>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t('settings.mfa.recoveryCodesRemaining', { count: status.recoveryCodesRemaining })}
</p>
{showRegenerate ? (
<div className="space-y-3 p-4 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-sm text-neutral-700 dark:text-neutral-300">{t('settings.mfa.regenerateHelp')}</p>
<Input
type="text"
value={regenerateCode}
onChange={(e) => {
setRegenerateCode(e.target.value);
if (regenerateError) setRegenerateError(null);
}}
placeholder={t('settings.mfa.codePlaceholder')}
leftIcon={<KeyRound className="w-5 h-5 text-neutral-400" />}
error={regenerateError || undefined}
autoComplete="one-time-code"
/>
<div className="flex gap-2">
<Button
variant="primary"
isLoading={regenerateMutation.isPending}
onClick={() => {
const trimmed = regenerateCode.trim();
if (!trimmed) { setRegenerateError(t('settings.mfa.codeRequired')); return; }
regenerateMutation.mutate(trimmed);
}}
>
{t('settings.mfa.regenerateConfirm')}
</Button>
<Button variant="ghost" onClick={() => { setShowRegenerate(false); setRegenerateCode(''); setRegenerateError(null); }}>
{t('common.cancel')}
</Button>
</div>
</div>
) : (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setShowRegenerate(true)}>
{t('settings.mfa.regenerate')}
</Button>
<Button
variant="outline"
leftIcon={<ShieldOff className="w-4 h-4" />}
isLoading={disableMutation.isPending}
onClick={handleDisable}
>
{t('settings.mfa.disable')}
</Button>
</div>
)}
</div>
) : setupData ? (
/* ---------------- Setup in progress ---------------- */
<div className="space-y-4">
<p className="text-sm text-neutral-700 dark:text-neutral-300">{t('settings.mfa.setupScanInstruction')}</p>
<div className="flex flex-col sm:flex-row gap-4 items-start">
<img
src={setupData.qr}
alt={t('settings.mfa.qrAlt')}
className="w-44 h-44 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white p-2"
/>
<div className="space-y-2">
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.mfa.manualEntry')}</p>
<code className="block px-3 py-2 rounded bg-neutral-100 dark:bg-neutral-800 text-sm font-mono text-neutral-900 dark:text-neutral-100 break-all select-all">
{setupData.secret}
</code>
</div>
</div>
<div>
<label htmlFor="mfa-enable-code" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.mfa.enterCodeLabel')}
</label>
<Input
id="mfa-enable-code"
type="text"
value={enableCode}
onChange={(e) => {
setEnableCode(e.target.value);
if (enableError) setEnableError(null);
}}
placeholder={t('settings.mfa.codePlaceholder')}
leftIcon={<KeyRound className="w-5 h-5 text-neutral-400" />}
error={enableError || undefined}
inputMode="numeric"
autoComplete="one-time-code"
/>
</div>
<div className="flex gap-2">
<Button
variant="primary"
isLoading={enableMutation.isPending}
onClick={() => {
const trimmed = enableCode.trim();
if (!trimmed) { setEnableError(t('settings.mfa.codeRequired')); return; }
enableMutation.mutate(trimmed);
}}
>
{t('settings.mfa.enable')}
</Button>
<Button variant="ghost" onClick={() => { setSetupData(null); setEnableCode(''); setEnableError(null); }}>
{t('common.cancel')}
</Button>
</div>
</div>
) : (
/* ---------------- Not enrolled ---------------- */
<div className="space-y-3">
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.mfa.notEnrolled')}</p>
<Button
variant="primary"
leftIcon={<ShieldCheck className="w-5 h-5" />}
isLoading={setupMutation.isPending}
onClick={() => setupMutation.mutate()}
>
{t('settings.mfa.setUp')}
</Button>
</div>
)}
</Card>
);
};
@@ -35,6 +35,7 @@ export interface GeneralSettings {
export interface SecuritySettings {
password_min_length: number;
password_complexity: string;
enable_2fa: boolean;
session_timeout_minutes: number;
max_login_attempts: number;
attempt_window_minutes: number;
@@ -133,6 +134,7 @@ export function useSettingsState() {
const [securitySettings, setSecuritySettings] = useState<SecuritySettings>({
password_min_length: 8,
password_complexity: 'moderate',
enable_2fa: false,
session_timeout_minutes: 60,
max_login_attempts: 5,
attempt_window_minutes: 15,
@@ -229,6 +231,7 @@ export function useSettingsState() {
setSecuritySettings({
password_min_length: toNumber(settings.security_password_min_length, 8),
password_complexity: settings.security_password_complexity ?? 'moderate',
enable_2fa: toBoolean(settings.security_enable_2fa, false),
session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60),
max_login_attempts: toNumber(settings.security_max_login_attempts, 5),
attempt_window_minutes: toNumber(settings.security_attempt_window_minutes, 15),
@@ -235,13 +235,15 @@ export const FeaturesTab: React.FC = () => {
title={t('settings.features.messaging.title', 'Messaging')}
description={t(
'settings.features.messaging.description',
'A unified Messages area: your sent + automated mail, the accounting inbox, and a customer mailbox (hello@) in one place — with reply and create-from-template composing. Configure the customer mailbox under Settings → Email; incoming mailboxes need the Incoming mail toggle too.',
'In-app threads with guests, attached to a gallery. Email is genuinely fine for most teams — this is for studios that want everything in one place. Coming soon.',
)}
status="new"
statusLabel={statusLabel('new')}
status="roadmap"
statusLabel={statusLabel('roadmap')}
sidebarLabel={t('settings.features.messaging.sidebar', 'Messages')}
enabled={staged.messaging}
onToggle={(next) => setFlag('messaging', next)}
onToggle={() => { /* locked */ }}
disabled
lockedReason={NOT_YET_AVAILABLE}
/>
</Section>
@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next';
import type { GeneralSettings } from '../hooks/useSettingsState';
import { MAX_FILES_PER_UPLOAD_LIMIT } from '../hooks/useSettingsState';
import { SUPPORTED_LANGUAGES } from "../../../components/common/LanguageSelector.tsx";
import { MfaSettingsCard } from '../components/MfaSettingsCard';
interface GeneralTabProps {
generalSettings: GeneralSettings;
@@ -95,10 +94,6 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
)}
</Card>
{/* Per-user two-factor authentication (issue #738) lives beside the
admin's own account details rather than the admin-wide Security tab. */}
<MfaSettingsCard />
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.general.siteConfiguration')}</h2>
@@ -1,5 +1,5 @@
import React from 'react';
import { Save, Key, AlertCircle, ShieldCheck } from 'lucide-react';
import { Save, Key, AlertCircle } from 'lucide-react';
import { Button, Card, Input } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import type { SecuritySettings } from '../hooks/useSettingsState';
@@ -124,15 +124,15 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
</div>
</div>
<div className="p-4 bg-neutral-50 dark:bg-neutral-800/60 border border-neutral-200 dark:border-neutral-700 rounded-lg">
<div className="flex items-start gap-3">
<ShieldCheck className="w-5 h-5 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-neutral-700 dark:text-neutral-300">
<p className="font-medium text-neutral-900 dark:text-neutral-100">{t('settings.security.twoFactorTitle')}</p>
<p className="mt-1">{t('settings.security.twoFactorNote')}</p>
</div>
</div>
</div>
<label className="flex items-center">
<input
type="checkbox"
checked={securitySettings.enable_2fa}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_2fa: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.security.enable2FA')}</span>
</label>
</div>
</Card>
+1 -7
View File
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow, isValid } from 'date-fns';
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
import { de, enUS, ptBR, fr } from 'date-fns/locale';
import { usePublicSettings } from './usePublicSettings';
@@ -32,11 +32,6 @@ export const useLocalizedDate = () => {
const format = (date: Date | string, formatStr?: string) => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
// date-fns `format` throws RangeError on an Invalid Date, which crashes the
// whole page when a call site renders a transient/partial date (e.g. the
// event-date field mid-edit). Return '' instead so a bad value degrades to
// blank rather than tearing down the tree.
if (!isValid(dateObj)) return '';
// Use admin-configured date format if available and no format string provided
let dateFormat = formatStr;
if (!dateFormat && settings?.general_date_format) {
@@ -55,7 +50,6 @@ export const useLocalizedDate = () => {
const formatDistanceToNow = (date: Date | string, options?: { addSuffix?: boolean }) => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
if (!isValid(dateObj)) return '';
return dateFnsFormatDistanceToNow(dateObj, { ...options, locale: getLocale() });
};
+6 -247
View File
@@ -197,7 +197,6 @@
"navigation": {
"dashboard": "Dashboard",
"events": "Veranstaltungen",
"messages": "Nachrichten",
"settings": "Einstellungen",
"systemHealth": "Systemzustand",
"archives": "Archive",
@@ -635,16 +634,7 @@
"upload": {
"name": "Backup hochladen",
"description": "Eine Backup-Datei hochladen",
"comingSoon": "Upload-Funktion kommt bald",
"manifestComingSoon": "Manifest-Upload-Funktion kommt bald",
"picpeak": {
"name": ".picpeak-Backup",
"description": "Portables Voll-Backup — stellt alles wieder her (vollständige Überschreibung, Ihr aktuelles Konto bleibt erhalten)."
},
"manifest": {
"name": "Manifest + Dateien",
"description": "Ein Manifest und die zugehörigen Backup-Dateien hochladen (Legacy-Format)."
}
"comingSoon": "Upload-Funktion kommt bald"
},
"configuration": {
"s3": "S3-Konfiguration",
@@ -1111,9 +1101,6 @@
"protectionLevelMaximum": "Maximum - DevTools-Erkennung & Canvas-Rendering",
"heroLogoSettings": "Hero-Logo-Einstellungen",
"heroLogoVisible": "Logo im Hero-Bereich anzeigen",
"heroLogoInherit": "Branding-Standard verwenden",
"heroLogoShow": "Immer anzeigen",
"heroLogoHide": "Immer ausblenden",
"heroLogoSize": "Logo-Größe",
"heroLogoSizeSmall": "Klein",
"heroLogoSizeMedium": "Mittel",
@@ -1426,14 +1413,13 @@
"attemptWindowMinutesHelp": "Zeitraum, in dem fehlgeschlagene Anmeldeversuche gezählt werden",
"lockoutDurationMinutes": "Sperrdauer (Minuten)",
"lockoutDurationMinutesHelp": "Wie lange Galerie oder Konto nach zu vielen Fehlern gesperrt bleiben",
"enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren",
"recaptchaSettings": "reCAPTCHA-Einstellungen",
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
"siteKey": "Site-Schlüssel",
"secretKey": "Geheimer Schlüssel",
"recaptchaHelp": "Holen Sie sich Ihre reCAPTCHA-Schlüssel von",
"saveSecuritySettings": "Sicherheitseinstellungen speichern",
"twoFactorTitle": "Zwei-Faktor-Authentifizierung",
"twoFactorNote": "Die Zwei-Faktor-Authentifizierung wird jetzt pro Admin unter Einstellungen → Allgemein → Admin-Konto verwaltet. Jeder Admin aktiviert sie für seine eigene Anmeldung."
"saveSecuritySettings": "Sicherheitseinstellungen speichern"
},
"events": {
"title": "Veranstaltungserstellung",
@@ -1793,27 +1779,7 @@
"title": "E-Mail-Einstellungen"
},
"backup": {
"title": "Backup",
"picpeak": {
"title": "Portables Backup (.picpeak)",
"intro": "Laden Sie eine einzelne, in sich geschlossene Datei herunter und laden Sie sie auf einer anderen Instanz hoch, um diese zu klonen — komplett im Browser.",
"includePhotos": "Original-Galeriefotos einschließen (größere Datei)",
"secretsWarning": "Diese Datei enthält Geheimnisse im Klartext (E-Mail-Passwort, Admin-Zugangsdaten, API-Schlüssel). Bewahren Sie sie sicher auf und übertragen Sie sie nur über vertrauenswürdige Kanäle.",
"download": ".picpeak herunterladen",
"downloadFailed": "Die Backup-Datei konnte nicht erstellt werden.",
"restoreTitle": "Aus einer .picpeak wiederherstellen",
"restoreIntro": "Laden Sie eine .picpeak von dieser oder einer anderen Instanz hoch. Nur dieselbe Datenbank-Engine.",
"chooseFile": ".picpeak-Datei auswählen…",
"restoreDone": "Backup wiederhergestellt.",
"restoreFailed": "Wiederherstellung fehlgeschlagen.",
"restoreSummary": "{{tables}} Tabellen und {{files}} Dateien wiederhergestellt.",
"externalMediaNote": "Dieses Backup verweist auf eine externe Medienbibliothek. Stellen Sie sicher, dass das externe Medien-Routing auf dieser Instanz konfiguriert ist.",
"externalMediaLink": "Einrichtungsanleitung",
"reload": "App neu laden",
"confirmTitle": "Die Wiederherstellung löscht alle aktuellen Daten",
"confirmBody": "Dies ersetzt ALLE Daten auf dieser Instanz dauerhaft durch das hochgeladene Backup, mit Ausnahme Ihres aktuellen Kontos. Dies kann nicht rückgängig gemacht werden.",
"confirmRestore": "Löschen & wiederherstellen"
}
"title": "Backup"
},
"branding": {
"title": "Branding"
@@ -2045,42 +2011,6 @@
"testSend": "Test senden",
"testSending": "Senden…",
"testSentToast": "Testnachricht gesendet (ID: {{id}})."
},
"mfa": {
"title": "Zwei-Faktor-Authentifizierung",
"description": "Sichere deine Admin-Anmeldung mit einem zweiten Schritt über eine Authenticator-App (TOTP).",
"notEnrolled": "Die Zwei-Faktor-Authentifizierung ist für dein Konto nicht aktiviert.",
"setUp": "Einrichten",
"setupScanInstruction": "Scanne diesen QR-Code mit deiner Authenticator-App (z. B. Google Authenticator, 1Password, Authy).",
"manualEntry": "Oder gib diesen Schlüssel manuell ein:",
"qrAlt": "QR-Code zur Zwei-Faktor-Einrichtung",
"enterCodeLabel": "Gib den 6-stelligen Code aus deiner App ein",
"codePlaceholder": "123456",
"enable": "Aktivieren",
"enabledBadge": "Die Zwei-Faktor-Authentifizierung ist aktiviert.",
"recoveryCodesRemaining": "Noch {{count}} Wiederherstellungscode übrig.",
"recoveryCodesRemaining_other": "Noch {{count}} Wiederherstellungscodes übrig.",
"regenerate": "Wiederherstellungscodes neu erzeugen",
"regenerateHelp": "Gib einen aktuellen Authentifizierungscode ein, um neue Wiederherstellungscodes zu erzeugen. Deine alten Codes werden ungültig.",
"regenerateConfirm": "Neu erzeugen",
"disable": "Deaktivieren",
"disablePrompt": "Gib einen aktuellen Authentifizierungs- oder Wiederherstellungscode ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren:",
"disableConfirmTitle": "Zwei-Faktor-Authentifizierung deaktivieren?",
"disableConfirmMessage": "Für dein Konto ist bei der Anmeldung dann kein zweiter Schritt mehr erforderlich. Du kannst sie jederzeit wieder aktivieren.",
"disableConfirmButton": "Deaktivieren",
"disabledToast": "Zwei-Faktor-Authentifizierung deaktiviert.",
"codeRequired": "Ein Code ist erforderlich.",
"recoveryCodesWarning": "Speichere diese Wiederherstellungscodes jetzt. Jeder kann einmal verwendet werden, falls du den Zugriff auf deine Authenticator-App verlierst. Sie werden nicht erneut angezeigt.",
"recoveryCodesAck": "Ich habe meine Wiederherstellungscodes an einem sicheren Ort gespeichert.",
"copy": "Kopieren",
"copied": "Kopiert",
"copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen.",
"download": ".txt herunterladen",
"done": "Fertig",
"setupFailed": "Zwei-Faktor-Einrichtung konnte nicht gestartet werden. Bitte versuche es erneut.",
"enableFailed": "Zwei-Faktor-Authentifizierung konnte nicht aktiviert werden. Prüfe den Code und versuche es erneut.",
"disableFailed": "Zwei-Faktor-Authentifizierung konnte nicht deaktiviert werden. Prüfe den Code und versuche es erneut.",
"regenerateFailed": "Wiederherstellungscodes konnten nicht neu erzeugt werden. Prüfe den Code und versuche es erneut."
}
},
"branding": {
@@ -3136,24 +3066,6 @@
"backup": "Backup & Wiederherstellung",
"system": "System-Updates",
"other": "Sonstige"
},
"customerMailbox": {
"title": "Kunden-Postfach (hello@)",
"subtitle": "Ein zweites Eingangspostfach für Kundenkommunikation. Die E-Mails erscheinen unter Nachrichten → Kunden; Anhänge werden nicht an die Buchhaltung weitergeleitet.",
"enabled": "Dieses Postfach jede Minute abrufen",
"outgoing": "Ausgang (SMTP)",
"outgoingHint": "Antworten aus diesem Postfach werden von hier gesendet. Leer lassen, um die globale Absenderadresse zu verwenden.",
"fromEmail": "Absenderadresse",
"smtpHost": "SMTP-Host",
"smtpUser": "SMTP-Benutzername",
"smtpPass": "SMTP-Passwort",
"smtpSsl": "SSL (465)",
"smtpStarttls": "STARTTLS (587)",
"save": "Kunden-Postfach speichern",
"savedToast": "Kunden-Postfach gespeichert.",
"requiredFields": "Host, Port und Benutzername sind erforderlich.",
"testOk": "Verbunden mit {{folder}} — {{messages}} Nachrichten, {{unseen}} ungelesen.",
"testFailed": "Verbindung fehlgeschlagen."
}
},
"cms": {
@@ -3555,58 +3467,6 @@
"subtitle": "Erstellen Sie Ihr Administrator-Konto, um loszulegen",
"tokenStepSubtitle": "Geben Sie zunächst Ihren einmaligen Setup-Token ein",
"accountStepSubtitle": "Erstellen Sie nun Ihr Administrator-Konto",
"usageSubtitle": "Wie möchten Sie PicPeak nutzen?",
"usageAlwaysOn": "Galerien, Analysen und Benutzerverwaltung sind immer enthalten. Wählen Sie unten optionale Funktionen — Sie können dies jederzeit in den Einstellungen ändern.",
"usageGroupCrm": "Kundenverwaltung",
"usageGroupAccounting": "Buchhaltung",
"usageGroupAutomation": "Automatisierung & Versand",
"usageDepsNote": "Rechnungen aktivieren automatisch den Buchhaltungsbereich.",
"usageSkip": "Nur mit Galerien fortfahren",
"finish": "Einrichtung abschließen",
"featuresSaveFailed": "Ihre Funktionsauswahl konnte nicht gespeichert werden — Sie können sie später unter Einstellungen → Funktionen festlegen.",
"restoreStepSubtitle": "Aus einem Backup wiederherstellen",
"restoreEntry": "Wechsel von einer anderen PicPeak-Instanz?",
"restoreEntryHint": "Stellen Sie stattdessen ein .picpeak-Backup wieder her, anstatt neu einzurichten.",
"restoreIntro": "Laden Sie ein .picpeak-Backup hoch, um eine andere Instanz auf diese zu klonen. Dies ersetzt alles außer dem gerade erstellten Konto.",
"config": {
"subtitle": "Richten Sie Ihre Funktionen ein",
"intro": "Einige Angaben zu den gewählten Funktionen. Was Sie überspringen, behält den Standard und kann später in den Einstellungen festgelegt werden.",
"invoicing": "Rechnungsdaten",
"invoicingDisclaimer": "Erscheint auf Ihren Rechnungen. Bank-/IBAN- und Mehrwertsteuerangaben liegen in Ihrer Verantwortung — prüfen Sie sie mit Ihrer Bank und Ihrem Treuhänder/Steuerberater.",
"companyName": "Firma / rechtlicher Name",
"addressLine1": "Strasse und Nummer",
"postalCode": "PLZ",
"city": "Ort",
"countryCode": "Ländercode (z. B. CH)",
"currency": "Währung (z. B. CHF)",
"vatId": "MwSt-Nummer (oder leer lassen)",
"taxId": "Steuernummer (oder MwSt-Nummer)",
"iban": "IBAN (für Rechnungszahlungen)",
"email": "E-Mail-Versand (SMTP)",
"emailHint": "Erforderlich, um Erinnerungen, Rechnungen und Benachrichtigungen zu senden.",
"smtpHost": "SMTP-Host",
"smtpPort": "Port",
"smtpUser": "Benutzername",
"smtpPass": "Passwort",
"fromEmail": "Absenderadresse",
"fromName": "Absendername",
"skip": "Vorerst überspringen",
"finish": "Einrichtung abschließen",
"saveFailed": "Einige Einstellungen konnten nicht gespeichert werden — Sie können sie in den Einstellungen abschließen."
},
"community": {
"subtitle": "Alles bereit",
"mission": "PicPeak gibt es, damit Fotografinnen und Fotografen ihre Galerien und Kundendaten selbst besitzen — auf dem eigenen Server, ohne monatliche SaaS-Gebühren. Danke, dass du es ausprobierst.",
"bugTitle": "Fehler gefunden?",
"bugDesc": "Melde ihn und wir sehen ihn uns an.",
"featureTitle": "Wunschfunktion?",
"featureDesc": "Sag uns, was PicPeak für dich besser machen würde.",
"starTitle": "Gefällt es dir? Erzähl davon",
"starDesc": "Empfiehl es anderen Fotografen, teile es und gib dem Projekt einen Stern auf GitHub.",
"supportTitle": "Entwicklung unterstützen",
"supportDesc": "Optional — spendier mir einen Kaffee, um PicPeak am Leben zu halten.",
"finish": "Fertig → Dashboard"
},
"stepOf": "Schritt {{current}} von {{total}}",
"continue": "Weiter",
"back": "Zurück",
@@ -3657,25 +3517,7 @@
"generalError": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
"needHelp": "Hilfe benötigt? Kontakt",
"poweredBy": "Bereitgestellt von PicPeak",
"devModeHint": "Entwicklungsmodus: E-Mail: admin@example.com, Passwort: admin123",
"mfa": {
"title": "Zwei-Faktor-Authentifizierung",
"subtitle": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
"recoverySubtitle": "Gib einen deiner Wiederherstellungscodes ein.",
"codeLabel": "Authentifizierungscode",
"codePlaceholder": "123456",
"recoveryCodeLabel": "Wiederherstellungscode",
"recoveryCodePlaceholder": "awzq-jca3-va",
"verify": "Bestätigen",
"back": "Zurück",
"useRecoveryCode": "Stattdessen Wiederherstellungscode verwenden",
"useAuthenticator": "Stattdessen Authenticator-App verwenden",
"codeRequired": "Gib deinen Authentifizierungscode ein",
"invalidCode": "Ungültiger Code. Bitte versuche es erneut.",
"sessionExpired": "Deine Bestätigungssitzung ist abgelaufen. Bitte melde dich erneut an.",
"locked": "Konto wegen zu vieler Versuche vorübergehend gesperrt. Versuche es später erneut.",
"lockedRetry": "Konto vorübergehend gesperrt. Versuche es in {{seconds}} Sekunden erneut."
}
"devModeHint": "Entwicklungsmodus: E-Mail: admin@example.com, Passwort: admin123"
},
"cssTemplates": {
"title": "Benutzerdefinierte CSS-Vorlagen",
@@ -4093,7 +3935,7 @@
"noEvents": "Noch keinem Event zugewiesen. Fügen Sie diesen Kunden über das Event-Formular hinzu.",
"email": "E-Mail",
"preferredLanguage": "Bevorzugte Sprache",
"preferredLanguageHint": "Steuert die Portal-Sprache, Angebots-/Rechnungs-PDFs sowie Rechnungs-E-Mails (Erinnerungen/Mahnungen). Neue Kunden erben standardmässig die Sprache aus dem Geschäftsprofil ({{lang}}); hier kann pro Kunde überschrieben werden.",
"preferredLanguageHint": "Steuert die Portal-Sprache sowie die Sprache von Angebots- und Rechnungs-PDFs. Neue Kunden erben standardmässig die Sprache aus dem Geschäftsprofil ({{lang}}); hier kann pro Kunde überschrieben werden.",
"salutation": "Anrede",
"salutationNone": "—",
"firstName": "Vorname",
@@ -5552,88 +5394,5 @@
"titlePlaceholder": "z. B. Hochzeitsvertrag Doe / Müller",
"validUntil": "Unterzeichnen bis (optional)"
}
},
"messages": {
"title": "Nachrichten",
"subtitle": "Gesendete, automatische und eingehende E-Mails — an einem Ort.",
"sync": "Abrufen",
"newMessage": "Neue Nachricht",
"searchPlaceholder": "In diesem Ordner suchen…",
"account": {
"all": "Alle E-Mails",
"customers": "Kunden",
"accounting": "Buchhaltung",
"automated": "Automatisch"
},
"folder": {
"inbox": "Posteingang",
"sent": "Gesendet",
"archived": "Archiviert",
"deleted": "Gelöscht"
},
"unified": "Konten übergreifend",
"systemGenerated": "Systemgeneriert",
"acrossAccounts": "Über alle Konten",
"selectPrompt": "Nachricht zum Lesen auswählen",
"noMessages": "Keine Nachrichten",
"noSearchResults": "Keine Treffer",
"noSubject": "(kein Betreff)",
"from": "von",
"to": "An",
"reply": "Antworten",
"replyAll": "Allen antworten",
"forward": "Weiterleiten",
"archive": "Archivieren",
"delete": "Löschen",
"deleteForever": "Endgültig löschen",
"restore": "Wiederherstellen",
"bookExpense": "Als Ausgabe buchen",
"rebill": "An Kunden weiterverrechnen",
"createQuote": "Angebot",
"createContract": "Vertrag",
"createGallery": "Galerie",
"createInvoice": "Rechnung",
"doc": {
"quote": "Angebot",
"contract": "Vertrag",
"invoice": "Rechnung",
"gallery": "Galerie"
},
"soon": "In einer späteren Phase verfügbar",
"viewDocument": "Dokument ansehen",
"openInAccounting": "Im Buchhaltungs-Posteingang öffnen",
"noInboundBody": "Für diese E-Mail wurde kein Nachrichtentext erfasst.",
"noBody": "Diese Nachricht wurde gesendet, bevor die Textspeicherung eingeführt wurde — keine Vorschau verfügbar.",
"loadError": "Diese Nachricht konnte nicht geladen werden.",
"attachments": "Anhang/Anhänge",
"notArchived": "noch nicht archiviert",
"sentAttachHint": "Gesendete Anhänge werden noch nicht archiviert — Phase 2.",
"document": "Dokument",
"previewUnavailable": "Vorschau nicht verfügbar",
"rasterNote": "Serverseitig gerenderte Vorschau — die Originaldatei erreicht den Browser nie.",
"close": "Schliessen",
"compose": "Nachricht verfassen",
"cancel": "Abbrechen",
"send": "Senden",
"subject": "Betreff",
"optional": "optional",
"bodyHint": "Bearbeite die Nachricht frei — füge vor dem Senden an beliebiger Stelle eine Notiz ein.",
"sendsFromHint": "Wird von deiner konfigurierten Absenderadresse gesendet.",
"sentToast": "Nachricht gesendet.",
"sendFailed": "Nachricht konnte nicht gesendet werden.",
"onWrote": "Am",
"customer": "Kunde",
"resolvingCustomer": "Absender wird einem Kunden zugeordnet…",
"noCustomerMatch": "Kein Kunde zu diesem Absender gefunden — oben suchen oder neuen Kunden anlegen.",
"createNewDoc": "Neues {{label}} erstellen",
"existingDocs": "Oder ein bestehendes referenzieren",
"noExistingDocs": "Für diesen Kunden gibt es noch keine Dokumente.",
"galleryCreateOnly": "Galerien sind event-basiert — dies öffnet den Event-Editor, wo du den Kunden zuweisen kannst.",
"syncOk": "Postfächer geprüft — {{count}} neu.",
"syncDisabled": "Eingehende E-Mails sind deaktiviert — unter Einstellungen → Funktionen aktivieren.",
"syncUnconfigured": "Zuerst ein Postfach unter Einstellungen → E-Mail konfigurieren.",
"syncBusy": "Es läuft bereits eine Synchronisierung.",
"syncFailed": "Synchronisierung fehlgeschlagen.",
"actionFailed": "Aktion fehlgeschlagen."
}
}

Some files were not shown because too many files have changed in this diff Show More