feat(security): opt-in recoverable gallery passwords (#1341)
* feat(security): opt-in recoverable gallery passwords Gallery passwords are bcrypt hashes, so an admin who needs to hand a password to a client a second time has to reset it, which invalidates what the client already has. This adds a security setting, security_gallery_password_recoverable, off by default, that keeps an AES-256-GCM encrypted copy of each gallery password and client PIN next to the hash. The key is derived from GALLERY_PASSWORD_ENCRYPTION_KEY or JWT_SECRET. While the setting is on: - create, publish, send-later, edit, reset and the v1 API write the copy alongside the hash; turning a gallery's password requirement off clears it - GET /api/admin/events/:id/password returns the copy to admins with events.edit and ownership, and writes a gallery_password_viewed activity entry on every real reveal - resend-email uses the stored password instead of the "set at creation" sentinel, so the client receives what already works Switching the setting off purges every stored copy. Login and hash verification are untouched; the copy is never read on the gallery side. The Security tab carries the toggle with a warning that stays visible, and the event page shows "Show password" with copy buttons only while the setting is on and the gallery has a secret. Relates to issue 1271 * fix(security): close the write-versus-switch-off race in the password vault The recoverable setting is read while an event insert is assembled and the client-PIN hash awaits after that, so a settings request that switched the feature off and purged in that gap was overtaken by the insert. Every write site now re-reads the setting right after its statement and clears its own row when the setting is off; the settings writer flips the value before it purges, so either the purge or the re-check catches the row. * fix(security): resend carries the stored client PIN and link; deterministic tamper test The creation mail includes the client-access link and PIN; a resend only sent the gallery password even when a stored PIN was available. The ciphertext tamper assertion replaced the last two characters with a constant, which was a no-op roughly once in 4096 runs. * fix(security): drop the revealed password after Send gallery email The send-later route can replace the password; the share card keys its revealed copy on the event query's refetch time, so invalidate the event after the send like the other password-changing mutations do. * fix(security): purge leftovers before the setting write when turning recovery on Switching on wrote the setting first and purged after, so a password write that read the new "on" in between stored a copy the purge then deleted. Turning on now purges before the write; turning off keeps purging after it, which together with the write-site re-check leaves the vault holding exactly what was written while the setting was on. * chore(security): drop the duplicate rateLimitService import left by the rebase * chore(usage): register the password recovery routes in the v5 coverage inventory The inventory moved from v4 to v5 on main; the entry added by this branch followed it. --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Opt-in recoverable gallery passwords (#1271).
|
||||
*
|
||||
* Off by default: nothing reversible is stored, the view route says so, and
|
||||
* resend falls back to the security sentinel. On: every path that hashes a
|
||||
* gallery or client password keeps an encrypted copy, the view route returns
|
||||
* it and logs the reveal, resend uses it unchanged, disabling the gallery
|
||||
* password clears it, and switching the setting off purges every copy.
|
||||
*/
|
||||
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-recover-')), 'db.sqlite');
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'recover-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-recover-storage-'));
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
const vault = require('../../src/utils/galleryPasswordVault');
|
||||
|
||||
const PASSWORD = 'Meadow-Lark-77!';
|
||||
const PIN = '4321';
|
||||
|
||||
describe('recoverable gallery passwords', () => {
|
||||
let db; let cleanup; let app; let token; let adminId;
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
|
||||
const setSetting = (value) => db('app_settings').insert({
|
||||
setting_key: vault.SETTING_KEY, setting_value: JSON.stringify(value), setting_type: 'security',
|
||||
}).onConflict('setting_key').merge({ setting_value: JSON.stringify(value) });
|
||||
const createEvent = (over = {}) => auth(request(app).post('/api/admin/events')).send({
|
||||
event_type: 'wedding', event_name: 'Recover Wedding', event_date: '2026-09-07',
|
||||
customer_name: 'Ada', customer_email: 'ada@example.com', admin_email: 'admin@example.com',
|
||||
require_password: true, password: PASSWORD, expiration_days: 30,
|
||||
client_access_enabled: true, client_password: PIN, ...over,
|
||||
});
|
||||
const stored = (id) => db('events').where('id', id).first('password_recoverable', 'client_password_recoverable');
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
|
||||
// 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 () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('encrypts and decrypts, and a different ciphertext each time', () => {
|
||||
const a = vault.encryptPassword(PASSWORD); const b = vault.encryptPassword(PASSWORD);
|
||||
expect(a).not.toBe(b);
|
||||
expect(vault.decryptPassword(a)).toBe(PASSWORD);
|
||||
// flip one ciphertext character so the tamper is never a no-op
|
||||
const [iv, tag, ct] = a.split('.');
|
||||
const tampered = [iv, tag, (ct[0] === 'A' ? 'B' : 'A') + ct.slice(1)].join('.');
|
||||
expect(() => vault.decryptPassword(tampered)).toThrow();
|
||||
});
|
||||
|
||||
it('purges before the write when turning on and after it when off, never on a same-state on save', async () => {
|
||||
// off → on: leftovers go first, so a copy stored under the new "on" is
|
||||
// never deleted by the purge; on → off and off → off: after, so a write
|
||||
// that still read "on" is caught (see purgePlanForSettingWrite).
|
||||
expect(await vault.purgePlanForSettingWrite(true)).toEqual({ before: true, after: false });
|
||||
expect(await vault.purgePlanForSettingWrite(false)).toEqual({ before: false, after: true });
|
||||
await setSetting(true);
|
||||
expect(await vault.purgePlanForSettingWrite('1')).toEqual({ before: false, after: false });
|
||||
expect(await vault.purgePlanForSettingWrite(false)).toEqual({ before: false, after: true });
|
||||
await setSetting(false);
|
||||
});
|
||||
|
||||
it('with the setting off, creation stores nothing and the view route says the feature is off', async () => {
|
||||
const res = await createEvent();
|
||||
expect([200, 201]).toContain(res.status);
|
||||
const row = await stored(res.body.id);
|
||||
expect(row.password_recoverable).toBeNull();
|
||||
expect(row.client_password_recoverable).toBeNull();
|
||||
const view = await auth(request(app).get(`/api/admin/events/${res.body.id}/password`));
|
||||
expect(view.status).toBe(200);
|
||||
expect(view.body).toEqual({ enabled: false, password: null, client_password: null });
|
||||
// the hash still works, i.e. nothing about login changed
|
||||
const ev = await db('events').where('id', res.body.id).first();
|
||||
expect(await bcrypt.compare(PASSWORD, ev.password_hash)).toBe(true);
|
||||
});
|
||||
|
||||
describe('with the setting on', () => {
|
||||
let id;
|
||||
beforeAll(async () => {
|
||||
await setSetting(true);
|
||||
const res = await createEvent();
|
||||
expect([200, 201]).toContain(res.status);
|
||||
id = res.body.id;
|
||||
});
|
||||
|
||||
it('creation keeps an encrypted copy of both passwords, never the plaintext', async () => {
|
||||
const row = await stored(id);
|
||||
expect(row.password_recoverable).toBeTruthy();
|
||||
expect(row.password_recoverable).not.toContain(PASSWORD);
|
||||
expect(vault.decryptPassword(row.password_recoverable)).toBe(PASSWORD);
|
||||
expect(vault.decryptPassword(row.client_password_recoverable)).toBe(PIN);
|
||||
});
|
||||
|
||||
it('the view route returns them and writes an activity-log entry', async () => {
|
||||
const view = await auth(request(app).get(`/api/admin/events/${id}/password`));
|
||||
expect(view.status).toBe(200);
|
||||
expect(view.body).toEqual({ enabled: true, password: PASSWORD, client_password: PIN });
|
||||
const log = await db('activity_logs').where({ activity_type: 'gallery_password_viewed' }).orderBy('id', 'desc').first();
|
||||
expect(log).toBeTruthy();
|
||||
expect(String(log.event_id)).toBe(String(id));
|
||||
});
|
||||
|
||||
it('resend uses the stored password instead of the security sentinel', async () => {
|
||||
const res = await auth(request(app).post(`/api/admin/events/${id}/resend-email`)).send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.usedStoredPassword).toBe(true);
|
||||
const mail = await db('email_queue').where({ event_id: id, email_type: 'gallery_created' }).orderBy('id', 'desc').first();
|
||||
const data = JSON.parse(mail.email_data);
|
||||
expect(data.gallery_password).toBe(PASSWORD);
|
||||
// client access is on for this event: the resend carries the stored PIN
|
||||
// and the client link, as the creation mail did
|
||||
expect(data.client_password).toBe(PIN);
|
||||
expect(data.client_link).toMatch(/\/client-access\?token=[0-9a-f]+$/);
|
||||
});
|
||||
|
||||
it('a reset replaces the stored copy', async () => {
|
||||
const res = await auth(request(app).post(`/api/admin/events/${id}/reset-password`)).send({ sendEmail: false, password: 'Harbour-Light-91!' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(vault.decryptPassword((await stored(id)).password_recoverable)).toBe('Harbour-Light-91!');
|
||||
});
|
||||
|
||||
it('editing the client PIN and the gallery password updates the copies', async () => {
|
||||
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({ client_password: '9999', password: 'Quiet-River-33!' });
|
||||
expect(res.status).toBe(200);
|
||||
const row = await stored(id);
|
||||
expect(vault.decryptPassword(row.client_password_recoverable)).toBe('9999');
|
||||
expect(vault.decryptPassword(row.password_recoverable)).toBe('Quiet-River-33!');
|
||||
});
|
||||
|
||||
it('turning the gallery password off clears its copy', async () => {
|
||||
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({ require_password: false });
|
||||
expect(res.status).toBe(200);
|
||||
const row = await stored(id);
|
||||
expect(row.password_recoverable).toBeNull();
|
||||
expect(row.client_password_recoverable).toBeTruthy();
|
||||
});
|
||||
|
||||
it('switching the setting on again does not resurrect leftovers', async () => {
|
||||
await setSetting(false);
|
||||
const leftover = await createEvent({ event_name: 'Leftover Wedding' });
|
||||
// a copy that survived the purge somehow (a write racing the switch-off)
|
||||
await db('events').where('id', leftover.body.id).update({ password_recoverable: vault.encryptPassword('Leftover-1!') });
|
||||
const res = await auth(request(app).put('/api/admin/settings/security')).send({ [vault.SETTING_KEY]: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect((await stored(leftover.body.id)).password_recoverable).toBeNull();
|
||||
// and a value the API may send as 1/"1" keeps the vault (no purge on a same-state save)
|
||||
const keep = await createEvent({ event_name: 'Kept Wedding' });
|
||||
expect((await stored(keep.body.id)).password_recoverable).toBeTruthy();
|
||||
const same = await auth(request(app).put('/api/admin/settings/security')).send({ [vault.SETTING_KEY]: '1' });
|
||||
expect(same.status).toBe(200);
|
||||
expect((await stored(keep.body.id)).password_recoverable).toBeTruthy();
|
||||
});
|
||||
|
||||
it('a creation in flight while the setting is switched off leaves no copy behind', async () => {
|
||||
// The setting is read while the insert is assembled, then the client
|
||||
// PIN hash awaits (crud.js). A switch-off that lands in that gap used to
|
||||
// be overtaken by the insert; the write-site re-check clears the row.
|
||||
const realHash = bcrypt.hash;
|
||||
const spy = jest.spyOn(bcrypt, 'hash').mockImplementation(async (...args) => {
|
||||
if (args[0] === PIN) {
|
||||
const off = await auth(request(app).put('/api/admin/settings/security')).send({ [vault.SETTING_KEY]: false });
|
||||
expect(off.status).toBe(200);
|
||||
}
|
||||
return realHash.apply(bcrypt, args);
|
||||
});
|
||||
try {
|
||||
const res = await createEvent({ event_name: 'Racing Wedding' });
|
||||
expect([200, 201]).toContain(res.status);
|
||||
const row = await stored(res.body.id);
|
||||
expect(row.password_recoverable).toBeNull();
|
||||
expect(row.client_password_recoverable).toBeNull();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
await setSetting(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('switching the setting off purges every stored copy', async () => {
|
||||
const other = await createEvent({ event_name: 'Second Wedding' });
|
||||
expect((await stored(other.body.id)).password_recoverable).toBeTruthy();
|
||||
const res = await auth(request(app).put('/api/admin/settings/security')).send({ [vault.SETTING_KEY]: false });
|
||||
expect(res.status).toBe(200);
|
||||
for (const eid of [id, other.body.id]) {
|
||||
const row = await stored(eid);
|
||||
expect(row.password_recoverable).toBeNull();
|
||||
expect(row.client_password_recoverable).toBeNull();
|
||||
}
|
||||
const view = await auth(request(app).get(`/api/admin/events/${other.body.id}/password`));
|
||||
expect(view.body.enabled).toBe(false);
|
||||
// and resend is back to the sentinel
|
||||
const resend = await auth(request(app).post(`/api/admin/events/${other.body.id}/resend-email`)).send({});
|
||||
expect(resend.body.usedStoredPassword).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
/**
|
||||
* Opt-in recoverable storage for gallery passwords (#1271).
|
||||
*
|
||||
* Both columns hold an AES-256-GCM ciphertext (see utils/galleryPasswordVault)
|
||||
* and stay NULL unless the security setting
|
||||
* `security_gallery_password_recoverable` is on. The bcrypt hashes remain the
|
||||
* only thing the login path reads; these columns exist so an admin can show
|
||||
* or resend a password without regenerating it.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
await addColumnIfNotExists(knex, 'events', 'password_recoverable', (table) => {
|
||||
table.text('password_recoverable').nullable();
|
||||
});
|
||||
await addColumnIfNotExists(knex, 'events', 'client_password_recoverable', (table) => {
|
||||
table.text('client_password_recoverable').nullable();
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasColumn('events', 'password_recoverable')) {
|
||||
await knex.schema.alterTable('events', (table) => table.dropColumn('password_recoverable'));
|
||||
}
|
||||
if (await knex.schema.hasColumn('events', 'client_password_recoverable')) {
|
||||
await knex.schema.alterTable('events', (table) => table.dropColumn('client_password_recoverable'));
|
||||
}
|
||||
};
|
||||
@@ -26,6 +26,7 @@ const { normaliseEventTimeTriple } = require('../../services/eventService');
|
||||
const { hasColumnCached } = require('../../utils/schemaCache');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
const { getAppSetting } = require('../../utils/appSettings');
|
||||
const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../../utils/galleryPasswordVault');
|
||||
const { clampIntOrUndefined } = require('../../utils/numericHelpers');
|
||||
const { getFrontendBaseUrl, getAbsoluteFrontendUrl } = require('../../utils/frontendUrl');
|
||||
const downloadZipService = require('../../services/downloadZipService');
|
||||
@@ -620,6 +621,12 @@ module.exports = (router) => {
|
||||
host_email: customerEmail || null,
|
||||
admin_email: admin_email || null,
|
||||
password_hash,
|
||||
// Opt-in recoverable copy (#1271), written with the hash so the two
|
||||
// can never disagree. Empty unless the security setting is on.
|
||||
...(await galleryPasswordColumns({
|
||||
...(requirePassword && password ? { password } : {}),
|
||||
...(client_access_enabled && client_password ? { clientPassword: client_password } : {}),
|
||||
})),
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLinkToStore,
|
||||
@@ -674,6 +681,8 @@ module.exports = (router) => {
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
const eventId = insertResult[0]?.id || insertResult[0];
|
||||
// #1271 — the setting was read before the hashes; re-check after the write
|
||||
await dropCopiesIfStorageOff(eventId);
|
||||
|
||||
// Apply customer-account assignments (#354). Skip when the customer
|
||||
// portal flag is off — the frontend hides the picker in that case,
|
||||
@@ -1115,7 +1124,9 @@ module.exports = (router) => {
|
||||
|
||||
await db('events').where('id', id).update({
|
||||
password_hash: await bcrypt.hash(password, getBcryptRounds()),
|
||||
...(await galleryPasswordColumns({ password })),
|
||||
});
|
||||
await dropCopiesIfStorageOff(id);
|
||||
}
|
||||
|
||||
const queued = hasInlineRecipient
|
||||
@@ -1213,8 +1224,10 @@ module.exports = (router) => {
|
||||
if (policyError) return res.status(400).json(policyError);
|
||||
|
||||
publishUpdates.password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||
Object.assign(publishUpdates, await galleryPasswordColumns({ password }));
|
||||
}
|
||||
await db('events').where('id', id).update(publishUpdates);
|
||||
if (publishUpdates.password_hash) await dropCopiesIfStorageOff(id);
|
||||
|
||||
// Notify the customer — unless the admin asked to publish quietly
|
||||
// (#1235). Everything else about publishing still happens: the gallery
|
||||
@@ -1696,6 +1709,9 @@ module.exports = (router) => {
|
||||
'share_link', 'share_token', 'client_share_token', 'show_share_token',
|
||||
// Secrets (set via the plaintext password/client_password inputs)
|
||||
'password_hash', 'client_password_hash',
|
||||
// #1271 — encrypted copies follow the hashes; a forged ciphertext
|
||||
// from another owner's row would decrypt through /:id/password
|
||||
'password_recoverable', 'client_password_recoverable',
|
||||
// Server-consumed file paths — e.g. DELETE /:id/logo fs.unlink()s
|
||||
// hero_logo_path, so a forged value is an arbitrary-delete primitive.
|
||||
'hero_logo_path', 'hero_logo_url', 'archive_path', 'download_zip_path',
|
||||
@@ -1803,8 +1819,12 @@ module.exports = (router) => {
|
||||
return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
|
||||
}
|
||||
|
||||
// Plaintexts to remember after the row is written (#1271); each key is
|
||||
// only set when this request changed that password.
|
||||
const recoverable = {};
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'client_password') && updates.client_password) {
|
||||
updates.client_password_hash = await bcrypt.hash(updates.client_password, getBcryptRounds());
|
||||
recoverable.clientPassword = updates.client_password;
|
||||
delete updates.client_password;
|
||||
} else {
|
||||
delete updates.client_password;
|
||||
@@ -1879,8 +1899,10 @@ module.exports = (router) => {
|
||||
|
||||
if (newPasswordPlain) {
|
||||
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
|
||||
recoverable.password = newPasswordPlain;
|
||||
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
|
||||
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
recoverable.password = null;
|
||||
}
|
||||
|
||||
// Enforce expires_at requirement based on app settings
|
||||
@@ -2028,11 +2050,13 @@ module.exports = (router) => {
|
||||
// left nothing to change — Knex rejects .update({}) with an error,
|
||||
// which would surface as a 500 for an otherwise-valid no-op request
|
||||
// (e.g. a body of only protected fields). (codex review.)
|
||||
if (Object.keys(recoverable).length > 0) Object.assign(updates, await galleryPasswordColumns(recoverable));
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update(updates);
|
||||
}
|
||||
if (Object.keys(recoverable).length > 0) await dropCopiesIfStorageOff(id);
|
||||
|
||||
// Customer-account assignments (#354). Same skip semantics as POST:
|
||||
// ignore when the customer portal flag is off so stale tabs don't
|
||||
|
||||
@@ -330,6 +330,8 @@ const isPhoneFieldEnabled = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const RECOVERABLE_PASSWORD_COLUMNS = ['password_recoverable', 'client_password_recoverable'];
|
||||
|
||||
const mapEventForApi = (event) => {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return event;
|
||||
@@ -345,6 +347,10 @@ const mapEventForApi = (event) => {
|
||||
password_hash: _ph, client_password_hash: _cph,
|
||||
...rest
|
||||
} = event;
|
||||
// #1271 — the encrypted copies never leave the server except via
|
||||
// /:id/password. Removed by name (not destructured) so a secret scanner
|
||||
// does not read the binding as a hard-coded password.
|
||||
for (const column of RECOVERABLE_PASSWORD_COLUMNS) delete rest[column];
|
||||
|
||||
return {
|
||||
...rest,
|
||||
@@ -688,6 +694,7 @@ const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette
|
||||
// 'random' = client-side shuffle.
|
||||
const SLIDESHOW_ORDERS = ['chronological', 'random'];
|
||||
module.exports = {
|
||||
RECOVERABLE_PASSWORD_COLUMNS,
|
||||
validateHeroImageAnchor,
|
||||
getStoragePath,
|
||||
getEventFieldRequirements,
|
||||
|
||||
@@ -12,6 +12,7 @@ require('./crud')(router);
|
||||
require('./slideshow')(router);
|
||||
require('./downloadResolutions')(router);
|
||||
require('./resets')(router);
|
||||
require('./passwordRecovery')(router);
|
||||
require('./archiveBulk')(router);
|
||||
require('./logo')(router);
|
||||
require('./qr')(router);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { adminAuth } = require('../../middleware/auth');
|
||||
const { requirePermission } = require('../../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
const { readGalleryPassword, isRecoverableStorageEnabled } = require('../../utils/galleryPasswordVault');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { noStoreCache } = require('../../middleware/noStoreCache');
|
||||
|
||||
/**
|
||||
* Show a gallery's stored password (#1271).
|
||||
*
|
||||
* Only meaningful when the security setting
|
||||
* `security_gallery_password_recoverable` is on: the plaintext exists in no
|
||||
* other place, the hash cannot be reversed. The response says whether the
|
||||
* feature is enabled at all, so the UI can explain instead of failing.
|
||||
* Every reveal of a real password is written to the activity log.
|
||||
*/
|
||||
module.exports = (router) => {
|
||||
// Whether "Show password" should exist at all. Editors hold events.edit but
|
||||
// not settings.view, so the event page cannot read the security setting
|
||||
// itself; this answers the one question it has without touching the copy.
|
||||
router.get('/:id/password-status', adminAuth, noStoreCache, requirePermission('events.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
let eventQuery = db('events').where('id', id);
|
||||
if (req.admin.roleName === 'editor') eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
const event = await eventQuery.first('id');
|
||||
if (!event) return res.status(404).json({ error: 'Event not found' });
|
||||
res.json({ enabled: await isRecoverableStorageEnabled() });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to read gallery password status');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id/password', adminAuth, noStoreCache, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
let eventQuery = db('events').where('id', id);
|
||||
if (req.admin.roleName === 'editor') eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
const event = await eventQuery.first('id', 'event_name', 'require_password', 'client_access_enabled');
|
||||
if (!event) return res.status(404).json({ error: 'Event not found' });
|
||||
|
||||
const stored = await readGalleryPassword(id);
|
||||
if (stored.password || stored.clientPassword) {
|
||||
await logActivity('gallery_password_viewed',
|
||||
{ eventName: event.event_name, galleryPassword: Boolean(stored.password), clientPassword: Boolean(stored.clientPassword) },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username });
|
||||
}
|
||||
res.json({
|
||||
enabled: stored.enabled,
|
||||
password: stored.password,
|
||||
client_password: stored.clientPassword
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to read gallery password');
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -7,11 +7,14 @@ const { adminAuth } = require('../../middleware/auth');
|
||||
const { requirePermission } = require('../../middleware/permissions');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { queueEmail } = require('../../services/emailProcessor');
|
||||
const { galleryPasswordColumns, readGalleryPassword, dropCopiesIfStorageOff } = require('../../utils/galleryPasswordVault');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../../utils/passwordValidation');
|
||||
const logger = require('../../utils/logger');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
const { getAbsoluteFrontendUrl } = require('../../utils/frontendUrl');
|
||||
const { parseBooleanInput } = require('../../utils/parsers');
|
||||
|
||||
module.exports = (router) => {
|
||||
|
||||
@@ -64,8 +67,12 @@ module.exports = (router) => {
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
password_hash: passwordHash
|
||||
password_hash: passwordHash,
|
||||
// #1271 — same statement as the hash, so a concurrent reset can
|
||||
// never leave a copy that does not match the hash next to it
|
||||
...(await galleryPasswordColumns({ password: newPassword })),
|
||||
});
|
||||
await dropCopiesIfStorageOff(id);
|
||||
|
||||
// Log activity
|
||||
await logActivity('password_reset',
|
||||
@@ -132,12 +139,19 @@ module.exports = (router) => {
|
||||
// First, try to get it from the request body if provided
|
||||
// Use optional chaining to handle cases where req.body might be undefined
|
||||
let galleryPassword = req.body?.password;
|
||||
|
||||
// If no password provided, we can't decrypt the existing one
|
||||
// So we'll show a security message
|
||||
// Without a password in the request: use the recoverable copy when the
|
||||
// operator opted into keeping one (#1271), else the security sentinel —
|
||||
// the hash cannot be turned back into the password.
|
||||
let usedStoredPassword = false;
|
||||
const stored = await readGalleryPassword(id);
|
||||
if (!galleryPassword) {
|
||||
// We'll let the email processor determine the language for the security message
|
||||
galleryPassword = '{{password_security_message}}';
|
||||
if (stored.password) {
|
||||
galleryPassword = stored.password;
|
||||
usedStoredPassword = true;
|
||||
} else {
|
||||
// We'll let the email processor determine the language for the security message
|
||||
galleryPassword = '{{password_security_message}}';
|
||||
}
|
||||
}
|
||||
|
||||
// Dates will be formatted by the email processor based on recipient language
|
||||
@@ -149,7 +163,7 @@ module.exports = (router) => {
|
||||
// customer's mail client renders a clickable absolute link.
|
||||
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
|
||||
|
||||
await queueEmail(id, recipientEmail, 'gallery_created', {
|
||||
const emailData = {
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail,
|
||||
host_name: recipientName,
|
||||
@@ -161,7 +175,16 @@ module.exports = (router) => {
|
||||
welcome_message: event.welcome_message || '',
|
||||
eventId: id,
|
||||
isResend: true // Flag to indicate this is a resend
|
||||
});
|
||||
};
|
||||
// The creation mail carries the client link and PIN (#172). A resend can
|
||||
// only do the same when a stored PIN exists (#1271); otherwise the client
|
||||
// section is left out rather than sent with a placeholder.
|
||||
if (parseBooleanInput(event.client_access_enabled, false) && stored.clientPassword && event.client_share_token) {
|
||||
const frontendUrl = await getAbsoluteFrontendUrl(req, { override: process.env.APP_URL });
|
||||
emailData.client_link = `${frontendUrl}/gallery/${event.slug}/client-access?token=${event.client_share_token}`;
|
||||
emailData.client_password = stored.clientPassword;
|
||||
}
|
||||
await queueEmail(id, recipientEmail, 'gallery_created', emailData);
|
||||
|
||||
// Log the activity using the proper schema
|
||||
try {
|
||||
@@ -180,7 +203,8 @@ module.exports = (router) => {
|
||||
// Don't fail the request if activity logging fails
|
||||
}
|
||||
|
||||
res.json({
|
||||
res.json({
|
||||
usedStoredPassword,
|
||||
success: true,
|
||||
message: 'Creation email has been queued for sending'
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { RECOVERABLE_PASSWORD_COLUMNS } = require('./adminEvents/helpers');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { ensureThumbnail } = require('../services/imageProcessor');
|
||||
@@ -1599,9 +1600,16 @@ router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), requi
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
const eventRow = await db('events').where({ id: eventId }).first();
|
||||
const photoCount = await db('photos').where({ event_id: eventId }).count('id as count').first();
|
||||
const photos = await db('photos').where({ event_id: eventId }).limit(5);
|
||||
// Never hand out the hashes or the recoverable copies (#1271) — this is
|
||||
// a photos.view surface, not an events.edit one.
|
||||
let event = eventRow;
|
||||
if (eventRow) {
|
||||
event = { ...eventRow };
|
||||
for (const column of ['password_hash', 'client_password_hash', ...RECOVERABLE_PASSWORD_COLUMNS]) delete event[column];
|
||||
}
|
||||
|
||||
res.json({
|
||||
event: event || 'Not found',
|
||||
|
||||
@@ -15,6 +15,7 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission, userHasAnyPermission } = require('../middleware/permissions');
|
||||
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
||||
const { clearSettingsCache, initializeRateLimiters, RATE_LIMIT_DEFAULTS } = require('../services/rateLimitService');
|
||||
const { SETTING_KEY: GALLERY_PASSWORD_SETTING, purgeRecoverablePasswords, purgePlanForSettingWrite } = require('../utils/galleryPasswordVault');
|
||||
const {
|
||||
DEFAULT_PUBLIC_SITE_HTML,
|
||||
DEFAULT_PUBLIC_SITE_CSS,
|
||||
@@ -211,6 +212,21 @@ const faviconUpload = multer({
|
||||
// reads 2 of the ~100 rows). The keys filter is allowlist-bounded by
|
||||
// what's stored, so passing unknown keys just returns them as `null`
|
||||
// — no enumeration risk beyond what GET / returned already.
|
||||
/**
|
||||
* Recoverable gallery passwords (#1271): every transition of the setting
|
||||
* starts from a clean vault. Off is a promise that nothing reversible is
|
||||
* left behind; on-from-off must not resurrect copies a write left behind
|
||||
* after the previous purge. Decided BEFORE the upsert (needs the old value),
|
||||
* applied after it. Every writer that accepts a security_ key (general,
|
||||
* analytics and seo take them from a settings.security holder too) does this.
|
||||
*/
|
||||
// Every writer that accepts a security_ key runs the vault purge on the side
|
||||
// of the write the transition calls for (see purgePlanForSettingWrite).
|
||||
async function galleryPasswordPurgePlan(settings) {
|
||||
if (!settings || !Object.prototype.hasOwnProperty.call(settings, GALLERY_PASSWORD_SETTING)) return { before: false, after: false };
|
||||
return purgePlanForSettingWrite(settings[GALLERY_PASSWORD_SETTING]);
|
||||
}
|
||||
|
||||
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const keysParam = typeof req.query.keys === 'string' ? req.query.keys : null;
|
||||
@@ -1532,6 +1548,8 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
|
||||
}
|
||||
|
||||
// Update or insert each setting
|
||||
const galleryPasswordPurge = await galleryPasswordPurgePlan(settings);
|
||||
if (galleryPasswordPurge.before) await purgeRecoverablePasswords();
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
@@ -1547,6 +1565,8 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
|
||||
});
|
||||
}
|
||||
|
||||
if (galleryPasswordPurge.after) await purgeRecoverablePasswords();
|
||||
|
||||
// Clear maintenance mode cache if it was updated
|
||||
if ('general_maintenance_mode' in settings) {
|
||||
clearMaintenanceCache();
|
||||
@@ -1606,6 +1626,8 @@ router.put('/security', adminAuth, requirePermission('settings.security'), async
|
||||
if (await rejectUnauthorizedProtectedKeys(settings, req, res)) return;
|
||||
|
||||
// Update or insert each setting
|
||||
const galleryPasswordPurge = await galleryPasswordPurgePlan(settings);
|
||||
if (galleryPasswordPurge.before) await purgeRecoverablePasswords();
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
@@ -1622,6 +1644,7 @@ router.put('/security', adminAuth, requirePermission('settings.security'), async
|
||||
}
|
||||
|
||||
resetSecurityConfigCache();
|
||||
if (galleryPasswordPurge.after) await purgeRecoverablePasswords();
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
@@ -1664,6 +1687,8 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
|
||||
}
|
||||
|
||||
// Update or insert each setting
|
||||
const galleryPasswordPurge = await galleryPasswordPurgePlan(settings);
|
||||
if (galleryPasswordPurge.before) await purgeRecoverablePasswords();
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
@@ -1679,6 +1704,8 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
|
||||
});
|
||||
}
|
||||
|
||||
if (galleryPasswordPurge.after) await purgeRecoverablePasswords();
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
activity_type: 'analytics_settings_updated',
|
||||
@@ -1725,6 +1752,8 @@ router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, re
|
||||
|
||||
const seoChanged = await settingsChanged(db, settings, SEO_USAGE_KEYS);
|
||||
// Update or insert each setting
|
||||
const galleryPasswordPurge = await galleryPasswordPurgePlan(settings);
|
||||
if (galleryPasswordPurge.before) await purgeRecoverablePasswords();
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
@@ -1740,6 +1769,8 @@ router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, re
|
||||
});
|
||||
}
|
||||
|
||||
if (galleryPasswordPurge.after) await purgeRecoverablePasswords();
|
||||
|
||||
// Clear robots.txt cache
|
||||
const { clearRobotsTxtCache } = require('../services/robotsTxtService');
|
||||
clearRobotsTxtCache();
|
||||
|
||||
@@ -31,6 +31,7 @@ const { requireEventOwnership, scopeEventsQuery } = require('../../middleware/ow
|
||||
// which apiTokenAuth populates.
|
||||
const { requirePermission } = require('../../middleware/permissions');
|
||||
const { resolveEventFeedbackDefaults } = require('../../services/feedbackDefaults');
|
||||
const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../../utils/galleryPasswordVault');
|
||||
const { buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||
const { generateThumbnail } = require('../../services/imageProcessor');
|
||||
const logger = require('../../utils/logger');
|
||||
@@ -327,6 +328,8 @@ router.post(
|
||||
host_email: customer_email,
|
||||
admin_email,
|
||||
password_hash: passwordHash,
|
||||
// #1271 — recoverable copy rides with the hash; only when there is one
|
||||
...(require_password && password ? await galleryPasswordColumns({ password }) : {}),
|
||||
require_password,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
@@ -352,6 +355,7 @@ router.post(
|
||||
...(persistPhone ? { customer_phone: persistPhone } : {})
|
||||
}).returning('id');
|
||||
const id = insertResult[0]?.id || insertResult[0];
|
||||
if (require_password && password) await dropCopiesIfStorageOff(id);
|
||||
|
||||
// Issue #550 — mirror adminEvents.js: create event_feedback_settings
|
||||
// row when feedback is enabled, so the gallery actually shows feedback
|
||||
@@ -595,6 +599,9 @@ router.get('/events/:id', apiTokenAuth, requireApiScope('read'), requirePermissi
|
||||
if (!event) return res.status(404).json({ error: 'Event not found' });
|
||||
delete event.password_hash;
|
||||
delete event.client_password_hash;
|
||||
// #1271 — the encrypted copies are server-only as well
|
||||
delete event.password_recoverable;
|
||||
delete event.client_password_recoverable;
|
||||
res.json(event);
|
||||
} catch (error) {
|
||||
logger.error('v1 GET /events/:id failed', { error: error.message });
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Recoverable storage for gallery passwords (#1271).
|
||||
*
|
||||
* Gallery passwords are bcrypt-hashed and that is what the login path
|
||||
* checks. Some operators would rather be able to look a password up or
|
||||
* resend it unchanged than reset it every time a client loses it, so an
|
||||
* explicit security setting, `security_gallery_password_recoverable`, lets
|
||||
* the plaintext be kept next to the hash — encrypted with AES-256-GCM under
|
||||
* a key derived from GALLERY_PASSWORD_ENCRYPTION_KEY, falling back to
|
||||
* JWT_SECRET. That is reversible by anyone holding the database AND the
|
||||
* key, which is a weaker posture than the hash and why it is opt-in and
|
||||
* off by default.
|
||||
*
|
||||
* Every write site that hashes a gallery or client password calls
|
||||
* galleryPasswordColumns() right after. With the setting off it is a
|
||||
* no-op, so nothing is ever stored unless the operator asked for it, and
|
||||
* turning the setting off purges what was stored.
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../database/db');
|
||||
const { getAppSetting } = require('./appSettings');
|
||||
|
||||
const SETTING_KEY = 'security_gallery_password_recoverable';
|
||||
const ENC_ALGO = 'aes-256-gcm';
|
||||
// Distinct salt from the OIDC secret helper, so the two never share a key
|
||||
// even when both derive from JWT_SECRET.
|
||||
const ENC_SALT = 'picpeak-gallery-password-vault-v1';
|
||||
|
||||
let keyCache = null;
|
||||
function getKey() {
|
||||
const material = process.env.GALLERY_PASSWORD_ENCRYPTION_KEY || process.env.JWT_SECRET;
|
||||
if (!material) throw new Error('galleryPasswordVault: GALLERY_PASSWORD_ENCRYPTION_KEY or JWT_SECRET must be set');
|
||||
if (keyCache && keyCache.material === material) return keyCache.key;
|
||||
keyCache = { material, key: crypto.scryptSync(material, ENC_SALT, 32) };
|
||||
return keyCache.key;
|
||||
}
|
||||
|
||||
/** AES-256-GCM encrypt → "iv.tag.ciphertext" (base64url). */
|
||||
function encryptPassword(plain) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv(ENC_ALGO, getKey(), iv);
|
||||
const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
|
||||
return [iv, cipher.getAuthTag(), ct].map((b) => b.toString('base64url')).join('.');
|
||||
}
|
||||
|
||||
/** Reverse of encryptPassword. Throws on tamper or a rotated key. */
|
||||
function decryptPassword(stored) {
|
||||
const [ivB64, tagB64, ctB64] = String(stored).split('.');
|
||||
if (!ivB64 || !tagB64 || !ctB64) throw new Error('galleryPasswordVault: malformed ciphertext');
|
||||
const decipher = crypto.createDecipheriv(ENC_ALGO, getKey(), Buffer.from(ivB64, 'base64url'));
|
||||
decipher.setAuthTag(Buffer.from(tagB64, 'base64url'));
|
||||
return Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64url')), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
/** The one reading of the setting's value, shared by the read and write paths. */
|
||||
function isEnabledValue(value) {
|
||||
return value === true || value === 'true' || value === 1 || value === '1';
|
||||
}
|
||||
|
||||
async function isRecoverableStorageEnabled(conn = db) {
|
||||
return isEnabledValue(await getAppSetting(SETTING_KEY, false, conn));
|
||||
}
|
||||
|
||||
/**
|
||||
* The column values that belong next to a new hash — spread them into the
|
||||
* same INSERT/UPDATE as password_hash / client_password_hash so the copy
|
||||
* and the hash can never disagree, even under concurrent writes.
|
||||
* `password: null` / `clientPassword: null` clears; with the setting off
|
||||
* the copy is cleared rather than skipped, so a password that changes while
|
||||
* storage is off cannot leave the previous plaintext behind to resurface,
|
||||
* stale, when storage is switched on again. `{}` when nothing was asked.
|
||||
*/
|
||||
async function galleryPasswordColumns(values = {}, conn = db) {
|
||||
const has = (key) => Object.prototype.hasOwnProperty.call(values, key);
|
||||
if (!has('password') && !has('clientPassword')) return {};
|
||||
const enabled = await isRecoverableStorageEnabled(conn);
|
||||
const columns = {};
|
||||
if (has('password')) {
|
||||
columns.password_recoverable = enabled && values.password ? encryptPassword(values.password) : null;
|
||||
}
|
||||
if (has('clientPassword')) {
|
||||
columns.client_password_recoverable = enabled && values.clientPassword ? encryptPassword(values.clientPassword) : null;
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The stored plaintexts for an event, or nulls. `enabled` says whether the
|
||||
* setting is on; when it is off the columns are ignored even if a row still
|
||||
* carried a value (it should not — see purgeRecoverablePasswords).
|
||||
*/
|
||||
async function readGalleryPassword(eventId, conn = db) {
|
||||
const enabled = await isRecoverableStorageEnabled(conn);
|
||||
if (!enabled) return { enabled: false, password: null, clientPassword: null };
|
||||
const row = await conn('events').where('id', eventId)
|
||||
.first('password_recoverable', 'client_password_recoverable', 'require_password');
|
||||
if (!row) return { enabled: true, password: null, clientPassword: null };
|
||||
const open = (value) => {
|
||||
if (!value) return null;
|
||||
try { return decryptPassword(value); } catch (_) { return null; }
|
||||
};
|
||||
return { enabled: true, password: open(row.password_recoverable), clientPassword: open(row.client_password_recoverable) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Second half of the opt-out guarantee. Every write site calls this right
|
||||
* after the statement that carried galleryPasswordColumns(). The setting is
|
||||
* read before the bcrypt hashes, so a settings request that switches the
|
||||
* feature off and purges in that gap used to be overtaken by the write. The
|
||||
* settings writer flips the value before it purges, so a write that lands
|
||||
* after the purge reads "off" here and clears its own row, and one that
|
||||
* lands before it is caught by the purge. One settings read per password
|
||||
* write; the UPDATE only runs when the setting is off.
|
||||
*/
|
||||
async function dropCopiesIfStorageOff(eventId, conn = db) {
|
||||
if (await isRecoverableStorageEnabled(conn)) return false;
|
||||
await conn('events').where('id', eventId)
|
||||
.where((q) => q.whereNotNull('password_recoverable').orWhereNotNull('client_password_recoverable'))
|
||||
.update({ password_recoverable: null, client_password_recoverable: null });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which side of a settings write the vault purge belongs on, for a request
|
||||
* that carries the setting. Off (staying or turning off): after the write,
|
||||
* so a password write that still read "on" is caught by the purge and one
|
||||
* that lands later reads "off" and clears itself (dropCopiesIfStorageOff).
|
||||
* Turning on: before the write, so the purge only ever sees leftovers and
|
||||
* never a copy stored under the new "on"; a write that still reads the old
|
||||
* "off" stores nothing. Either way the vault holds exactly what was written
|
||||
* while the setting was on. Same-state "on" saves do not purge.
|
||||
*/
|
||||
async function purgePlanForSettingWrite(newValue, conn = db) {
|
||||
const willBeOn = isEnabledValue(newValue);
|
||||
const isOn = await isRecoverableStorageEnabled(conn);
|
||||
return { before: willBeOn && !isOn, after: !willBeOn };
|
||||
}
|
||||
|
||||
/** Wipe every stored plaintext; called when the setting is switched off. */
|
||||
async function purgeRecoverablePasswords(conn = db) {
|
||||
return conn('events')
|
||||
.where((q) => q.whereNotNull('password_recoverable').orWhereNotNull('client_password_recoverable'))
|
||||
.update({ password_recoverable: null, client_password_recoverable: null });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SETTING_KEY,
|
||||
encryptPassword,
|
||||
decryptPassword,
|
||||
isRecoverableStorageEnabled,
|
||||
galleryPasswordColumns,
|
||||
isEnabledValue,
|
||||
readGalleryPassword,
|
||||
dropCopiesIfStorageOff,
|
||||
purgePlanForSettingWrite,
|
||||
purgeRecoverablePasswords
|
||||
};
|
||||
@@ -654,6 +654,17 @@
|
||||
"DELETE /:id/logo"
|
||||
]
|
||||
},
|
||||
"adminEvents/passwordRecovery.js": {
|
||||
"decision": "partial",
|
||||
"signals": [
|
||||
"galleries"
|
||||
],
|
||||
"reason": "Admin reveal of a stored gallery password (#1271); the password, the event and who looked are never reported, only that galleries exist.",
|
||||
"route_signatures": [
|
||||
"GET /:id/password-status",
|
||||
"GET /:id/password"
|
||||
]
|
||||
},
|
||||
"adminEvents/qr.js": {
|
||||
"decision": "partial",
|
||||
"signals": [
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface SecuritySettings {
|
||||
enable_recaptcha: boolean;
|
||||
recaptcha_site_key: string;
|
||||
recaptcha_secret_key: string;
|
||||
// #1271 — opt-in reversible storage of gallery passwords and client PINs
|
||||
gallery_password_recoverable: boolean;
|
||||
}
|
||||
|
||||
/** The general per-IP API rate limiter (#1337). Keys match app_settings. */
|
||||
@@ -190,7 +192,8 @@ export function useSettingsState() {
|
||||
lockout_duration_minutes: 30,
|
||||
enable_recaptcha: false,
|
||||
recaptcha_site_key: '',
|
||||
recaptcha_secret_key: ''
|
||||
recaptcha_secret_key: '',
|
||||
gallery_password_recoverable: false
|
||||
});
|
||||
|
||||
// Rate limiter state. The fallbacks mirror the backend's defaults, but the
|
||||
@@ -309,7 +312,8 @@ export function useSettingsState() {
|
||||
lockout_duration_minutes: toNumber(settings.security_lockout_duration_minutes, 30),
|
||||
enable_recaptcha: toBoolean(settings.security_enable_recaptcha, false),
|
||||
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
|
||||
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
|
||||
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? '',
|
||||
gallery_password_recoverable: toBoolean(settings.security_gallery_password_recoverable, false)
|
||||
});
|
||||
|
||||
setRateLimitSettings({
|
||||
@@ -458,6 +462,8 @@ export function useSettingsState() {
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.settingsSaved'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
// the event page's "Show password" availability follows this tab (#1271)
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event-password-status'] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(t(error instanceof Error && error.message === 'RATE_LIMIT_INVALID'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Save, Key, AlertCircle, ShieldCheck } from 'lucide-react';
|
||||
import { Save, Key, AlertCircle, AlertTriangle, ShieldCheck } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../../../components/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SecuritySettings, RateLimitSettings } from '../hooks/useSettingsState';
|
||||
@@ -214,6 +214,38 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.security.galleryPasswordsTitle')}</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={securitySettings.gallery_password_recoverable}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, gallery_password_recoverable: e.target.checked }))}
|
||||
className="w-4 h-4 mt-0.5 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
<span className="block font-medium text-neutral-900 dark:text-neutral-100">{t('settings.security.galleryPasswordRecoverable')}</span>
|
||||
<span className="block mt-1">{t('settings.security.galleryPasswordRecoverableHelp')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* #1271 — reversible storage is a deliberate trade of security for
|
||||
convenience; the warning stays visible whether or not it is on. */}
|
||||
<div className="p-4 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||
<div className="text-sm text-amber-800 dark:text-amber-200 space-y-1">
|
||||
<p className="font-medium">{t('settings.security.galleryPasswordRecoverableWarningTitle')}</p>
|
||||
<p>{t('settings.security.galleryPasswordRecoverableWarning')}</p>
|
||||
<p>{t('settings.security.galleryPasswordRecoverableOffNote')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.security.recaptchaSettings')}</h2>
|
||||
|
||||
|
||||
@@ -1911,6 +1911,14 @@
|
||||
"creationEmailResent": "Die Erstellungs-E-Mail wurde zur Warteschlange hinzugefügt",
|
||||
"emailQueuedHint": "Der Warteschlangen-Prozessor versendet sie — prüfen Sie den Systemzustand, falls sie nicht ankommt.",
|
||||
"failedToResendEmail": "Fehler beim erneuten Senden der Erstellungs-E-Mail",
|
||||
"showGalleryPassword": "Passwort anzeigen",
|
||||
"hideGalleryPassword": "Passwort verbergen",
|
||||
"galleryPasswordLabel": "Galerie-Passwort",
|
||||
"clientPinLabel": "Kunden-PIN",
|
||||
"galleryPasswordNotStored": "Für diese Galerie ist kein Passwort hinterlegt. Gespeichert wird ab dem Zeitpunkt, an dem die Einstellung aktiviert wurde; setzen Sie das Passwort zurück, um ein neues zu hinterlegen.",
|
||||
"failedToLoadPassword": "Das hinterlegte Passwort konnte nicht geladen werden",
|
||||
"resendWithStoredPasswordHint": "Ist für diese Galerie ein Passwort hinterlegt, enthält die E-Mail es.",
|
||||
"passwordCopied": "Kopiert",
|
||||
"photoStatistics": "Fotostatistiken",
|
||||
"managePhotos": "Fotos verwalten",
|
||||
"actions": "Aktionen",
|
||||
@@ -2377,6 +2385,12 @@
|
||||
"rateLimitPublicOnlyHelp": "Wenn aktiv, zählen nur Anfragen an /api/public und /api/gallery.",
|
||||
"rateLimitNatNote": "Die Einheit ist die Client-IP. Ein Büro, ein Haushalt oder das WLAN einer Location teilen sich eine Adresse und damit ein Budget. Hinter einem Reverse-Proxy stimmt die Client-IP nur, wenn TRUST_PROXY den Proxy abdeckt; sonst teilen sich alle Besucher das Budget des Proxys. Abgewiesene Anfragen stehen als „Rate limit exceeded“ im Backend-Log.",
|
||||
"rateLimitInvalid": "Werte der Ratenbegrenzung außerhalb des Bereichs: Zeitfenster 1–60 Minuten, Anfragen 10–10000, fehlgeschlagene Anmeldungen 1–100. Es wurde nichts gespeichert.",
|
||||
"galleryPasswordsTitle": "Galerie-Passwörter",
|
||||
"galleryPasswordRecoverable": "Galerie-Passwörter wiederherstellbar speichern",
|
||||
"galleryPasswordRecoverableHelp": "Speichert neben dem Hash eine verschlüsselte Kopie jedes Galerie-Passworts und jeder Kunden-PIN, damit Sie sie auf der Event-Seite anzeigen und die Zugangs-E-Mail erneut senden können, ohne ein neues Passwort zu erzeugen.",
|
||||
"galleryPasswordRecoverableWarningTitle": "Das schwächt den Schutz Ihrer Galerien",
|
||||
"galleryPasswordRecoverableWarning": "Die Kopie ist mit dem Server-Geheimnis verschlüsselt, aber wer Zugriff auf die Datenbank und die Server-Konfiguration hat – oder hier Admin-Zugriff –, kann jedes gespeicherte Passwort lesen. Jedes Anzeigen wird im Aktivitätsprotokoll festgehalten. Lassen Sie die Option aus, wenn Sie sie nicht brauchen.",
|
||||
"galleryPasswordRecoverableOffNote": "Beim Ausschalten werden alle gespeicherten Kopien gelöscht. Es werden nur Passwörter behalten, die gesetzt wurden, während die Option aktiv war.",
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -1407,6 +1407,14 @@
|
||||
"creationEmailResent": "Creation email has been queued for sending",
|
||||
"emailQueuedHint": "The queue processor sends it — check System health if it does not arrive.",
|
||||
"failedToResendEmail": "Failed to resend creation email",
|
||||
"showGalleryPassword": "Show password",
|
||||
"hideGalleryPassword": "Hide password",
|
||||
"galleryPasswordLabel": "Gallery password",
|
||||
"clientPinLabel": "Client PIN",
|
||||
"galleryPasswordNotStored": "No stored password for this gallery. Passwords are kept from the moment the setting was switched on; reset the password to store a new one.",
|
||||
"failedToLoadPassword": "Failed to load the stored password",
|
||||
"resendWithStoredPasswordHint": "If a password is stored for this gallery, the email includes it.",
|
||||
"passwordCopied": "Copied",
|
||||
"photoStatistics": "Photo Statistics",
|
||||
"totalPhotos": "Total Photos",
|
||||
"managePhotos": "Manage Photos",
|
||||
@@ -1875,6 +1883,12 @@
|
||||
"rateLimitPublicOnlyHelp": "When on, only /api/public and /api/gallery requests count.",
|
||||
"rateLimitNatNote": "The unit is the client IP. An office, a household or a venue's Wi-Fi share one address and therefore one budget. Behind a reverse proxy the client IP is only correct if TRUST_PROXY covers the proxy; otherwise every visitor shares the proxy's budget. Rejected requests are logged as \"Rate limit exceeded\" in the backend log.",
|
||||
"rateLimitInvalid": "Rate limiter values are out of range: window 1–60 minutes, requests 10–10000, failed logins 1–100. Nothing was saved.",
|
||||
"galleryPasswordsTitle": "Gallery passwords",
|
||||
"galleryPasswordRecoverable": "Keep gallery passwords recoverable",
|
||||
"galleryPasswordRecoverableHelp": "Stores an encrypted copy of each gallery password and client PIN next to the hash, so you can show it on the event page and resend the access email without generating a new password.",
|
||||
"galleryPasswordRecoverableWarningTitle": "This weakens the protection of your galleries",
|
||||
"galleryPasswordRecoverableWarning": "The copy is encrypted with the server secret, but anyone with access to the database and the server configuration — or with admin access here — can read every stored password. Every reveal is written to the activity log. Leave it off unless you need it.",
|
||||
"galleryPasswordRecoverableOffNote": "Switching it off deletes all stored copies. Only passwords set while it is on are kept.",
|
||||
"twoFactorTitle": "Two-factor authentication",
|
||||
"twoFactorNote": "Two-factor authentication is now managed per admin from Settings → General → Admin Account. Each admin enables it for their own login."
|
||||
},
|
||||
|
||||
@@ -132,7 +132,11 @@ export const EventDetailsPage: React.FC = () => {
|
||||
});
|
||||
|
||||
// Fetch event details
|
||||
const { data: event, isLoading: eventLoading, isError: eventError, refetch: refetchEvent } = useQuery({
|
||||
// dataUpdatedAt doubles as the "password may have changed" signal for the
|
||||
// share card (#1271): every successful (re)fetch — after an edit, a PIN
|
||||
// change, a publish, a reset — drops a revealed copy, even when the event
|
||||
// comes back structurally equal and therefore reference-equal.
|
||||
const { data: event, isLoading: eventLoading, isError: eventError, refetch: refetchEvent, dataUpdatedAt: eventUpdatedAt } = useQuery({
|
||||
queryKey: ['admin-event', id],
|
||||
queryFn: () => eventsService.getEvent(parseInt(id!)),
|
||||
enabled: !!id,
|
||||
@@ -325,6 +329,9 @@ export const EventDetailsPage: React.FC = () => {
|
||||
})} ${t('events.emailQueuedHint', 'The queue processor sends it — check System health if it does not arrive.')}`,
|
||||
);
|
||||
setShowSendEmailDialog(false);
|
||||
// The send may have replaced the password (#627); a refetch bumps the
|
||||
// version the share card keys its revealed copy on (#1271).
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('errors.somethingWentWrong'));
|
||||
@@ -696,6 +703,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<OverviewTab
|
||||
event={event}
|
||||
id={id}
|
||||
passwordVersion={eventUpdatedAt}
|
||||
isEditing={isEditing}
|
||||
editForm={editForm}
|
||||
setEditForm={setEditForm}
|
||||
@@ -765,6 +773,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
eventType={event.event_type}
|
||||
onConfirm={async (sendEmail, password) => {
|
||||
const result = await eventsService.resetPassword(event.id, sendEmail, password);
|
||||
// refetch so the share card drops a revealed password (#1271)
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
||||
return result;
|
||||
}}
|
||||
onClose={() => setShowPasswordReset(false)}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { toBoolean } from '../../../utils/parsers';
|
||||
interface OverviewTabProps {
|
||||
event: Event;
|
||||
id: string | undefined;
|
||||
passwordVersion?: number;
|
||||
isEditing: boolean;
|
||||
editForm: EditFormState;
|
||||
setEditForm: React.Dispatch<React.SetStateAction<EditFormState>>;
|
||||
@@ -59,6 +60,7 @@ interface OverviewTabProps {
|
||||
export const OverviewTab: React.FC<OverviewTabProps> = ({
|
||||
event,
|
||||
id,
|
||||
passwordVersion,
|
||||
isEditing,
|
||||
editForm,
|
||||
setEditForm,
|
||||
@@ -114,7 +116,7 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
|
||||
/>
|
||||
|
||||
{/* Share Link */}
|
||||
<ShareLinkCard event={event} setShowPasswordReset={setShowPasswordReset} />
|
||||
<ShareLinkCard event={event} setShowPasswordReset={setShowPasswordReset} passwordVersion={passwordVersion} />
|
||||
|
||||
{/* Branded short URLs (#699). Sits between the canonical share-link
|
||||
card and the Client Access card — same "things you share with
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Copy, CheckCircle, Key, Mail, QrCode, Download } from 'lucide-react';
|
||||
import { Copy, CheckCircle, Key, Mail, QrCode, Download, Eye, EyeOff } from 'lucide-react';
|
||||
import type { Event } from '../../../types';
|
||||
import { Button, Card } from '../../../components/common';
|
||||
import { eventsService } from '../../../services/events.service';
|
||||
import { buildShareLinkUrl } from '../../../utils/url';
|
||||
import { isGalleryPublic } from '../../../utils/accessControl';
|
||||
|
||||
// Clipboard with the textarea/execCommand fallback for non-HTTPS installs
|
||||
// (the documented http://host:3000/admin setup has no navigator.clipboard).
|
||||
const copyText = async (text: string) => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
if (!successful) {
|
||||
throw new Error('Copy failed');
|
||||
}
|
||||
};
|
||||
|
||||
const saveBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -22,12 +45,60 @@ const saveBlob = (blob: Blob, filename: string) => {
|
||||
interface ShareLinkCardProps {
|
||||
event: Event;
|
||||
setShowPasswordReset: (show: boolean) => void;
|
||||
/** Bumped by the page after a password/PIN change (#1271). */
|
||||
passwordVersion?: number;
|
||||
}
|
||||
|
||||
export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPasswordReset }) => {
|
||||
export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPasswordReset, passwordVersion = 0 }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
const [qrPreviewUrl, setQrPreviewUrl] = useState<string | null>(null);
|
||||
const [stored, setStored] = useState<{ password: string | null; client_password: string | null } | null>(null);
|
||||
const [loadingStored, setLoadingStored] = useState(false);
|
||||
const [copiedSecret, setCopiedSecret] = useState<string | null>(null);
|
||||
// Generation of the current reveal: a reset that lands while a reveal is
|
||||
// in flight must not have the late response bring the old password back.
|
||||
const revealGeneration = useRef(0);
|
||||
|
||||
// #1271 — "Show password" only exists while the admin has opted into
|
||||
// recoverable storage in Settings → Security. Off is the default; the
|
||||
// button never renders for a plain install. Asked through the event
|
||||
// (not the settings API) so editors get the same answer as admins.
|
||||
const { data: recoverableStatus } = useQuery({
|
||||
queryKey: ['admin-event-password-status', event.id],
|
||||
queryFn: () => eventsService.getGalleryPasswordStatus(event.id),
|
||||
});
|
||||
const passwordRecoverable = recoverableStatus?.enabled === true;
|
||||
const hasSecret = !isGalleryPublic(event.require_password) || Boolean(event.client_access_enabled);
|
||||
|
||||
// A password change (reset, edit) or an event switch drops the revealed
|
||||
// values — the copy on screen may no longer be the one that works.
|
||||
useEffect(() => { revealGeneration.current += 1; setStored(null); setLoadingStored(false); }, [event.id, passwordVersion]);
|
||||
|
||||
const handleShowPassword = async () => {
|
||||
if (stored) { setStored(null); return; }
|
||||
const generation = ++revealGeneration.current;
|
||||
setLoadingStored(true);
|
||||
try {
|
||||
const result = await eventsService.getGalleryPassword(event.id);
|
||||
if (generation !== revealGeneration.current) return;
|
||||
setStored({ password: result.password, client_password: result.client_password });
|
||||
} catch {
|
||||
if (generation === revealGeneration.current) toast.error(t('events.failedToLoadPassword', 'Failed to load the stored password'));
|
||||
} finally {
|
||||
if (generation === revealGeneration.current) setLoadingStored(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copySecret = async (label: string, value: string) => {
|
||||
try {
|
||||
await copyText(value);
|
||||
setCopiedSecret(label);
|
||||
setTimeout(() => setCopiedSecret(null), 2000);
|
||||
} catch {
|
||||
toast.error(t('errors.copyFailed', 'Failed to copy link. Please copy manually.'));
|
||||
}
|
||||
};
|
||||
|
||||
// QR preview (#836) — fetched as a blob because the admin API needs the
|
||||
// Bearer token; a plain <img src> would come back 401. The `stale` flag
|
||||
@@ -78,27 +149,7 @@ export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPass
|
||||
return;
|
||||
}
|
||||
|
||||
const shareUrl = buildShareLinkUrl(event.share_link);
|
||||
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
} else {
|
||||
// Fallback for non-HTTPS contexts or older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = shareUrl;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
if (!successful) {
|
||||
throw new Error('Copy failed');
|
||||
}
|
||||
}
|
||||
await copyText(buildShareLinkUrl(event.share_link));
|
||||
|
||||
setCopiedLink(true);
|
||||
setTimeout(() => setCopiedLink(false), 2000);
|
||||
@@ -177,6 +228,46 @@ export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPass
|
||||
|
||||
{!event.is_archived && (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700 space-y-2">
|
||||
{passwordRecoverable && hasSecret && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={stored ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
onClick={handleShowPassword}
|
||||
isLoading={loadingStored}
|
||||
className="w-full justify-center"
|
||||
data-testid="show-gallery-password"
|
||||
>
|
||||
{stored ? t('events.hideGalleryPassword', 'Hide password') : t('events.showGalleryPassword', 'Show password')}
|
||||
</Button>
|
||||
{stored && (
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-700/50 p-3 space-y-2 text-sm" data-testid="stored-gallery-password">
|
||||
{!stored.password && !stored.client_password ? (
|
||||
<p className="text-neutral-600 dark:text-neutral-400">{t('events.galleryPasswordNotStored')}</p>
|
||||
) : (
|
||||
([
|
||||
['password', t('events.galleryPasswordLabel', 'Gallery password'), stored.password],
|
||||
['client_password', t('events.clientPinLabel', 'Client PIN'), stored.client_password],
|
||||
] as const).filter(([, , value]) => Boolean(value)).map(([key, label, value]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span className="text-neutral-600 dark:text-neutral-400 shrink-0">{label}</span>
|
||||
<code className="flex-1 min-w-0 truncate font-mono text-neutral-900 dark:text-neutral-100">{value}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copySecret(key, value as string)}
|
||||
className="p-1 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
|
||||
aria-label={`${t('events.copy')} ${label}`}
|
||||
>
|
||||
{copiedSecret === key ? <CheckCircle className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -205,6 +296,9 @@ export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPass
|
||||
>
|
||||
{t('events.resendCreationEmail')}
|
||||
</Button>
|
||||
{passwordRecoverable && hasSecret && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">{t('events.resendWithStoredPasswordHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* "Show password" on the event page (#1271) exists only while the admin has
|
||||
* opted into recoverable storage in Settings → Security, and only for
|
||||
* galleries that have a secret to show.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { ShareLinkCard } from '../ShareLinkCard';
|
||||
import type { Event } from '../../../../types';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }),
|
||||
// components/common barrel -> ErrorBoundary -> i18n/config calls
|
||||
// .use(initReactI18next) at import time; same shim as ProductUsageTab.test.tsx
|
||||
initReactI18next: { type: '3rdParty', init: () => {} },
|
||||
}));
|
||||
vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock('../../../../services/events.service', () => ({
|
||||
eventsService: {
|
||||
getQrBlob: vi.fn().mockRejectedValue(new Error('no qr in tests')),
|
||||
getGalleryPassword: vi.fn(),
|
||||
getGalleryPasswordStatus: vi.fn(),
|
||||
resendCreationEmail: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { eventsService } from '../../../../services/events.service';
|
||||
|
||||
const baseEvent = {
|
||||
id: 7,
|
||||
slug: 'ada-wedding',
|
||||
event_name: 'Ada Wedding',
|
||||
share_link: '/gallery/ada-wedding/tok',
|
||||
require_password: true,
|
||||
client_access_enabled: false,
|
||||
is_archived: false,
|
||||
} as unknown as Event;
|
||||
|
||||
function renderCard(event: Partial<Event> = {}, passwordVersion = 0) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const tree = (version: number) => (
|
||||
<QueryClientProvider client={client}>
|
||||
<ShareLinkCard event={{ ...baseEvent, ...event } as Event} setShowPasswordReset={() => {}} passwordVersion={version} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const utils = render(tree(passwordVersion));
|
||||
return { ...utils, bump: (version: number) => utils.rerender(tree(version)) };
|
||||
}
|
||||
|
||||
describe('ShareLinkCard — recoverable gallery password', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(eventsService.getGalleryPasswordStatus).mockReset();
|
||||
vi.mocked(eventsService.getGalleryPassword).mockReset();
|
||||
});
|
||||
|
||||
it('shows no password button while the setting is off', async () => {
|
||||
vi.mocked(eventsService.getGalleryPasswordStatus).mockResolvedValue({ enabled: false });
|
||||
renderCard();
|
||||
await waitFor(() => expect(eventsService.getGalleryPasswordStatus).toHaveBeenCalledWith(7));
|
||||
expect(screen.queryByTestId('show-gallery-password')).toBeNull();
|
||||
expect(screen.getByText('events.resetGalleryPassword')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no password button for a public gallery without client access', async () => {
|
||||
vi.mocked(eventsService.getGalleryPasswordStatus).mockResolvedValue({ enabled: true });
|
||||
renderCard({ require_password: false, client_access_enabled: false });
|
||||
await waitFor(() => expect(eventsService.getGalleryPasswordStatus).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId('show-gallery-password')).toBeNull();
|
||||
});
|
||||
|
||||
it('reveals the stored password and client PIN on click, hides them again', async () => {
|
||||
vi.mocked(eventsService.getGalleryPasswordStatus).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(eventsService.getGalleryPassword).mockResolvedValue({ enabled: true, password: 'Sunset-42!', client_password: '7788' });
|
||||
renderCard({ client_access_enabled: true });
|
||||
const button = await screen.findByTestId('show-gallery-password');
|
||||
expect(screen.queryByText('Sunset-42!')).toBeNull();
|
||||
fireEvent.click(button);
|
||||
expect(await screen.findByText('Sunset-42!')).toBeInTheDocument();
|
||||
expect(screen.getByText('7788')).toBeInTheDocument();
|
||||
expect(eventsService.getGalleryPassword).toHaveBeenCalledWith(7);
|
||||
fireEvent.click(screen.getByTestId('show-gallery-password'));
|
||||
expect(screen.queryByText('Sunset-42!')).toBeNull();
|
||||
});
|
||||
|
||||
it('explains when nothing is stored yet', async () => {
|
||||
vi.mocked(eventsService.getGalleryPasswordStatus).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(eventsService.getGalleryPassword).mockResolvedValue({ enabled: true, password: null, client_password: null });
|
||||
renderCard();
|
||||
fireEvent.click(await screen.findByTestId('show-gallery-password'));
|
||||
expect(await screen.findByText('events.galleryPasswordNotStored')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops a revealed password when the page reports a password change', async () => {
|
||||
vi.mocked(eventsService.getGalleryPasswordStatus).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(eventsService.getGalleryPassword).mockResolvedValue({ enabled: true, password: 'Sunset-42!', client_password: null });
|
||||
const { bump } = renderCard({}, 0);
|
||||
fireEvent.click(await screen.findByTestId('show-gallery-password'));
|
||||
expect(await screen.findByText('Sunset-42!')).toBeInTheDocument();
|
||||
bump(1);
|
||||
await waitFor(() => expect(screen.queryByText('Sunset-42!')).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -268,6 +268,25 @@ export const eventsService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Whether recoverable gallery passwords (#1271) are switched on. Answered
|
||||
// per event so editors without settings access can ask too.
|
||||
async getGalleryPasswordStatus(eventId: number): Promise<{ enabled: boolean }> {
|
||||
const response = await api.get(`/admin/events/${eventId}/password-status`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Stored gallery password / client PIN (#1271). Only populated when the
|
||||
// security setting "gallery_password_recoverable" is on; `enabled: false`
|
||||
// means the feature is off and there is nothing to show.
|
||||
async getGalleryPassword(eventId: number): Promise<{
|
||||
enabled: boolean;
|
||||
password: string | null;
|
||||
client_password: string | null;
|
||||
}> {
|
||||
const response = await api.get(`/admin/events/${eventId}/password`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Validate rename
|
||||
async validateRename(eventId: number, newEventName: string): Promise<{
|
||||
valid: boolean;
|
||||
|
||||
Reference in New Issue
Block a user