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'; import { knexOwner as knex } from './knex.js'; import { curateUser, fetchUser } from './users.js'; import { HttpError } from './errors.js'; import initLogger from './logger.js'; const logger = initLogger(); 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})`); } export async function login(credentials, userIp) { if (!config.auth.login) { throw new HttpError('Logins are currently disabled', 405); } const { user, stashes } = await fetchUser(credentials.username.trim(), { email: true, raw: true, includeStashes: true, }).catch(() => { throw new HttpError('Username or password incorrect', 401); }); await verifyPassword(credentials.password, user.password); await knex('users') .update('last_login', 'NOW()') .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, { stashes }); } export async function signup(credentials, userIp) { if (!config.auth.signup) { throw new HttpError('Sign-ups are currently disabled', 405); } 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') .where(knex.raw('lower(username)'), curatedUsername.toLowerCase()) .orWhere(knex.raw('lower(email)'), credentials.email.toLowerCase()) .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, }); logger.verbose(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`); await generateAvatar({ id: userId, username: curatedUsername, }); return fetchUser(userId); }