Release v2.11.1
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m47s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s

This commit is contained in:
2026-09-04 15:43:55 +01:00
parent 2c150b5b2e
commit 98f969ca0f
104 changed files with 3559 additions and 447 deletions
+92
View File
@@ -1,6 +1,7 @@
// Admin account route registration and profile helpers.
const { fetchAppSettings } = require('#src/data/app-settings');
const { renderAccountEmailTemplate } = require('#src/data/account-email-templates');
module.exports = function registerAccountRoutes(app, deps) {
const pool = deps.pool;
@@ -18,6 +19,8 @@ module.exports = function registerAccountRoutes(app, deps) {
const hashSessionToken = deps.hashSessionToken;
const sessionCookieName = deps.sessionCookieName;
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const sendAccountEmail = deps.sendAccountEmail;
const createOneTimeToken = deps.createOneTimeToken;
async function getPasswordRequirements() {
const settings = await fetchAppSettings(pool);
@@ -43,6 +46,29 @@ module.exports = function registerAccountRoutes(app, deps) {
};
}
function getRequestOrigin(req) {
const forwardedProto = String(req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim();
const forwardedHost = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim();
return forwardedHost ? forwardedProto + '://' + forwardedHost : '';
}
app.post('/account/email', async function (req, res, next) {
try {
const email = String(req.body.email || '').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).send('Email address is invalid.');
const settings = await fetchAppSettings(pool);
if (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function') return res.status(400).send('Email delivery is not configured.');
const token = createOneTimeToken();
await pool.query('UPDATE a_users SET pending_email = ?, pending_email_token_hash = ?, pending_email_expires_at = DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE id = ?', [email, hashSessionToken(token), req.currentUser.id]);
const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token);
await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.verification_subject'], settings['email.verification_body'], { url: url, username: req.currentUser.username, display_name: req.currentUser.name, email: email, action_alignment: settings['email.verification_button_alignment'], action_label: settings['email.verification_button_text'] })));
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.email_change_requested', actorUserId: req.currentUser.id, targetType: 'user', targetId: req.currentUser.id, targetLabel: email });
res.redirect('/account?message=' + encodeURIComponent('Check your new email address for a verification link.'));
} catch (error) {
next(error);
}
});
app.get('/account', function (req, res) {
fetchAppSettings(pool).then(function (settings) {
const passwordRequirements = buildPasswordRequirements(settings);
@@ -74,6 +100,52 @@ module.exports = function registerAccountRoutes(app, deps) {
});
});
app.post('/account/profile', async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
const username = common.validateMaxLength(req.body.username || '', 64, 'Username');
const email = String(req.body.email || '').trim().toLowerCase();
const currentPassword = String(req.body.current_password || '');
if (!name) return res.status(400).send('Name is required.');
if (!username) return res.status(400).send('Username is required.');
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).send('Email address is invalid.');
if (!currentPassword) return res.status(400).send('Current password is required.');
const [rows] = await pool.query('SELECT id, username, name, email, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
const user = rows[0] || null;
if (!user) return res.status(404).send('User not found.');
if (!verifyPassword(currentPassword, user)) return res.status(400).send('Current password is incorrect.');
if (username !== user.username && await common.fetchDuplicateName(pool, 'a_users', username, user.id, 'username')) return res.status(400).send('That username already exists.');
if (name !== user.name && await common.fetchDuplicateName(pool, 'a_users', name, user.id)) return res.status(400).send('That name already exists.');
const confirmedEmail = String(user.email || '').trim().toLowerCase();
const pendingEmail = String(user.pending_email || '').trim().toLowerCase();
const emailChanged = email !== confirmedEmail && email !== pendingEmail;
const pendingEmailCleared = Boolean(pendingEmail && email === confirmedEmail);
const settings = emailChanged ? await fetchAppSettings(pool) : null;
if (emailChanged && email && (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function')) return res.status(400).send('Email delivery is not configured.');
const actorId = getAuditUserId(req);
await pool.query('UPDATE a_users SET username = ?, name = ?, modified_by = ? WHERE id = ?', [username, name, actorId, user.id]);
if (emailChanged || pendingEmailCleared) {
if (!email) {
await pool.query('UPDATE a_users SET email = NULL, email_verified_at = NULL, pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]);
} else if (pendingEmailCleared) {
await pool.query('UPDATE a_users SET pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]);
} else {
const token = createOneTimeToken();
await pool.query('UPDATE a_users SET pending_email = ?, pending_email_token_hash = ?, pending_email_expires_at = DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE id = ?', [email, hashSessionToken(token), user.id]);
const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token);
await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.verification_subject'], settings['email.verification_body'], { url: url, username: req.currentUser.username, display_name: req.currentUser.name, email: email, action_alignment: settings['email.verification_button_alignment'], action_label: settings['email.verification_button_text'] })));
}
}
if (emailChanged && typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: email ? 'user.email_change_requested' : 'user.email_cleared', actorUserId: user.id, targetType: 'user', targetId: user.id, targetLabel: email || user.email, details: { previousEmail: confirmedEmail || null, email: email || null, verificationRequired: Boolean(email) } });
res.redirect('/account?message=' + encodeURIComponent(emailChanged && email ? 'Account details updated. Check your new email address for a verification link.' : 'Account details updated.'));
} catch (error) {
next(error);
}
});
app.post('/account/sessions/:id/revoke', async function (req, res, next) {
try {
const sessionId = Number(req.params.id);
@@ -161,6 +233,26 @@ module.exports = function registerAccountRoutes(app, deps) {
}
});
app.post('/account/username', async function (req, res, next) {
try {
const username = common.validateMaxLength(req.body.username || '', 255, 'Username');
const currentPassword = String(req.body.current_password || '');
if (!username) return res.status(400).send('Username is required.');
if (!currentPassword) return res.status(400).send('Current password is required.');
const [rows] = await pool.query('SELECT id, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
const user = rows[0] || null;
if (!user) return res.status(404).send('User not found.');
if (!verifyPassword(currentPassword, user)) return res.status(400).send('Current password is incorrect.');
const [existingRows] = await pool.query('SELECT id FROM a_users WHERE username = ? AND id <> ? LIMIT 1', [username, user.id]);
if (existingRows.length) return res.status(400).send('That username already exists.');
await pool.query('UPDATE a_users SET username = ?, modified_by = ? WHERE id = ?', [username, getAuditUserId(req), user.id]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.username_updated', actorUserId: user.id, targetType: 'user', targetId: user.id, targetLabel: username, details: { previousUsername: user.username, username: username } });
res.redirect('/account?message=' + encodeURIComponent('Username updated.'));
} catch (error) {
next(error);
}
});
app.post('/account/password', async function (req, res, next) {
try {
const currentPassword = String(req.body.current_password || '');