fix(events): publish-from-draft email carries the real password (#627)

Previously, publishing a password-protected DRAFT gallery sent the
gallery_created email with the literal sentinel "(set at creation)",
which the email processor localised to "The password you set when
creating the gallery" / "Das bei der Erstellung der Galerie gesetzte
Passwort". Root cause: at draft creation only the bcrypt hash is stored
(no plaintext column, by design); the publish endpoint had nowhere to
pull the actual password from. Create-and-publish-in-one-step worked
because the plaintext is still in memory at email-queue time.

Fix: the Publish action now opens a small PublishGalleryDialog that
prompts the admin to (re-)type the gallery password. The publish
endpoint accepts an optional `password` body, re-hashes + writes
`password_hash` so the stored hash matches what was just emailed (admins
who mistype at creation get a self-healing publish flow), and puts the
plaintext into the gallery_password email field. When the publish call
is made without a password (API-only consumers), behaviour falls back
to the legacy sentinel — no breaking change.

The window.confirm() publish flow is gone; the dialog handles the no-
password case too (plain confirm + Publish button).

I18n: EN + DE entries for the dialog. Other locales fall through to
the EN defaults via the t() default-value pattern.

No schema changes. No plaintext at rest.
This commit is contained in:
Paul Nothaft
2026-06-17 22:58:17 +02:00
parent ea6245cfde
commit 83b568ee2d
7 changed files with 228 additions and 20 deletions
+39 -4
View File
@@ -1062,9 +1062,25 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res)
});
// Publish a draft event (set is_draft=false and queue creation email)
router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
// Optional password the admin re-types in the publish dialog so the
// gallery_created email can carry the actual plaintext (#627). When the
// event is password-protected and the body carries a password, picpeak
// re-hashes + writes `password_hash` (the admin may have mistyped at
// creation; this guarantees the email content matches the live login
// password). When omitted, behaviour is the legacy sentinel for backward
// compat with API-only consumers.
body('password').optional().isString().isLength({ min: 6 })
.withMessage('Password must be at least 6 characters long'),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { password } = req.body;
const event = await db('events').where('id', id).first();
if (!event) {
@@ -1075,8 +1091,15 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require
return res.status(400).json({ error: 'Event is already published' });
}
// Set is_draft to false
await db('events').where('id', id).update({ is_draft: formatBoolean(false) });
const requirePassword = parseBooleanInput(event.require_password, true);
const publishUpdates = { is_draft: formatBoolean(false) };
if (requirePassword && password) {
// Re-hash so the stored hash matches what the email carries — even if
// the admin mistypes vs. what was set at draft creation, the gallery
// password the customer receives is the one that actually works.
publishUpdates.password_hash = await bcrypt.hash(password, getBcryptRounds());
}
await db('events').where('id', id).update(publishUpdates);
// Queue creation email
const customerEmail = event.customer_email || event.host_email;
@@ -1085,6 +1108,18 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require
const frontendBase = await getFrontendBaseUrl();
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
let galleryPasswordForEmail;
if (!requirePassword) {
galleryPasswordForEmail = 'No password required';
} else if (password) {
// Admin re-typed the password in the publish dialog — put it straight
// into the email so the customer can actually log in (#627).
galleryPasswordForEmail = password;
} else {
// Legacy fallback for API-only publishes that don't carry the password.
galleryPasswordForEmail = '(set at creation)';
}
const emailData = {
customer_name: customerName,
customer_email: customerEmail,
@@ -1092,7 +1127,7 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require
event_name: event.event_name,
event_date: event.event_date,
gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`,
gallery_password: parseBooleanInput(event.require_password, true) ? '(set at creation)' : 'No password required',
gallery_password: galleryPasswordForEmail,
expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null,
welcome_message: event.welcome_message || ''
};