Added account update form and e-mail verification.

This commit is contained in:
2026-07-22 05:49:39 +02:00
parent 17d34f6a80
commit 9b058fd44b
19 changed files with 1108 additions and 233 deletions

View File

@@ -3,11 +3,10 @@ import { CronJob } from 'cron';
import { add } from 'date-fns';
import escapeRegexp from 'escape-string-regexp';
import markdownIt from 'markdown-it';
import { Resend } from 'resend';
import promiseProps from '../utils/promise-props.js';
import { getIdsBySlug } from './cache.js';
import { emailTemplates } from './email-templates.js';
import { sendEmails } from './email.js';
import { HttpError } from './errors.js';
import { knexOwner as knex } from './knex.js';
import initLogger from './logger.js';
@@ -15,18 +14,6 @@ import { indexApi } from './manticore.js';
import { fetchScenesById } from './scenes.js';
const logger = initLogger();
const resend = (() => {
if (config.email.enabled) {
try {
return new Resend(config.email.apiKey);
}
catch (error) {
logger.error(`Failed to initialize Resend: ${error.message}`);
}
}
return null;
})();
function getImagePath(media) {
if (!media) {
@@ -452,11 +439,6 @@ async function sendEmailAlerts() {
return;
}
if (!resend) {
logger.warn('E-mails are enabled, but Resend is not initialized');
return;
}
const qualifyingUsers = await knex('notifications')
.select('users.id as user_id', 'users.settings', knex.raw('max(emailed_at) as last_email'))
.where('notifications.email', true)
@@ -546,7 +528,7 @@ async function sendEmailAlerts() {
&& !!notification.scene
&& notification.user.settings?.alertEmailsEnabled !== false); // keep the e-mails marked as 'sent', if emails get re-enabled there shouldn't be a barrage of old notifications
const emails = await Promise.all(Object.values(usersByUserId).map(async (user) => {
const emailItems = Object.values(usersByUserId).map((user) => {
const userTriggers = Object.values(Object.fromEntries(triggers
.filter((trigger) => trigger.user.id === user.id)
.map((trigger) => [trigger.scene.id, trigger])))
@@ -556,42 +538,24 @@ async function sendEmailAlerts() {
return null;
}
try {
const { html, text } = await emailTemplates.render('alert.vue', {
props: {
user,
triggers: userTriggers,
address: config.web.address,
},
});
return {
template: 'alert',
to: user.email,
subject: `Notification for ${userTriggers.length} scenes on traxxx`,
props: {
user,
triggers: userTriggers,
address: config.web.address,
},
};
}).filter(Boolean);
return {
from: config.email.from,
to: user.email,
subject: `Notification for ${userTriggers.length} scenes on traxxx`,
html,
text,
};
}
catch (error) {
logger.warn(`Failed to render e-mail for '${user.username}' with scenes ${userTriggers.map((trigger) => `${trigger.notificationId}: ${trigger.scene.id}`)}: ${error.message}`);
return null;
}
})).then((allEmails) => allEmails.filter(Boolean));
if (emails.length === 0) {
if (emailItems.length === 0) {
logger.info(`No alert e-mails sent`);
return;
}
const { data, error } = await resend.batch.send(emails);
if (error) {
logger.error(`Failed to send ${emails.length} alert e-mails (${error.statusCode} ${error.name}): ${error.message}`);
}
else {
logger.info(`Sent ${data.data.length}/${emails.length} alert e-mails`);
}
await sendEmails(emailItems);
}
export async function notify(sceneIds, options = {}) {

View File

@@ -1,3 +1,4 @@
import { Buffer } from 'node:buffer';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import util from 'node:util';
@@ -8,6 +9,7 @@ import config from 'config';
import { verify } from 'hcaptcha';
import slugify from '../utils/slugify.js';
import { sendEmails } from './email.js';
import { HttpError } from './errors.js';
import { knexOwner as knex } from './knex.js';
import initLogger, { initAccessLogger } from './logger.js';
@@ -46,7 +48,7 @@ async function generateAvatar(user) {
export async function login(credentials, userIp) {
if (!config.auth.login) {
throw new HttpError('Logins are currently disabled', 405);
throw new HttpError('Logins are currently disabled', 503);
}
const { user } = await fetchUser(credentials.username.trim(), {
@@ -76,9 +78,62 @@ export async function login(credentials, userIp) {
return curateUser(user, user);
}
async function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
const hashedPassword = (await scrypt(password, salt, 64)).toString('hex');
const storedPassword = `${salt}/${hashedPassword}`;
return {
storedPassword,
hashedPassword,
salt,
};
}
export async function sendEmailVerification(user, customEmail, trigger) {
if (!user) {
throw new HttpError('No user provided', 401);
}
const email = customEmail || user.email;
if (!email) {
throw new HttpError('No e-mail address provided', 400);
}
const tokenKey = `traxxx:token_email:${user.id}`;
const token = crypto.randomBytes(32).toString('hex');
const tokenData = {
email,
token,
};
await redis.set(tokenKey, JSON.stringify(tokenData));
await redis.expire(tokenKey, config.auth.emailTokenExpiry * 60);
const verifyUrl = `${config.web.address}/verify/email?${new URLSearchParams({
userId: user.id,
token,
}).toString()}`;
logger.info(`E-mail verification sent to ${user.username} (${user.id}): ${verifyUrl}`);
await sendEmails([{
template: 'verify',
to: email,
subject: 'Please verify your traxxx e-mail address',
props: {
user,
verifyUrl,
trigger,
},
}]);
}
export async function signup(credentials, userIp) {
if (!config.auth.signup) {
throw new HttpError('Sign-ups are currently disabled', 405);
throw new HttpError('Sign-ups are currently disabled', 503);
}
const curatedUsername = credentials.username.trim();
@@ -125,9 +180,7 @@ export async function signup(credentials, userIp) {
throw new HttpError('Username or e-mail already in use', 409);
}
const salt = crypto.randomBytes(16).toString('hex');
const hashedPassword = (await scrypt(credentials.password, salt, 64)).toString('hex');
const storedPassword = `${salt}/${hashedPassword}`;
const { storedPassword } = await hashPassword(credentials.password);
const [{ id: userId }] = await knex('users')
.insert({
@@ -152,7 +205,159 @@ export async function signup(credentials, userIp) {
username: curatedUsername,
});
return fetchUser(userId);
const user = await fetchUser(userId, {
includePrivate: true,
});
await sendEmailVerification(user, null, 'signup');
return user;
}
async function updatePassword(password, reqUser, reqSession) {
const now = new Date();
const { storedPassword } = await hashPassword(password);
await knex('users')
.where('id', reqUser.id)
.update('password', storedPassword);
const country = reqSession.country && await knex('countries').where('alpha2', reqSession.country).first();
await sendEmails([{
template: 'password',
to: reqUser.email,
subject: 'Your traxxx password has been changed',
props: {
user: reqUser,
ip: reqUser.ip,
country: country?.name,
address: config.web.address,
timestamp: now,
},
}]);
logger.info(`Password changed by ${reqUser.username} (${reqUser.id}, ${reqUser.ip})`);
}
async function updateEmail(email, reqUser) {
const user = await fetchUser(email, {
email: true,
throwError: false,
});
if (user) {
throw new HttpError('This e-mail address is already in use', 409);
}
await sendEmailVerification(reqUser, email, 'change');
}
export async function verifyEmail(userId, emailToken) {
if (!userId) {
throw new HttpError('User ID not provided', 400);
}
if (!emailToken) {
throw new HttpError('Token not provided', 400);
}
const tokenKey = `traxxx:token_email:${userId}`;
const tokenEntry = await redis.getDel(tokenKey);
if (!tokenEntry) {
throw new HttpError('Invalid verification link', 401);
}
const { token, email } = JSON.parse(tokenEntry);
if (!token
|| !email
|| emailToken.length !== token.length
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
throw new HttpError('Invalid verification link', 401);
}
const updated = await knex('users')
.where('id', userId)
.update({
email,
email_verified: true,
});
if (updated === 0) {
throw new HttpError('E-mail verification failed because the user could not be found', 404);
}
}
export async function updateUser({ credentials, password }, reqUser, reqSession) {
if (!reqUser) {
throw new HttpError('You are not logged in', 401);
}
if (!credentials?.email && !credentials.password) {
throw new HttpError('No changeable credentials provided', 400);
}
if (credentials.email && !config.email.enabled) {
throw new HttpError('E-mail is currently disabled', 503);
}
if (credentials.password && credentials.password?.length < 3) {
throw new HttpError('Password must be 3 characters or longer', 400);
}
if (!password) {
throw new HttpError('No current password provided', 401);
}
const { user } = await fetchUser(reqUser.id, {
email: true,
raw: true,
}).catch(() => {
throw new HttpError('Username or password incorrect', 401);
});
await verifyPassword(password, user.password);
if (credentials.password) {
await updatePassword(credentials.password, reqUser, reqSession);
}
if (credentials.email) {
await updateEmail(credentials.email, reqUser);
}
return curateUser(user, reqUser);
}
const validSettingKeys = [
'alertNotificationsEnabled',
'alertEmailsEnabled',
'alertEmailsFrequency',
];
export async function updateSettings(settings, reqUser) {
if (!reqUser) {
throw new HttpError('You need to be authenticated to change your settings', 401);
}
const invalidKeys = Object.keys(settings).filter((key) => !validSettingKeys.includes(key));
if (invalidKeys.length > 0) {
throw new HttpError(`Invalid settings: ${invalidKeys.join()}`, 400);
}
if (settings.alertEmailsFrequency && !['instant', 'daily', 'weekly', 'monthly'].includes(settings.alertEmailsFrequency)) {
throw new HttpError('Invalid email frequency setting', 400);
}
const [{ settings: updatedSettings }] = await knex('users')
.where('id', reqUser.id)
.update('settings', knex.raw('coalesce(settings, \'{}\'::jsonb) || ?::jsonb', [JSON.stringify(settings)]))
.returning('settings');
return updatedSettings;
}
function curateKey(key) {

79
src/email.js Normal file
View File

@@ -0,0 +1,79 @@
import config from 'config';
import { Resend } from 'resend';
import { emailTemplates } from './email-templates.js';
import initLogger from './logger.js';
const logger = initLogger();
const resend = (() => {
if (config.email.enabled) {
try {
return new Resend(config.email.apiKey);
}
catch (error) {
logger.error(`Failed to initialize Resend: ${error.message}`);
}
}
return null;
})();
export async function renderEmail(template, { to, subject, props }) {
const { html, text } = await emailTemplates.render(`${template}.vue`, { props });
return {
from: config.email.from,
to,
subject,
html,
text,
};
}
// always batches, even for a single item: Resend's batch endpoint accepts a batch of one,
// and passing batchValidation: 'permissive' means one bad recipient among many doesn't
// sink the rest of the batch (the default 'strict' mode fails the whole call instead)
export async function sendEmails(items) {
if (!config.email.enabled) {
return null;
}
if (!resend) {
logger.warn('E-mails are enabled, but Resend is not initialized');
return null;
}
const rendered = await Promise.all(items.map(async (item) => {
try {
return await renderEmail(item.template, item);
}
catch (error) {
logger.error(`Failed to render '${item.template}' e-mail for '${item.to}': ${error.message}${JSON.stringify(item.props)}`);
return null;
}
}));
const sentItems = items.filter((item, index) => rendered[index]);
const emails = rendered.filter(Boolean);
if (emails.length === 0) {
return null;
}
const { data, error } = await resend.batch.send(emails, { batchValidation: 'permissive' });
if (error) {
logger.error(`Failed to send ${emails.length} e-mail(s) (${error.statusCode} ${error.name}): ${error.message}`);
return null;
}
data.errors?.forEach(({ index, message }) => {
const item = sentItems[index];
logger.error(`Failed to send '${item.template}' e-mail to '${item.to}': ${message}${JSON.stringify(item.props)}`);
});
logger.info(`Sent ${emails.length - (data.errors?.length || 0)}/${items.length} e-mail(s)`);
return data;
}

View File

@@ -14,7 +14,7 @@ function curateTemplate(template) {
};
}
export function curateUser(user, reqUser) {
export function curateUser(user, reqUser, options = {}) {
if (!user) {
return null;
}
@@ -35,7 +35,7 @@ export function curateUser(user, reqUser) {
role: user.role,
abilities: [...user.role_abilities || [], ...user.abilities || []],
createdAt: user.created_at,
...(reqUser?.id === user.id ? privateUser : null),
...(reqUser?.id === user.id || options.includePrivate ? privateUser : null),
};
return curatedUser;
@@ -63,7 +63,7 @@ export async function fetchUser(userId, options = {}, reqUser) {
.groupBy('users.id', 'users_roles.role')
.first();
if (!user) {
if (!user && options.throwError !== false) {
throw new HttpError(`User '${userId}' not found`, 404);
}
@@ -71,7 +71,7 @@ export async function fetchUser(userId, options = {}, reqUser) {
return { user };
}
return curateUser(user, reqUser);
return curateUser(user, reqUser, options);
}
export async function fetchUserTemplates(reqUser) {
@@ -136,32 +136,3 @@ export async function createBan(ban, reqUser) {
await knex('bans').insert(curatedBan);
}
const validSettingKeys = [
'alertNotificationsEnabled',
'alertEmailsEnabled',
'alertEmailsFrequency',
];
export async function updateSettings(settings, reqUser) {
if (!reqUser) {
throw new HttpError('You need to be authenticated to change your settings', 401);
}
const invalidKeys = Object.keys(settings).filter((key) => !validSettingKeys.includes(key));
if (invalidKeys.length > 0) {
throw new HttpError(`Invalid settings: ${invalidKeys.join()}`, 400);
}
if (settings.alertEmailsFrequency && !['instant', 'daily', 'weekly', 'monthly'].includes(settings.alertEmailsFrequency)) {
throw new HttpError('Invalid email frequency setting', 400);
}
const [{ settings: updatedSettings }] = await knex('users')
.where('id', reqUser.id)
.update('settings', knex.raw('coalesce(settings, \'{}\'::jsonb) || ?::jsonb', [JSON.stringify(settings)]))
.returning('settings');
return updatedSettings;
}

View File

@@ -219,12 +219,12 @@ export async function updateNotificationGraphql(query, req) {
return updatedNotification;
}
export const router = Router();
export const alertsRouter = Router();
router.get('/api/alerts', fetchAlertsApi);
router.post('/api/alerts', createAlertApi);
router.delete('/api/alerts/:alertId', removeAlertApi);
alertsRouter.get('/api/alerts', fetchAlertsApi);
alertsRouter.post('/api/alerts', createAlertApi);
alertsRouter.delete('/api/alerts/:alertId', removeAlertApi);
router.get('/api/users/:userId/notifications', fetchNotificationsApi);
router.patch('/api/users/:userId/notifications', updateNotificationsApi);
router.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi);
alertsRouter.get('/api/users/:userId/notifications', fetchNotificationsApi);
alertsRouter.patch('/api/users/:userId/notifications', updateNotificationsApi);
alertsRouter.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi);

View File

@@ -1,4 +1,5 @@
import crypto from 'node:crypto';
import { Router } from 'express';
import IPCIDR from 'ip-cidr';
import argv from '../argv.js';
@@ -8,10 +9,16 @@ import {
flushUserKeys,
login,
removeUserKey,
sendEmailVerification,
signup,
updateSettings,
updateUser,
verifyEmail,
} from '../auth.js';
import { fetchUser } from '../users.js';
import {
fetchUser,
} from '../users.js';
// changes every time the process restarts, so a deploy invalidates all cached session users
// even if they're within the staleness window below
@@ -131,6 +138,60 @@ export async function signupApi(req, res) {
res.send(user);
}
async function updateUserApi(req, res) {
const updatedUser = await updateUser(req.body, req.user, req.session);
const sessions = await req.sessionStore.all();
req.session.user.refreshedAt = 0;
await Promise.all(sessions.map(async (session) => {
if (session.user?.id === req.user.id && session.id !== req.sessionID) {
if (req.body.credentials?.password) {
// we changed the password, destroy all user sessions except our own (we already provided all the credentials)
await req.sessionStore.destroy(session.id);
}
else {
// mark our sessions as stale
session.user.refreshedAt = 0; // eslint-disable-line no-param-reassign
await req.sessionStore.set(session.id, session);
}
}
}));
res.send(updatedUser);
}
async function sendEmailVerificationApi(req, res) {
await sendEmailVerification(req.user, null, 'verify');
res.status(204).send();
}
async function verifyEmailApi(req, res) {
const userId = Number(req.body.userId) || null;
await verifyEmail(userId, req.body.token);
const sessions = await req.sessionStore.all();
await Promise.all(sessions.map(async (session) => {
if (session.user?.id === userId) {
session.user.refreshedAt = 0; // eslint-disable-line no-param-reassign
await req.sessionStore.set(session.id, session);
}
}));
res.status(204).send();
}
async function updateSettingsApi(req, res) {
const updatedSettings = await updateSettings(req.body, req.user);
req.session.user.settings = updatedSettings;
res.send(updatedSettings);
}
export async function fetchUserKeysApi(req, res) {
const keys = await fetchUserKeys(req.user);
@@ -154,3 +215,28 @@ export async function flushUserKeysApi(req, res) {
res.status(204).send();
}
export const authRouter = Router();
// SESSION
// authRouter.use(setUserApi);
authRouter.post('/api/session', loginApi);
authRouter.delete('/api/session', logoutApi);
// USERS
authRouter.post('/api/users', signupApi);
// SETTINGS
authRouter.patch('/api/me', updateUserApi);
authRouter.patch('/api/me/settings', updateSettingsApi);
// EMAIL VERIFICATION
authRouter.post('/api/me/email_verification', sendEmailVerificationApi);
authRouter.post('/api/emails/verify', verifyEmailApi); // usable without session
// API KEYS
authRouter.get('/api/me/keys', fetchUserKeysApi);
authRouter.post('/api/me/keys', createKeyApi);
authRouter.delete('/api/me/keys/:keyIdentifier', removeUserKeyApi);
authRouter.delete('/api/me/keys', flushUserKeysApi);

View File

@@ -30,6 +30,7 @@ export default async function mainHandler(req, res, next) {
role: req.user.role,
abilities: req.user.abilities,
avatar: req.user.avatar,
isEmailVerified: req.user.isEmailVerified,
},
assets: req.user
? {

View File

@@ -12,19 +12,11 @@ import initLogger from '../logger.js';
import redis from '../redis.js';
import queryBoolean from '../utils/query-boolean.js';
import { actorsRouter } from './actors.js';
import { router as alertsRouter } from './alerts.js';
import {
createKeyApi,
fetchUserKeysApi,
flushUserKeysApi,
loginApi,
logoutApi,
removeUserKeyApi,
setUserApi,
signupApi,
} from './auth.js';
import { actorsRouter } from './actors.js';
import { alertsRouter } from './alerts.js';
import { authRouter, setUserApi } from './auth.js';
import consentHandler from './consent.js';
import { fetchEntitiesApi } from './entities.js';
@@ -91,8 +83,6 @@ export default async function initServer() {
store: redisStore,
}));
router.use(setUserApi);
// Vite integration
if (isProduction) {
app.enable('trust proxy');
@@ -118,6 +108,7 @@ export default async function initServer() {
router.use(viteDevMiddleware);
}
router.use(setUserApi); // we need to set the IP before the restriction handler
router.use(restrictionHandler);
router.get('/consent', (_req, res) => {
@@ -134,22 +125,10 @@ export default async function initServer() {
next();
});
// SESSION
router.post('/api/session', loginApi);
router.delete('/api/session', logoutApi);
// USERS
router.post('/api/users', signupApi);
// API KEYS
router.get('/api/me/keys', fetchUserKeysApi);
router.post('/api/me/keys', createKeyApi);
router.delete('/api/me/keys/:keyIdentifier', removeUserKeyApi);
router.delete('/api/me/keys', flushUserKeysApi);
router.use(userRouter);
router.use(stashesRouter);
router.use(alertsRouter);
router.use(authRouter);
router.use(scenesRouter);
router.use(actorsRouter);
router.use(syncRouter);

View File

@@ -6,7 +6,6 @@ import {
fetchUser,
fetchUserTemplates,
removeTemplate,
updateSettings,
} from '../users.js';
async function fetchUserApi(req, res) {
@@ -15,14 +14,6 @@ async function fetchUserApi(req, res) {
res.send(user);
}
async function updateSettingsApi(req, res) {
const updatedSettings = await updateSettings(req.body, req.user);
req.session.user.settings = updatedSettings;
res.send(updatedSettings);
}
async function fetchUserTemplatesApi(req, res) {
const templates = await fetchUserTemplates(req.user);
@@ -52,8 +43,6 @@ export const router = Router();
router.get('/api/users/:userId', fetchUserApi);
router.get('/api/users/:userId/templates', fetchUserTemplatesApi);
router.patch('/api/me/settings', updateSettingsApi);
router.post('/api/templates', createTemplateApi);
router.delete('/api/templates/:templateId', removeTemplateApi);