Initial commit, basic pages and sessions.
This commit is contained in:
36
src/.eslintrc
Executable file
36
src/.eslintrc
Executable file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["airbnb-base", "plugin:vue/recommended"],
|
||||
"parserOptions": {
|
||||
"parser": "@babel/eslint-parser",
|
||||
"ecmaVersion": 2019,
|
||||
"sourceType": "script",
|
||||
"requireConfigFile": false
|
||||
},
|
||||
"rules": {
|
||||
"indent": ["error", "tab"],
|
||||
"no-tabs": "off",
|
||||
"no-unused-vars": ["error", {"argsIgnorePattern": "^_"}],
|
||||
"no-console": 0,
|
||||
"template-curly-spacing": "off",
|
||||
"import/prefer-default-export": 0,
|
||||
"max-len": 0,
|
||||
"vue/no-v-html": 0,
|
||||
"vue/html-indent": ["error", "tab"],
|
||||
"vue/multiline-html-element-content-newline": 0,
|
||||
"vue/singleline-html-element-content-newline": 0,
|
||||
"vue/multi-word-component-names": 0,
|
||||
"no-param-reassign": ["error", {
|
||||
"props": true,
|
||||
"ignorePropertyModificationsFor": ["state", "acc"]
|
||||
}]
|
||||
},
|
||||
"globals": {
|
||||
"CONFIG": "readonly",
|
||||
"$fetch": "readonly",
|
||||
"createError": "readonly",
|
||||
"defineEventHandler": "readonly",
|
||||
"defineNuxtConfig": "readonly",
|
||||
"readBody": "readonly"
|
||||
}
|
||||
}
|
||||
11
src/cli.js
Executable file
11
src/cli.js
Executable file
@@ -0,0 +1,11 @@
|
||||
// const config = require('config');
|
||||
const yargs = require('yargs');
|
||||
|
||||
const { argv } = yargs
|
||||
.command('npm start')
|
||||
.option('log-level', {
|
||||
alias: 'level',
|
||||
type: 'string',
|
||||
});
|
||||
|
||||
module.exports = argv;
|
||||
12
src/errors.js
Executable file
12
src/errors.js
Executable file
@@ -0,0 +1,12 @@
|
||||
class HttpError extends Error {
|
||||
constructor({ message, statusCode = 400, statusMessage }) {
|
||||
super(message || statusMessage);
|
||||
|
||||
this.name = 'HttpError';
|
||||
|
||||
this.statusCode = statusCode;
|
||||
this.statusMessage = statusMessage;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { HttpError };
|
||||
10
src/knex.js
Executable file
10
src/knex.js
Executable file
@@ -0,0 +1,10 @@
|
||||
const config = require('config');
|
||||
const knex = require('knex');
|
||||
|
||||
module.exports = knex({
|
||||
client: 'pg',
|
||||
connection: config.database,
|
||||
// performance overhead, don't use asyncStackTraces in production
|
||||
asyncStackTraces: process.env.NODE_ENV === 'development',
|
||||
// debug: process.env.NODE_ENV === 'development',
|
||||
});
|
||||
35
src/logger.js
Executable file
35
src/logger.js
Executable file
@@ -0,0 +1,35 @@
|
||||
const util = require('util');
|
||||
const path = require('path');
|
||||
const winston = require('winston');
|
||||
|
||||
require('winston-daily-rotate-file');
|
||||
|
||||
// import args from './args';
|
||||
|
||||
module.exports = function initLogger(filepath) {
|
||||
const contextLabel = path.basename(filepath, '.js');
|
||||
|
||||
return winston.createLogger({
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format((info) => (info instanceof Error
|
||||
? { ...info, message: info.stack }
|
||||
: { ...info, message: typeof info.message === 'string' ? info.message : util.inspect(info.message) }))(),
|
||||
winston.format.colorize(),
|
||||
winston.format.printf(({
|
||||
level, timestamp, label, message,
|
||||
}) => `${timestamp} ${level} [${label || contextLabel}] ${message}`),
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
level: 'silly',
|
||||
timestamp: true,
|
||||
}),
|
||||
new winston.transports.DailyRotateFile({
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
filename: path.join('log', '%DATE%.log'),
|
||||
level: 'silly',
|
||||
}),
|
||||
],
|
||||
});
|
||||
};
|
||||
14
src/redis.js
Normal file
14
src/redis.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const config = require('config');
|
||||
const redis = require('redis');
|
||||
const logger = require('./logger')(__filename);
|
||||
|
||||
const client = redis.createClient({
|
||||
socket: config.redis,
|
||||
});
|
||||
|
||||
client.connect();
|
||||
logger.info('Redis module initialized');
|
||||
|
||||
module.exports = {
|
||||
client,
|
||||
};
|
||||
17
src/shelves.js
Normal file
17
src/shelves.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const knex = require('./knex');
|
||||
|
||||
async function createShelf(shelf) {
|
||||
console.log('create', shelf);
|
||||
|
||||
const shelfEntry = await knex('shelves').insert({
|
||||
slug: shelf.slug,
|
||||
});
|
||||
|
||||
console.log('entry', shelfEntry);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createShelf,
|
||||
};
|
||||
147
src/users.js
Normal file
147
src/users.js
Normal file
@@ -0,0 +1,147 @@
|
||||
const config = require('config');
|
||||
const util = require('util');
|
||||
const crypto = require('crypto');
|
||||
const bhttp = require('bhttp');
|
||||
|
||||
const logger = require('./logger')(__filename);
|
||||
const { HttpError } = require('./errors');
|
||||
const knex = require('./knex');
|
||||
|
||||
const scrypt = util.promisify(crypto.scrypt);
|
||||
|
||||
function curateDatabaseUser(user) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
};
|
||||
}
|
||||
|
||||
async function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = await scrypt(password, salt, 64);
|
||||
|
||||
return `${salt}/${hash.toString('hex')}`;
|
||||
}
|
||||
|
||||
async function verifyPassword(password, storedPassword) {
|
||||
const [salt, hash] = storedPassword.split('/');
|
||||
const hashedPassword = await scrypt(password, salt, 64);
|
||||
|
||||
if (hashedPassword.toString('hex') !== hash) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Username or password incorrect',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCaptcha(captchaToken) {
|
||||
if (!captchaToken) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'No CAPTCHA provided',
|
||||
});
|
||||
}
|
||||
|
||||
const res = await bhttp.post('https://hcaptcha.com/siteverify', {
|
||||
response: captchaToken,
|
||||
secret: config.auth.captcha.secret,
|
||||
});
|
||||
|
||||
if (res.statusCode !== 200 || !res.body.success) {
|
||||
throw new HttpError({
|
||||
statusCode: 498,
|
||||
statusMessage: 'Invalid CAPTCHA',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function login(credentials) {
|
||||
if (!credentials.username) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'You must provide a username',
|
||||
});
|
||||
}
|
||||
|
||||
if (!credentials.password) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'You must provide a password',
|
||||
});
|
||||
}
|
||||
|
||||
const user = await knex('users')
|
||||
.where('username', credentials.username)
|
||||
.orWhere('email', credentials.username)
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Username or password incorrect',
|
||||
});
|
||||
}
|
||||
|
||||
await verifyPassword(credentials.password, user.password);
|
||||
|
||||
return curateDatabaseUser(user);
|
||||
}
|
||||
|
||||
async function createUser(credentials, context) {
|
||||
if (!credentials.username) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'You must provide a username',
|
||||
});
|
||||
}
|
||||
|
||||
if (!credentials.password) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'You must provide a password',
|
||||
});
|
||||
}
|
||||
|
||||
if (config.auth.captcha.enabled) {
|
||||
await verifyCaptcha(credentials.captchaToken);
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(credentials.password);
|
||||
|
||||
try {
|
||||
const [userEntry] = await knex('users')
|
||||
.insert({
|
||||
username: credentials.username,
|
||||
email: credentials.email,
|
||||
password: hashedPassword,
|
||||
ip: context.ip,
|
||||
})
|
||||
.returning('*');
|
||||
|
||||
const user = curateDatabaseUser(userEntry);
|
||||
|
||||
logger.info(`Registered user ${user.username} (${user.id}, ${user.email}, ${userEntry.ip})`);
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (error.code === '23505') {
|
||||
throw new HttpError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'Username or e-mail already registered',
|
||||
});
|
||||
}
|
||||
|
||||
logger.error(error.message);
|
||||
|
||||
throw new HttpError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Sign-up failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createUser,
|
||||
login,
|
||||
};
|
||||
32
src/web/default.js
Normal file
32
src/web/default.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const { renderPage } = require('vite-plugin-ssr/server');
|
||||
|
||||
async function initDefaultHandler() {
|
||||
async function defaultHandler(req, res, next) {
|
||||
const pageContextInit = {
|
||||
urlOriginal: req.originalUrl,
|
||||
session: req.session,
|
||||
};
|
||||
|
||||
const pageContext = await renderPage(pageContextInit);
|
||||
const { httpResponse } = pageContext;
|
||||
|
||||
if (!httpResponse) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
body, statusCode, contentType, earlyHints,
|
||||
} = httpResponse;
|
||||
|
||||
if (res.writeEarlyHints) {
|
||||
res.writeEarlyHints({ link: earlyHints.map((e) => e.earlyHintLink) });
|
||||
}
|
||||
|
||||
res.status(statusCode).type(contentType).send(body);
|
||||
}
|
||||
|
||||
return defaultHandler;
|
||||
}
|
||||
|
||||
module.exports = initDefaultHandler;
|
||||
22
src/web/error.js
Executable file
22
src/web/error.js
Executable file
@@ -0,0 +1,22 @@
|
||||
const logger = require('../logger')(__filename);
|
||||
|
||||
function errorHandler(error, req, res, _next) {
|
||||
logger.warn(`Failed to fulfill request to ${req.path}: ${error.message}`);
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
if (error.statusCode) {
|
||||
res.status(error.statusCode).send({
|
||||
statusCode: error.statusCode,
|
||||
message: error.statusMessage,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).send('Something didn\'t quite go as expected... Our apologies for the inconvenience.');
|
||||
}
|
||||
|
||||
module.exports = errorHandler;
|
||||
18
src/web/ip.js
Normal file
18
src/web/ip.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const IPCIDR = require('ip-cidr');
|
||||
|
||||
function setIp(req, res, next) {
|
||||
const ip = req.headers['x-forwarded-for']
|
||||
? req.headers['x-forwarded-for'].split(',')[0]
|
||||
: req.connection.remoteAddress;
|
||||
|
||||
// ensure IP is in expanded notation to simplify matching
|
||||
const expandedIp = /:/.test(ip)
|
||||
? new IPCIDR(`${ip}/128`) // IPv6
|
||||
: new IPCIDR(`${ip}/32`); // IPv4
|
||||
|
||||
req.userIp = expandedIp.addressStart?.addressMinusSuffix || null; // eslint-disable-line no-param-reassign
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = setIp;
|
||||
77
src/web/server.js
Normal file
77
src/web/server.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// Note that this file isn't processed by Vite, see https://github.com/brillout/vite-plugin-ssr/issues/562
|
||||
const config = require('config');
|
||||
const express = require('express');
|
||||
const Router = require('express-promise-router');
|
||||
const session = require('express-session');
|
||||
const RedisStore = require('connect-redis').default;
|
||||
const bodyParser = require('body-parser');
|
||||
const compression = require('compression');
|
||||
const sirv = require('sirv');
|
||||
const vite = require('vite');
|
||||
|
||||
const logger = require('../logger')(__filename);
|
||||
const redis = require('../redis').client;
|
||||
|
||||
const initDefaultHandler = require('./default');
|
||||
|
||||
const setIp = require('./ip');
|
||||
const errorHandler = require('./error');
|
||||
|
||||
const {
|
||||
login,
|
||||
logout,
|
||||
fetchUser,
|
||||
createUser,
|
||||
} = require('./users');
|
||||
|
||||
const { createShelf } = require('./shelves');
|
||||
|
||||
const root = `${__dirname}/../..`;
|
||||
|
||||
async function startServer() {
|
||||
const app = express();
|
||||
const router = Router();
|
||||
const sessionStore = new RedisStore({ client: redis });
|
||||
|
||||
const defaultHandler = await initDefaultHandler();
|
||||
|
||||
app.disable('x-powered-by');
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
router.use(session({ ...config.web.session, store: sessionStore }));
|
||||
router.use(setIp);
|
||||
router.use(bodyParser.json({ strict: false }));
|
||||
app.use(compression());
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use(sirv(`${root}/dist/client`));
|
||||
} else {
|
||||
const viteDevMiddleware = (await vite.createServer({
|
||||
root,
|
||||
server: { middlewareMode: true },
|
||||
})).middlewares;
|
||||
|
||||
app.use(viteDevMiddleware);
|
||||
}
|
||||
|
||||
router.get('/api/session', fetchUser);
|
||||
router.post('/api/session', login);
|
||||
router.delete('/api/session', logout);
|
||||
|
||||
router.post('/api/shelves', createShelf);
|
||||
|
||||
router.post('/api/users', createUser);
|
||||
|
||||
router.get('*', defaultHandler);
|
||||
router.use(errorHandler);
|
||||
|
||||
app.use(router);
|
||||
|
||||
const server = app.listen(process.env.PORT || config.web.port, process.env.HOST || config.web.host, () => {
|
||||
const { address, port } = server.address();
|
||||
|
||||
logger.info(`Server running at ${address}:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
startServer();
|
||||
13
src/web/shelves.js
Normal file
13
src/web/shelves.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const { createShelf } = require('../shelves');
|
||||
|
||||
async function createShelfApi(req) {
|
||||
console.log('create shelf', req.body);
|
||||
|
||||
const shelf = await createShelf(req.body);
|
||||
|
||||
return shelf;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createShelf: createShelfApi,
|
||||
};
|
||||
33
src/web/users.js
Normal file
33
src/web/users.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const {
|
||||
login,
|
||||
createUser,
|
||||
} = require('../users');
|
||||
|
||||
async function fetchUserApi(req, res) {
|
||||
res.send(req.session.user);
|
||||
}
|
||||
|
||||
async function loginApi(req, res) {
|
||||
const user = await login(req.body);
|
||||
|
||||
req.session.user = user; // eslint-disable-line no-param-reassign
|
||||
res.send(user);
|
||||
}
|
||||
|
||||
async function logoutApi(req, res) {
|
||||
req.session.destroy();
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
async function createUserApi(req, res) {
|
||||
const user = await createUser(req.body, { ip: req.userIp });
|
||||
|
||||
res.send(user);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
login: loginApi,
|
||||
logout: logoutApi,
|
||||
fetchUser: fetchUserApi,
|
||||
createUser: createUserApi,
|
||||
};
|
||||
Reference in New Issue
Block a user