2024-02-29 04:08:54 +00:00
|
|
|
import config from 'config';
|
|
|
|
import util from 'util';
|
|
|
|
import crypto from 'crypto';
|
|
|
|
import fs from 'fs/promises';
|
|
|
|
import { createAvatar } from '@dicebear/core';
|
|
|
|
import { shapes } from '@dicebear/collection';
|
|
|
|
|
2024-03-03 01:33:35 +00:00
|
|
|
import { knexOwner as knex } from './knex.js';
|
2024-02-29 04:08:54 +00:00
|
|
|
import { curateUser, fetchUser } from './users.js';
|
|
|
|
import { HttpError } from './errors.js';
|
2024-03-14 23:08:24 +00:00
|
|
|
import initLogger from './logger.js';
|
2024-02-29 04:08:54 +00:00
|
|
|
|
2024-03-14 23:08:24 +00:00
|
|
|
const logger = initLogger();
|
2024-02-29 04:08:54 +00:00
|
|
|
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`);
|
2024-03-14 23:08:24 +00:00
|
|
|
|
|
|
|
logger.verbose(`Generated avatar for '${user.username}' (${user.id})`);
|
2024-02-29 04:08:54 +00:00
|
|
|
}
|
|
|
|
|
2024-03-14 23:08:24 +00:00
|
|
|
export async function login(credentials, userIp) {
|
2024-02-29 04:08:54 +00:00
|
|
|
if (!config.auth.login) {
|
2024-03-03 01:33:35 +00:00
|
|
|
throw new HttpError('Logins are currently disabled', 405);
|
2024-02-29 04:08:54 +00:00
|
|
|
}
|
|
|
|
|
2024-03-14 23:08:24 +00:00
|
|
|
const { user, stashes } = await fetchUser(credentials.username.trim(), {
|
2024-02-29 05:00:12 +00:00
|
|
|
email: true,
|
|
|
|
raw: true,
|
2024-03-26 03:14:42 +00:00
|
|
|
includeStashes: true,
|
2024-03-17 04:51:18 +00:00
|
|
|
}).catch(() => {
|
2024-02-29 04:08:54 +00:00
|
|
|
throw new HttpError('Username or password incorrect', 401);
|
2024-03-17 04:51:18 +00:00
|
|
|
});
|
2024-02-29 04:08:54 +00:00
|
|
|
|
|
|
|
await verifyPassword(credentials.password, user.password);
|
|
|
|
|
|
|
|
await knex('users')
|
|
|
|
.update('last_login', 'NOW()')
|
|
|
|
.where('id', user.id);
|
|
|
|
|
2024-03-14 23:08:24 +00:00
|
|
|
logger.verbose(`Login from '${user.username}' (${user.id}, ${userIp})`);
|
|
|
|
|
|
|
|
try {
|
|
|
|
await fs.access(`media/avatars/${user.id}_${user.username}.png`);
|
|
|
|
} catch (error) {
|
2024-02-29 04:08:54 +00:00
|
|
|
await generateAvatar(user);
|
|
|
|
}
|
|
|
|
|
2024-03-03 01:33:35 +00:00
|
|
|
// fetched the raw user for password verification, don't return directly to user
|
2024-03-14 23:08:24 +00:00
|
|
|
return curateUser(user, { stashes });
|
2024-02-29 04:08:54 +00:00
|
|
|
}
|
|
|
|
|
2024-03-14 23:08:24 +00:00
|
|
|
export async function signup(credentials, userIp) {
|
2024-02-29 04:08:54 +00:00
|
|
|
if (!config.auth.signup) {
|
2024-03-03 01:33:35 +00:00
|
|
|
throw new HttpError('Sign-ups are currently disabled', 405);
|
2024-02-29 04:08:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!credentials.email) {
|
|
|
|
throw new HttpError('E-mail required', 400);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (credentials.password?.length < 3) {
|
|
|
|
throw new HttpError('Password must be 3 characters or longer', 400);
|
|
|
|
}
|
|
|
|
|
|
|
|
const existingUser = await knex('users')
|
2024-02-29 05:00:12 +00:00
|
|
|
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
|
|
|
|
.orWhere(knex.raw('lower(email)'), credentials.email.toLowerCase())
|
2024-02-29 04:08:54 +00:00
|
|
|
.first();
|
|
|
|
|
|
|
|
if (existingUser) {
|
|
|
|
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 [{ id: userId }] = await knex('users')
|
|
|
|
.insert({
|
|
|
|
username: curatedUsername,
|
|
|
|
email: credentials.email,
|
|
|
|
password: storedPassword,
|
|
|
|
})
|
|
|
|
.returning('id');
|
|
|
|
|
|
|
|
await knex('stashes').insert({
|
|
|
|
user_id: userId,
|
|
|
|
name: 'Favorites',
|
|
|
|
slug: 'favorites',
|
|
|
|
public: false,
|
|
|
|
primary: true,
|
|
|
|
});
|
|
|
|
|
2024-03-14 23:08:24 +00:00
|
|
|
logger.verbose(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`);
|
|
|
|
|
2024-02-29 05:00:12 +00:00
|
|
|
await generateAvatar({
|
|
|
|
id: userId,
|
|
|
|
username: curatedUsername,
|
|
|
|
});
|
|
|
|
|
2024-02-29 04:08:54 +00:00
|
|
|
return fetchUser(userId);
|
|
|
|
}
|