Added basic login.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { parse } from '@brillout/json-serializer/parse';
|
||||
import { parse } from '@brillout/json-serializer/parse'; /* eslint-disable-line import/extensions */
|
||||
|
||||
const postHeaders = {
|
||||
mode: 'cors',
|
||||
@@ -26,7 +26,7 @@ export async function get(path, query = {}) {
|
||||
return body;
|
||||
}
|
||||
|
||||
throw new Error(body.message);
|
||||
throw new Error(body.statusMessage);
|
||||
}
|
||||
|
||||
export async function post(path, data, { query } = {}) {
|
||||
@@ -66,7 +66,7 @@ export async function patch(path, data, { query } = {}) {
|
||||
return body;
|
||||
}
|
||||
|
||||
throw new Error(body.message);
|
||||
throw new Error(body.statusMessage);
|
||||
}
|
||||
|
||||
export async function del(path, { data, query } = {}) {
|
||||
@@ -86,5 +86,5 @@ export async function del(path, { data, query } = {}) {
|
||||
return body;
|
||||
}
|
||||
|
||||
throw new Error(body.message);
|
||||
throw new Error(body.statusMessage);
|
||||
}
|
||||
|
||||
11
src/argv.js
Executable file
11
src/argv.js
Executable file
@@ -0,0 +1,11 @@
|
||||
import yargs from 'yargs';
|
||||
|
||||
const { argv } = yargs()
|
||||
.command('npm start')
|
||||
.option('debug', {
|
||||
describe: 'Show error stack traces',
|
||||
type: 'boolean',
|
||||
default: process.env.NODE_ENV === 'development',
|
||||
});
|
||||
|
||||
export default argv;
|
||||
124
src/auth.js
Executable file
124
src/auth.js
Executable file
@@ -0,0 +1,124 @@
|
||||
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 knex from './knex.js';
|
||||
import { curateUser, fetchUser } from './users.js';
|
||||
import { HttpError } from './errors.js';
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
export async function login(credentials) {
|
||||
if (!config.auth.login) {
|
||||
throw new HttpError('Authentication is disabled', 405);
|
||||
}
|
||||
|
||||
const user = await fetchUser(credentials.username.trim(), true);
|
||||
|
||||
if (!user) {
|
||||
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);
|
||||
|
||||
if (!user.avatar) {
|
||||
await generateAvatar(user);
|
||||
}
|
||||
|
||||
return curateUser(user);
|
||||
}
|
||||
|
||||
export async function signup(credentials) {
|
||||
if (!config.auth.signup) {
|
||||
throw new HttpError('Authentication is 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('username', curatedUsername)
|
||||
.orWhere('email', credentials.email)
|
||||
.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,
|
||||
});
|
||||
|
||||
return fetchUser(userId);
|
||||
}
|
||||
50
src/users.js
Executable file
50
src/users.js
Executable file
@@ -0,0 +1,50 @@
|
||||
import knex from './knex.js';
|
||||
// import { curateStash } from './stashes.js';
|
||||
|
||||
export function curateUser(user) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ability = [...(user.role_abilities || []), ...(user.abilities || [])];
|
||||
|
||||
const curatedUser = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
emailVerified: user.email_verified,
|
||||
identityVerified: user.identity_verified,
|
||||
ability,
|
||||
avatar: `/media/avatars/${user.id}_${user.username}.png`,
|
||||
createdAt: user.created_at,
|
||||
// stashes: user.stashes?.filter(Boolean).map((stash) => curateStash(stash)) || [],
|
||||
};
|
||||
|
||||
return curatedUser;
|
||||
}
|
||||
|
||||
export async function fetchUser(userId, raw) {
|
||||
const user = await knex('users')
|
||||
.select(knex.raw('users.*, users_roles.abilities as role_abilities, COALESCE(json_agg(stashes ORDER BY stashes.created_at) FILTER (WHERE stashes.id IS NOT NULL), \'[]\') as stashes'))
|
||||
.modify((builder) => {
|
||||
if (typeof userId === 'number') {
|
||||
builder.where('users.id', userId);
|
||||
}
|
||||
|
||||
if (typeof userId === 'string') {
|
||||
builder
|
||||
.where('users.username', userId)
|
||||
.orWhere('users.email', userId);
|
||||
}
|
||||
})
|
||||
.leftJoin('users_roles', 'users_roles.role', 'users.role')
|
||||
.leftJoin('stashes', 'stashes.user_id', 'users.id')
|
||||
.groupBy('users.id', 'users_roles.role')
|
||||
.first();
|
||||
|
||||
if (raw) {
|
||||
return user;
|
||||
}
|
||||
|
||||
return curateUser(user);
|
||||
}
|
||||
49
src/web/auth.js
Executable file
49
src/web/auth.js
Executable file
@@ -0,0 +1,49 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { login, signup } from '../auth.js';
|
||||
import { fetchUser } from '../users.js';
|
||||
|
||||
export async function setUserApi(req, res, next) {
|
||||
if (req.session.user) {
|
||||
req.user = req.session.user;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export async function loginApi(req, res) {
|
||||
console.log('login!', req.body);
|
||||
|
||||
const user = await login(req.body);
|
||||
|
||||
req.session.user = user;
|
||||
res.send(user);
|
||||
}
|
||||
|
||||
export async function logoutApi(req, res) {
|
||||
req.session.destroy((error) => {
|
||||
if (error) {
|
||||
res.status(500).send();
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMeApi(req, res) {
|
||||
if (req.session.user) {
|
||||
req.session.user = await fetchUser(req.session.user.id, false, req.session.user);
|
||||
|
||||
res.send(req.session.user);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(401).send();
|
||||
}
|
||||
|
||||
export async function signupApi(req, res) {
|
||||
const user = await signup(req.body);
|
||||
|
||||
req.session.user = user;
|
||||
res.send(user);
|
||||
}
|
||||
/* eslint-enable no-param-reassign */
|
||||
26
src/web/error.js
Executable file
26
src/web/error.js
Executable file
@@ -0,0 +1,26 @@
|
||||
import argv from '../argv.js';
|
||||
import initLogger from '../logger.js';
|
||||
|
||||
const logger = initLogger();
|
||||
|
||||
export default function errorHandler(error, req, res, _next) {
|
||||
logger.warn(`Failed to fulfill request to ${req.path} (${error.httpCode || 500}): ${error.message}`);
|
||||
|
||||
if (argv.debug) {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
if (error.httpCode) {
|
||||
res.status(error.httpCode).send({
|
||||
statusCode: error.httpCode,
|
||||
statusMessage: error.message,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).send({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Oops... our server messed up. We will be investigating this incident, our apologies for the inconvenience.',
|
||||
});
|
||||
}
|
||||
@@ -15,15 +15,27 @@ import config from 'config';
|
||||
import express from 'express';
|
||||
import boolParser from 'express-query-boolean';
|
||||
import Router from 'express-promise-router';
|
||||
import session from 'express-session';
|
||||
import RedisStore from 'connect-redis';
|
||||
import compression from 'compression';
|
||||
import { renderPage } from 'vike/server'; // eslint-disable-line import/extensions
|
||||
|
||||
// import root from './root.js';
|
||||
|
||||
import redis from '../redis.js';
|
||||
|
||||
import errorHandler from './error.js';
|
||||
|
||||
import { fetchScenesApi } from './scenes.js';
|
||||
import { fetchActorsApi } from './actors.js';
|
||||
import { fetchMoviesApi } from './movies.js';
|
||||
|
||||
import {
|
||||
setUserApi,
|
||||
loginApi,
|
||||
logoutApi,
|
||||
} from './auth.js';
|
||||
|
||||
import initLogger from '../logger.js';
|
||||
|
||||
const logger = initLogger();
|
||||
@@ -42,6 +54,20 @@ export default async function initServer() {
|
||||
router.use('/', express.static('static'));
|
||||
router.use('/media', express.static(config.media.path));
|
||||
|
||||
router.use(express.json());
|
||||
|
||||
const redisStore = new RedisStore({
|
||||
client: redis,
|
||||
prefix: 'traxxx:session:',
|
||||
});
|
||||
|
||||
router.use(session({
|
||||
...config.web.session,
|
||||
store: redisStore,
|
||||
}));
|
||||
|
||||
router.use(setUserApi);
|
||||
|
||||
// Vite integration
|
||||
if (isProduction) {
|
||||
// In production, we need to serve our static assets ourselves.
|
||||
@@ -69,6 +95,9 @@ export default async function initServer() {
|
||||
|
||||
router.get('/api/movies', fetchMoviesApi);
|
||||
|
||||
router.post('/api/session', loginApi);
|
||||
router.delete('/api/session', logoutApi);
|
||||
|
||||
// ...
|
||||
// Other middlewares (e.g. some RPC middleware such as Telefunc)
|
||||
// ...
|
||||
@@ -79,6 +108,7 @@ export default async function initServer() {
|
||||
const pageContextInit = {
|
||||
urlOriginal: req.originalUrl,
|
||||
urlQuery: req.query, // vike's own query does not apply boolean parser
|
||||
user: req.user,
|
||||
env: {
|
||||
maxAggregateSize: config.database.manticore.maxAggregateSize,
|
||||
},
|
||||
@@ -110,6 +140,7 @@ export default async function initServer() {
|
||||
res.send(body);
|
||||
});
|
||||
|
||||
router.use(errorHandler);
|
||||
app.use(router);
|
||||
|
||||
const port = process.env.PORT || config.web.port || 3000;
|
||||
|
||||
Reference in New Issue
Block a user