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

@@ -1,4 +1,5 @@
.input {
width: 15rem;
box-sizing: border-box;
padding: .5rem .75rem;
font-size: 1rem;

View File

@@ -0,0 +1,106 @@
<script setup>
import {
Body,
Container,
Heading,
Html,
Img,
Preview,
Text,
} from '@vue-email/components';
import { format } from 'date-fns';
defineProps({
user: {
type: Object,
default: () => mockUser,
},
address: {
type: String,
default: 'http://localhost:5100',
},
ip: {
type: String,
default: '127.0.0.1',
},
country: {
type: String,
default: 'Unknown',
},
timestamp: {
type: Date,
default: () => new Date(),
},
});
</script>
<script>
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
// <script setup> itself, since that block gets hoisted out of the setup() function.
const mockUser = {
id: 1,
username: 'DebuggingLibrarian',
email: 'debug@alerts.traxxx.me',
};
</script>
<template>
<Html>
<Body style="font-family: sans-serif;">
<Preview>Your traxxx password has been changed.</Preview>
<Container>
<Img
:src="`${address}/img/logo.png`"
width="150"
alt="traxxx"
style="margin: 15px auto 25px auto;"
/>
<Heading
as="h3"
style="color: #f65596; margin: 0 auto 0 auto;"
>Hi, {{ user.username }}</Heading>
<Heading
as="h3"
style="margin-top: 30px;"
>Your traxxx password was changed.</Heading>
<Text>Device used to change your password:</Text>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="font-size: 14px;"
>
<tbody>
<tr>
<td style="padding: 4px 8px 4px 0;">IP:</td>
<td style="padding: 4px 0;">{{ ip }}</td>
</tr>
<tr>
<td style="padding: 4px 8px 4px 0;">Country:</td>
<td style="padding: 4px 0;">{{ country }}</td>
</tr>
<tr>
<td style="padding: 4px 8px 4px 0;">Timestamp:</td>
<td style="padding: 4px 0;">{{ format(timestamp, 'yyyy-MM-dd hh:mm:ss') }}</td>
</tr>
</tbody>
</table>
<Text style="font-size: 16px; margin: 20px 0;">If this change was made by you, you can ignore this e-mail. If this was not you, please <a
href="mailto:account@traxxx.me"
style="color: #f65596; text-decoration: none;"
>contact us</a> immediately.</Text>
</Container>
</Body>
</Html>
</template>

View File

@@ -0,0 +1,85 @@
<script setup>
import {
Body,
Button,
Container,
Heading,
Html,
Img,
Preview,
Text,
} from '@vue-email/components';
import config from 'config';
defineProps({
user: {
type: Object,
default: () => mockUser,
},
verifyUrl: {
type: String,
default: 'http://localhost:5100/verify?userId=0&token=00000000000000000000000000000000',
},
trigger: {
type: String,
default: 'change',
},
address: {
type: String,
default: 'http://localhost:5100',
},
});
</script>
<script>
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
// <script setup> itself, since that block gets hoisted out of the setup() function.
const mockUser = {
id: 1,
username: 'DebuggingLibrarian',
email: 'debug@alerts.traxxx.me',
};
</script>
<template>
<Html>
<Body style="font-family: sans-serif;">
<Preview>Please verify your traxxx e-mail address.</Preview>
<Container>
<Img
:src="`${address}/img/logo.png`"
width="150"
alt="traxxx"
style="margin: 15px auto 25px auto;"
/>
<Heading
as="h3"
style="color: #f65596; margin: 0 auto 0 auto;"
>Hi, {{ user.username }}</Heading>
<Text>
<strong v-if="trigger === 'signup'">Thank you for creating your traxxx account!</strong>
<strong v-else>You requested to {{ trigger === 'verify' ? 'verify' : 'change' }} the e-mail address for your traxxx account.</strong>
<br>
Please confirm your e-mail address by clicking the button below:
</Text>
<Button
:href="verifyUrl"
target="_blank"
style="background: #f65596; color: #fff; font-size: 16px; padding: 1rem 1.5rem; border-radius: .5rem; margin: 1rem 0;"
><strong>Verify e-mail address</strong></Button>
<Text style="font-size: 16px; margin: 20px 0;">
The link will expire in {{ config.auth.emailTokenExpiry }} minutes, or when you request an(other) e-mail change or verification.
<template v-if="trigger === 'signup'">If you did not sign up, you can safely ignore this e-mail.</template>
<template v-else>If you did not request this {{ trigger === 'verify' ? 'verification' : 'change' }}, you can safely ignore this e-mail.</template>
</Text>
</Container>
</Body>
</Html>
</template>

View File

@@ -0,0 +1,92 @@
<script setup>
import { ref } from 'vue';
const props = defineProps({
password: {
type: String,
default: null,
},
placeholder: {
type: String,
default: 'Password',
},
autocomplete: {
type: String,
default: 'current-password',
},
});
const emit = defineEmits(['change', 'input']);
const currentPassword = ref(props.password);
const showPassword = ref(false);
</script>
<template>
<div class="password-container">
<input
v-model="currentPassword"
:type="showPassword ? 'input' : 'password'"
:placeholder="placeholder"
:autocomplete="autocomplete"
minlength="3"
class="password input"
@change="emit('change', currentPassword)"
@input="emit('input', currentPassword)"
>
<div
class="password-show"
@click="showPassword = !showPassword"
>
<Icon
v-show="!showPassword"
icon="eye"
class="password-show"
/>
<Icon
v-show="showPassword"
icon="eye-blocked"
class="password-show"
/>
</div>
</div>
</template>
<style scoped>
.input {
background: var(--background);
padding-right: 2.5rem;
}
.password-container {
display: flex;
position: relative;
}
.password {
flex-grow: 1;
}
.password-show {
height: 100%;
display: flex;
align-items: center;
position: absolute;
right: 0;
padding: 0 1rem 0 .5rem;
.icon {
fill: var(--glass);
}
&:hover {
cursor: pointer;
.icon {
fill: var(--primary);
}
}
}
</style>

277
components/user/account.vue Normal file
View File

@@ -0,0 +1,277 @@
<script setup>
import { inject, reactive, ref } from 'vue';
import Password from '#/components/form/password.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
// import Checkbox from '#/components/form/checkbox.vue';
import { patch, post } from '#/src/api.js';
const pageContext = inject('pageContext');
const { user } = pageContext;
const credentials = reactive({
email: null,
password: null,
});
const currentPassword = ref(null);
const saving = ref(false);
const submitted = ref(false);
const verifying = ref(false);
const verified = ref(false);
function getFeedback() {
if (credentials.email && Object.entries(credentials).some(([key, value]) => key !== 'email' && value !== null)) {
return 'Account updated, please check your inbox to change e-mail.';
}
if (credentials.email) {
return 'Please check your inbox to change e-mail.';
}
return 'Account updated.';
}
async function updateAccount() {
saving.value = true;
try {
await patch('/me', {
credentials,
password: currentPassword.value,
}, {
successFeedback: getFeedback(),
errorFeedback: 'Failed to update account',
appendErrorMessage: true,
});
submitted.value = true;
}
finally {
saving.value = false;
}
}
async function verifyEmail() {
verifying.value = true;
try {
await post('/me/email_verification', null, {
successFeedback: 'E-mail verification sent, please check your inbox',
errorFeedback: 'Failed to request e-mail verification',
appendErrorMessage: true,
});
verified.value = true;
}
finally {
verifying.value = false;
}
}
</script>
<template>
<form
class="profile-section"
@submit.prevent="updateAccount"
>
<h3 class="heading settings-heading">Alerts</h3>
<label class="setting">
<span class="setting-label">
<strong class="setting-title">E-mail address</strong>
<span class="setting-description">Primarily used for alert notifications and password recovery</span>
</span>
<span class="setting-value">
<span class="setting-group">
<VDropdown v-if="!user.isEmailVerified">
<Icon
icon="warning2"
class="unverified"
/>
<template #popper>
<div class="unverified-tooltip">
Your e-mail address is unverified
<Ellipsis v-if="verifying" />
<strong
v-else-if="verified"
class="success"
>Verification e-mail sent</strong>
<button
v-else
type="button"
class="button button-primary"
@click="verifyEmail"
>Verify now</button>
</div>
</template>
</VDropdown>
<input
:value="user.email"
type="email"
class="input"
disabled
>
</span>
<input
v-model="credentials.email"
type="email"
placeholder="New e-mail"
class="input"
>
</span>
</label>
<label class="setting">
<span class="setting-label">
<strong class="setting-title">New password</strong>
<span class="setting-description">Change the password you use for signing in</span>
</span>
<span class="setting-value">
<Password
:password="credentials.password"
placeholder="New password"
autocomplete="new-password"
@input="(password) => credentials.password = password"
/>
</span>
</label>
<label class="setting">
<span class="setting-label">
<strong class="setting-title">Current password</strong>
<span class="setting-description">Enter your current password to save your changes</span>
</span>
<span class="setting-value">
<Password
:password="currentPassword"
placeholder="Current password"
@input="(password) => currentPassword = password"
/>
</span>
</label>
<div class="settings-actions">
<Ellipsis v-if="saving" />
<strong
v-else-if="submitted"
class="success"
>{{ getFeedback() }}</strong>
<button
v-else
v-tooltip="(credentials.email || credentials.password) && currentPassword ? null : 'Please supply your new credentials and current password'"
class="button button-primary"
:disabled="!currentPassword"
>Save</button>
</div>
</form>
</template>
<style scoped>
.input {
flex-shrink: 0;
flex-basis: auto;
background: var(--background);
&:disabled {
color: var(--glass);
background: none;
}
}
.profile-section .heading {
margin: .5rem 1rem 0 1rem;
}
.setting {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
box-sizing: border-box;
padding: .75rem 1rem;
border-bottom: solid 1px var(--glass-weak-30);
&:hover {
border-color: var(--glass-weak-20);
}
}
.setting-label {
display: flex;
flex-direction: column;
}
.setting-title {
color: var(--glass-strong-20);
margin-bottom: .25rem;
}
.setting-value {
display: flex;
align-items: flex-end;
flex-direction: column;
gap: .5rem;
}
.setting-group {
display: flex;
align-items: center;
}
.setting-description {
color: var(--glass);
font-size: .9rem;
}
.settings-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 1rem;
.button {
flex-basis: 0;
font-size: 1.1rem;
padding: .5rem 1rem;
}
}
.success {
color: var(--success);
margin-top: 1rem;
text-align: center;
}
.unverified {
fill: var(--error);
padding: .5rem 1rem;
cursor: pointer;
}
.unverified-tooltip {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.load-container {
display: flex;
justify-content: center;
}
</style>

View File

@@ -154,6 +154,7 @@ module.exports = {
siteKey: '10000000-ffff-ffff-ffff-000000000001',
secretKey: '0x0000000000000000000000000000000000000000',
},
emailTokenExpiry: 120, // minutes
},
bans: {
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days

View File

@@ -1,6 +1,8 @@
<script setup>
import { inject, onMounted, ref } from 'vue';
import Password from '#/components/form/password.vue';
import { post } from '#/src/api.js';
import navigate from '#/src/navigate.js';
@@ -14,7 +16,6 @@ const password = ref('');
const submitted = ref(false);
const errorMsg = ref(null);
const userInput = ref(null);
const showPassword = ref(false);
async function login() {
submitted.value = true;
@@ -88,31 +89,10 @@ onMounted(() => {
class="input"
>
<div class="password-container">
<input
v-model="password"
:type="showPassword ? 'input' : 'password'"
placeholder="Password"
class="password input"
>
<div
class="password-show"
@click="showPassword = !showPassword"
>
<Icon
v-show="!showPassword"
icon="eye"
class="password-show"
/>
<Icon
v-show="showPassword"
icon="eye-blocked"
class="password-show"
/>
</div>
</div>
<Password
:password="password"
@input="(newPassword) => password = newPassword"
/>
<button
class="button button-submit"
@@ -129,6 +109,10 @@ onMounted(() => {
</template>
<style scoped>
.input {
width: 100%;
}
.login-container {
display: flex;
flex-grow: 1;

View File

@@ -2,6 +2,8 @@
import VueHCaptcha from '@hcaptcha/vue3-hcaptcha';
import { inject, onMounted, ref } from 'vue';
import Password from '#/components/form/password.vue';
import { post } from '#/src/api.js';
import navigate from '#/src/navigate.js';
@@ -16,7 +18,6 @@ const passwordConfirm = ref('');
const errorMsg = ref(null);
const submitted = ref(false);
const userInput = ref(null);
const showPassword = ref(false);
const captcha = ref(null);
async function signup() {
@@ -113,63 +114,18 @@ onMounted(() => {
required
>
<div class="password-container">
<input
v-model="password"
:type="showPassword ? 'input' : 'password'"
autocomplete="new-password"
minlength="3"
placeholder="Password"
class="password input"
required
>
<Password
:password="password"
autocomplete="new-password"
@input="(newPassword) => password = newPassword"
/>
<div
class="password-show"
@click="showPassword = !showPassword"
>
<Icon
v-show="!showPassword"
icon="eye"
class="password-show"
/>
<Icon
v-show="showPassword"
icon="eye-blocked"
class="password-show"
/>
</div>
</div>
<div class="password-container">
<input
v-model="passwordConfirm"
:type="showPassword ? 'input' : 'password'"
autocomplete="new-password"
minlength="3"
placeholder="Confirm password"
class="password input"
required
>
<div
class="password-show"
@click="showPassword = !showPassword"
>
<Icon
v-show="!showPassword"
icon="eye"
class="password-show"
/>
<Icon
v-show="showPassword"
icon="eye-blocked"
class="password-show"
/>
</div>
</div>
<Password
:password="passwordConfirm"
autocomplete="new-password"
placeholder="Confirm password"
@input="(newPassword) => passwordConfirm = newPassword"
/>
<VueHCaptcha
v-if="env.captcha.enabled"
@@ -193,6 +149,10 @@ onMounted(() => {
</template>
<style scoped>
.input {
width: 100%;
}
.login-container {
display: flex;
flex-grow: 1;

View File

@@ -9,6 +9,7 @@ import Summaries from '#/components/scenes/summaries.vue';
// filters are implemented as generic settings panel in case we want to extend it in the future
import Settings from '#/components/settings/settings.vue';
import Stashes from '#/components/stashes/stashes.vue';
import Account from '#/components/user/account.vue';
import ApiKeys from '#/components/user/api-keys.vue';
import Preferences from '#/components/user/preferences.vue';
@@ -19,6 +20,7 @@ const domain = pageContext.routeParams.domain;
const user = pageContext.user;
const profile = ref(pageContext.pageProps.profile);
const primaryStash = pageContext.assets.primaryStash;
const showFilters = ref(false);
@@ -70,6 +72,12 @@ function scrollHorizontal(event) {
class="domains nobar"
@wheel.prevent="scrollHorizontal"
>
<a
v-if="primaryStash"
:href="`/stash/${user.username}/${primaryStash.slug}`"
class="domain nolink"
><Icon :icon="primaryStash.slug === 'favorites' ? 'heart7' : 'folder-heart'" />{{ primaryStash.name }}</a>
<a
:href="`/user/${profile.username}/stashes`"
class="domain nolink"
@@ -88,13 +96,6 @@ function scrollHorizontal(event) {
:class="{ active: section === 'templates' }"
><Icon icon="paste3" />Templates</a>
<a
v-if="profile.isIdentityVerified"
:href="`/user/${profile.username}/api`"
class="domain nolink"
:class="{ active: section === 'api' }"
><Icon icon="key" />API Keys</a>
<a
:href="`/user/${profile.username}/revisions/scenes`"
class="domain nolink"
@@ -114,11 +115,24 @@ function scrollHorizontal(event) {
@click="showFilters = true"
><Icon icon="filter" />Filters</span>
<a
v-if="profile.isIdentityVerified"
:href="`/user/${profile.username}/api`"
class="domain nolink"
:class="{ active: section === 'api' }"
><Icon icon="key" />API Keys</a>
<a
:href="`/user/${profile.username}/preferences`"
class="domain nolink"
:class="{ active: section === 'preferences' }"
><Icon icon="equalizer" />Preferences</a>
<a
:href="`/user/${profile.username}/account`"
class="domain nolink"
:class="{ active: section === 'account' }"
><Icon icon="user-tie" />Account</a>
</nav>
<Stashes v-if="section === 'stashes'" />
@@ -129,13 +143,9 @@ function scrollHorizontal(event) {
:release="mockupRelease"
/>
<ApiKeys
v-if="section === 'api'"
/>
<Preferences
v-if="section === 'preferences'"
/>
<ApiKeys v-if="section === 'api'" />
<Preferences v-if="section === 'preferences'" />
<Account v-if="section === 'account'" />
</div>
<div

View File

@@ -0,0 +1,85 @@
<script setup>
import { inject, ref } from 'vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
import { post } from '#/src/api.js';
const { urlParsed } = inject('pageContext');
const { userId, token } = urlParsed.search;
const errorMsg = ref(null);
const submitting = ref(false);
const success = ref(false);
async function verify() {
errorMsg.value = null;
submitting.value = true;
try {
await post('/emails/verify', {
userId,
token,
}, {
appendErrorMessage: true,
});
success.value = true;
}
catch (error) {
errorMsg.value = error.message;
}
finally {
submitting.value = false;
}
}
</script>
<template>
<div class="content">
<Ellipsis v-if="submitting" />
<strong
v-else-if="errorMsg"
class="feedback error"
>{{ errorMsg }}</strong>
<strong
v-else-if="success"
class="feedback success"
>Verification successful</strong>
<button
v-else
class="button button-primary"
@click="verify"
>Click here to verify your e-mail address</button>
</div>
</template>
<style scoped>
.content {
display: flex;
align-items: center;
justify-content: center;
flex-grow: 1;
padding: 1rem;
}
.button {
padding: 1rem 1.5rem;
font-size: 1.1rem;
}
.feedback {
font-size: 1.1rem;
}
.error {
color: var(--error);
}
.success {
color: var(--success);
}
</style>

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);