708 lines
18 KiB
JavaScript
Executable File
708 lines
18 KiB
JavaScript
Executable File
import { Buffer } from 'node:buffer';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs/promises';
|
|
import util from 'node:util';
|
|
import { shapes } from '@dicebear/collection';
|
|
import { createAvatar } from '@dicebear/core';
|
|
import { faker } from '@faker-js/faker';
|
|
import config from 'config';
|
|
import { verify as verifyCaptcha } 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';
|
|
import redis from './redis.js';
|
|
import { curateUser, fetchUser } from './users.js';
|
|
|
|
const logger = initLogger();
|
|
const accessLogger = initAccessLogger();
|
|
const scrypt = util.promisify(crypto.scrypt);
|
|
|
|
async function verifyPassword(password, storedPassword) {
|
|
const [salt, hash] = storedPassword.split('/');
|
|
const hashedPassword = (await scrypt(password, salt, 64)).toString('hex');
|
|
|
|
if (hashedPassword === hash) {
|
|
return true;
|
|
}
|
|
|
|
throw new HttpError('Username or password incorrect', 401);
|
|
}
|
|
|
|
async function generateAvatar(user) {
|
|
const avatar = createAvatar(shapes, {
|
|
seed: user.username,
|
|
backgroundColor: ['f65596', '9b004b', '006b68', '5abab6'],
|
|
shape1Color: ['f65596', 'ff6d7e', 'ff8d69', 'ffb15b', 'ffd55c', 'f9f871'],
|
|
shape2Color: ['c162c6', '6074dd', '007dd2', '007ba9', '007170'],
|
|
shape3Color: ['f65596', 'ff6d7e', 'ff8d69', 'ffb15b', 'ffd55c', 'f9f871'],
|
|
});
|
|
|
|
await fs.mkdir('media/avatars', { recursive: true });
|
|
await avatar.png().toFile(`media/avatars/${user.id}_${user.username}.png`);
|
|
|
|
logger.verbose(`Generated avatar for '${user.username}' (${user.id})`);
|
|
}
|
|
|
|
async function checkLoginCooldown(identifier, attemptThreshold) {
|
|
if (!identifier) {
|
|
return false;
|
|
}
|
|
|
|
const cooldownKey = `traxxx:login_cooldown:${slugify(identifier, '_')}`;
|
|
const attempts = JSON.parse(await redis.get(cooldownKey) || '0');
|
|
|
|
if (attempts >= attemptThreshold) {
|
|
throw new HttpError(`Please wait a few minutes before attempting another login`, 409);
|
|
}
|
|
}
|
|
|
|
async function setLoginCooldown(identifier, attemptCooldown) {
|
|
if (!identifier) {
|
|
return false;
|
|
}
|
|
|
|
const cooldownKey = `traxxx:login_cooldown:${slugify(identifier, '_')}`;
|
|
|
|
await redis.incr(cooldownKey);
|
|
await redis.expire(cooldownKey, attemptCooldown * 60);
|
|
}
|
|
|
|
export async function login(credentials, userIp) {
|
|
if (!config.auth.login) {
|
|
throw new HttpError('Logins are currently disabled', 503);
|
|
}
|
|
|
|
if (!credentials.username) {
|
|
throw new HttpError('Please provide a username or e-mail', 400);
|
|
}
|
|
|
|
if (!credentials.password) {
|
|
throw new HttpError('Please provide a password', 400);
|
|
}
|
|
|
|
await Promise.all([
|
|
checkLoginCooldown(credentials.username, config.auth.loginCredentialAttempts),
|
|
checkLoginCooldown(userIp, config.auth.loginIpAttempts),
|
|
]);
|
|
|
|
const { user } = await fetchUser(credentials.username.trim(), {
|
|
email: true,
|
|
raw: true,
|
|
}).catch(async () => {
|
|
await Promise.all([
|
|
setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown),
|
|
setLoginCooldown(userIp, config.auth.loginIpCooldown),
|
|
]);
|
|
|
|
throw new HttpError('Username or password incorrect', 401);
|
|
});
|
|
|
|
try {
|
|
await verifyPassword(credentials.password, user.password);
|
|
}
|
|
catch (error) {
|
|
await Promise.all([
|
|
setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown),
|
|
setLoginCooldown(userIp, config.auth.loginIpCooldown),
|
|
]);
|
|
|
|
throw error;
|
|
}
|
|
|
|
await knex('users')
|
|
.update('last_login', 'NOW()')
|
|
.update('last_ip', userIp)
|
|
.where('id', user.id);
|
|
|
|
logger.verbose(`Login from '${user.username}' (${user.id}, ${userIp})`);
|
|
|
|
try {
|
|
await fs.access(`media/avatars/${user.id}_${user.username}.png`);
|
|
}
|
|
catch (error) {
|
|
await generateAvatar(user);
|
|
}
|
|
|
|
// fetched the raw user for password verification, don't return directly to user
|
|
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,
|
|
};
|
|
}
|
|
|
|
async function tokenCooldown(identifier) {
|
|
const cooldownKey = `traxxx:token_cooldown:${slugify(identifier, '_')}`;
|
|
|
|
if (await redis.exists(cooldownKey)) {
|
|
throw new HttpError(`Please wait a few minutes before requesting another token`, 409);
|
|
}
|
|
|
|
await redis.set(cooldownKey, JSON.stringify(true));
|
|
await redis.expire(cooldownKey, config.auth.tokenCooldown * 60);
|
|
}
|
|
|
|
export async function sendEmailVerification(user, customEmail, trigger) {
|
|
if (!config.email.enabled) {
|
|
throw new HttpError('E-mail verification is disabled', 503);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
await tokenCooldown(email);
|
|
|
|
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 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 sendPasswordReset(credential) {
|
|
if (!config.email.enabled) {
|
|
throw new HttpError('Password reset is disabled', 503);
|
|
}
|
|
|
|
if (!credential) {
|
|
throw new HttpError('No credential provided', 400);
|
|
}
|
|
|
|
await tokenCooldown(credential);
|
|
|
|
const user = await fetchUser(credential, {
|
|
email: true,
|
|
includePrivate: true,
|
|
throwError: false,
|
|
});
|
|
|
|
// we don't throw errors because the requesting user shouldn't know whether the account exists or not
|
|
if (!user) {
|
|
logger.info(`Password reset requested for unknown user '${credential}'`);
|
|
return;
|
|
}
|
|
|
|
if (!user.email) {
|
|
logger.info(`Password reset requested for '${credential}', but user '${user.username}' (${user.id}) has no e-mail address known`);
|
|
return;
|
|
}
|
|
|
|
const tokenKey = `traxxx:token_password:${user.id}`;
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
|
|
const tokenData = {
|
|
token,
|
|
};
|
|
|
|
await redis.set(tokenKey, JSON.stringify(tokenData));
|
|
await redis.expire(tokenKey, config.auth.passwordTokenExpiry * 60);
|
|
|
|
const verifyUrl = `${config.web.address}/recovery?${new URLSearchParams({
|
|
userId: user.id,
|
|
token,
|
|
}).toString()}`;
|
|
|
|
logger.info(`Password reset sent to ${user.username} (${user.id}): ${verifyUrl}`);
|
|
|
|
await sendEmails([{
|
|
template: 'recovery',
|
|
to: user.email,
|
|
subject: 'Reset your traxxx password',
|
|
props: {
|
|
user,
|
|
verifyUrl,
|
|
},
|
|
}]);
|
|
}
|
|
|
|
export async function resetPassword(userId, emailToken, newPassword) {
|
|
if (!userId) {
|
|
throw new HttpError('User ID not provided', 400);
|
|
}
|
|
|
|
if (!emailToken) {
|
|
throw new HttpError('Token not provided', 400);
|
|
}
|
|
|
|
if (!newPassword) {
|
|
throw new HttpError('New password not provided', 400);
|
|
}
|
|
|
|
const tokenKey = `traxxx:token_password:${userId}`;
|
|
const tokenEntry = await redis.getDel(tokenKey);
|
|
|
|
if (!tokenEntry) {
|
|
throw new HttpError('Invalid recovery link', 401);
|
|
}
|
|
|
|
const { token } = JSON.parse(tokenEntry);
|
|
|
|
if (!token
|
|
|| emailToken.length !== token.length
|
|
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
|
|
throw new HttpError('Invalid recovery link', 401);
|
|
}
|
|
|
|
const { storedPassword } = await hashPassword(newPassword);
|
|
|
|
const updated = await knex('users')
|
|
.where('id', userId)
|
|
.update('password', storedPassword);
|
|
|
|
if (updated === 0) {
|
|
throw new HttpError('Password reset failed because the user could not be found', 404);
|
|
}
|
|
}
|
|
|
|
export async function sendSignup(email) {
|
|
if (!config.auth.signup || !config.email.enabled) {
|
|
throw new HttpError('Sign-ups are disabled', 503);
|
|
}
|
|
|
|
if (!email) {
|
|
throw new HttpError('No e-mail address provided', 400);
|
|
}
|
|
|
|
await tokenCooldown(email);
|
|
|
|
const user = await fetchUser(email, {
|
|
email: true,
|
|
includePrivate: true,
|
|
throwError: false,
|
|
});
|
|
|
|
if (user) {
|
|
await sendEmails([{
|
|
template: 'unavailable',
|
|
to: email,
|
|
subject: 'Someone tried to use your e-mail address for traxxx',
|
|
props: {
|
|
user,
|
|
},
|
|
}]);
|
|
|
|
logger.info(`Duplicate signup with '${email}' attempted, user e-mailed`);
|
|
|
|
// we don't throw an error because the requesting user shouldn't know whether the account exists or not
|
|
return;
|
|
}
|
|
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
const tokenKey = `traxxx:token_signup:${token}`;
|
|
|
|
const tokenData = {
|
|
email,
|
|
};
|
|
|
|
await redis.set(tokenKey, JSON.stringify(tokenData));
|
|
await redis.expire(tokenKey, config.auth.signupTokenExpiry * 60);
|
|
|
|
const verifyUrl = `${config.web.address}/signup?${new URLSearchParams({
|
|
token,
|
|
}).toString()}`;
|
|
|
|
logger.info(`Signup e-mail sent to ${email}: ${verifyUrl}`);
|
|
|
|
await sendEmails([{
|
|
template: 'signup',
|
|
to: email,
|
|
subject: 'Sign up for traxxx',
|
|
props: {
|
|
verifyUrl,
|
|
},
|
|
}]);
|
|
}
|
|
|
|
export async function signup(credentials, userIp) {
|
|
if (!config.auth.signup) {
|
|
throw new HttpError('Sign-ups are currently disabled', 503);
|
|
}
|
|
|
|
if (!credentials.token) {
|
|
throw new HttpError('Token required', 400);
|
|
}
|
|
|
|
if (credentials.password?.length < 3) {
|
|
throw new HttpError('Password must be 3 characters or longer', 400);
|
|
}
|
|
|
|
const curatedUsername = credentials.username.trim();
|
|
|
|
if (!curatedUsername) {
|
|
throw new HttpError('Username required', 400);
|
|
}
|
|
|
|
if (curatedUsername.length < config.auth.usernameLength[0]) {
|
|
throw new HttpError('Username is too short', 400);
|
|
}
|
|
|
|
if (curatedUsername.length > config.auth.usernameLength[1]) {
|
|
throw new HttpError('Username is too long', 400);
|
|
}
|
|
|
|
if (!config.auth.usernamePattern.test(curatedUsername)) {
|
|
throw new HttpError('Username contains invalid characters', 400);
|
|
}
|
|
|
|
const existingUser = await knex('users')
|
|
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
|
|
.first();
|
|
|
|
if (existingUser) {
|
|
throw new HttpError('Username already in use', 409);
|
|
}
|
|
|
|
if (config.auth.captcha.enabled) {
|
|
const captchaVerification = await verifyCaptcha(config.auth.captcha.secretKey, credentials.captcha);
|
|
|
|
if (!captchaVerification.success) {
|
|
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${userIp})`);
|
|
throw new HttpError('Invalid CAPTCHA', 400);
|
|
}
|
|
}
|
|
|
|
const tokenKey = `traxxx:token_signup:${credentials.token}`;
|
|
const tokenEntry = await redis.getDel(tokenKey);
|
|
|
|
if (!tokenEntry) {
|
|
throw new HttpError('Invalid sign-up link', 401);
|
|
}
|
|
|
|
const { email } = JSON.parse(tokenEntry);
|
|
|
|
if (!email) {
|
|
throw new HttpError('Invalid sign-up link', 401);
|
|
}
|
|
|
|
const { storedPassword } = await hashPassword(credentials.password);
|
|
|
|
const [{ id: userId }] = await knex('users')
|
|
.insert({
|
|
username: curatedUsername,
|
|
email,
|
|
password: storedPassword,
|
|
email_verified: true,
|
|
})
|
|
.returning('id');
|
|
|
|
await knex('stashes').insert({
|
|
user_id: userId,
|
|
name: 'Favorites',
|
|
slug: 'favorites',
|
|
public: false,
|
|
primary: true,
|
|
});
|
|
|
|
logger.info(`Signup from '${curatedUsername}' (${userId}, ${email}, ${userIp})`);
|
|
|
|
await generateAvatar({
|
|
id: userId,
|
|
username: curatedUsername,
|
|
});
|
|
|
|
const user = await fetchUser(userId, {
|
|
includePrivate: true,
|
|
});
|
|
|
|
// no longer needed with token-first signup flow
|
|
// 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.geo?.country && await knex('countries').where('alpha2', reqSession.geo.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,
|
|
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) {
|
|
await sendEmails([{
|
|
template: 'unavailable',
|
|
to: email,
|
|
subject: 'Someone tried to use your e-mail address for traxxx',
|
|
props: {
|
|
user, // not reqUser, we don't want to leak details about the requester
|
|
},
|
|
}]);
|
|
|
|
return;
|
|
}
|
|
|
|
await sendEmailVerification(reqUser, email, 'change');
|
|
}
|
|
|
|
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) {
|
|
return {
|
|
id: key.id,
|
|
identifier: key.identifier,
|
|
lastUsedAt: key.last_used_at,
|
|
lastUsedIp: key.last_used_ip,
|
|
createdAt: key.created_at,
|
|
};
|
|
}
|
|
|
|
export async function fetchUserKeys(reqUser) {
|
|
const keys = await knex('users_keys')
|
|
.where('user_id', reqUser.id)
|
|
.orderBy('created_at', 'asc');
|
|
|
|
return keys.map((key) => curateKey(key));
|
|
}
|
|
|
|
export async function verifyKey(userId, key, req) {
|
|
if (!key || !userId) {
|
|
throw new HttpError('The API credentials are not provided.', 401);
|
|
}
|
|
|
|
const hashedKey = (await scrypt(key, '', 64)).toString('hex'); // salt redundant for randomly generated key
|
|
|
|
const storedKey = await knex('users_keys')
|
|
.where('user_id', userId)
|
|
.where('key', hashedKey)
|
|
.first();
|
|
|
|
if (!storedKey) {
|
|
throw new HttpError('The API credentials are invalid.', 401);
|
|
}
|
|
|
|
accessLogger.access({
|
|
userId,
|
|
identifier: storedKey.identifier,
|
|
ip: req.userIp,
|
|
path: req.path,
|
|
userAgent: req.headers['user-agent'],
|
|
});
|
|
|
|
knex('users_keys')
|
|
.where('id', storedKey.id)
|
|
.update('last_used_at', knex.raw('now()'))
|
|
.update('last_used_ip', req.userIp)
|
|
.then(() => {
|
|
// no need to wait for this
|
|
});
|
|
|
|
return fetchUser(storedKey.user_id);
|
|
}
|
|
|
|
export async function createKey(reqUser) {
|
|
const cooldownKey = `traxxx:key_create_cooldown:${reqUser.id}`;
|
|
|
|
if (reqUser.role !== 'admin' && await redis.exists(cooldownKey)) {
|
|
throw new HttpError(`You can only create a new API key once every ${config.apiAccess.keyCooldown} minutes.`, 429);
|
|
}
|
|
|
|
const keys = await fetchUserKeys(reqUser);
|
|
|
|
if (keys.length >= config.apiAccess.keyLimit) {
|
|
throw new HttpError(`You can only hold ${config.apiAccess.keyLimit} API keys at one time. Please remove a key.`, 400);
|
|
}
|
|
|
|
const key = crypto.randomBytes(config.apiAccess.keySize).toString('base64url');
|
|
const hashedKey = (await scrypt(key, '', 64)).toString('hex'); // salt redundant for randomly generated key
|
|
|
|
const identifier = slugify([faker.word.adjective(), faker.animal[faker.animal.type()]()]);
|
|
|
|
const [newKey] = await knex('users_keys')
|
|
.insert({
|
|
user_id: reqUser.id,
|
|
key: hashedKey,
|
|
identifier,
|
|
})
|
|
.returning('*');
|
|
|
|
await redis.set(cooldownKey, identifier);
|
|
await redis.expire(cooldownKey, config.apiAccess.keyCooldown * 60);
|
|
|
|
return {
|
|
...curateKey(newKey),
|
|
key,
|
|
};
|
|
}
|
|
|
|
export async function removeUserKey(reqUser, identifier) {
|
|
await knex('users_keys')
|
|
.where('user_id', reqUser.id)
|
|
.where('identifier', identifier)
|
|
.delete();
|
|
}
|
|
|
|
export async function flushUserKeys(reqUser) {
|
|
await knex('users_keys')
|
|
.where('user_id', reqUser.id)
|
|
.delete();
|
|
}
|