feat: typescript and fastify

This commit is contained in:
Pitu 2021-06-21 20:01:50 +09:00
parent 77db6f34c6
commit af0e46e552
19 changed files with 921 additions and 528 deletions

1
.npmrc
View File

@ -1,4 +1,3 @@
audit=false
fund=false
node-version=false
legacy-peer-deps=true

View File

@ -2,8 +2,8 @@
"editor.detectIndentation": false,
"editor.insertSpaces": false,
"files.insertFinalNewline": true,
"editor.formatOnPaste": false,
"editor.formatOnSave": false,
"editor.formatOnPaste": true,
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},

890
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,8 +10,8 @@
},
"scripts": {
"setup": "node src/setup.js && npm run migrate && npm run seed",
"start": "npm run migrate && nuxt build && cross-env NODE_ENV=production node src/api/structures/Server",
"dev": "nodemon src/api/structures/Server",
"start": "npm run migrate && nuxt build && cross-env NODE_ENV=production node src/api/main.ts",
"dev": "nodemon src/api/main.ts",
"migrate": "knex migrate:latest",
"seed": "knex seed:run",
"restart": "pm2 restart chibisafe",
@ -45,29 +45,31 @@
"busboy": "^0.3.1",
"chrono-node": "^2.3.0",
"cookie-universal-nuxt": "^2.0.14",
"cors": "^2.8.5",
"cron": "^1.8.2",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"express-rate-limit": "^5.2.6",
"fastify": "^3.18.0",
"fastify-cors": "^6.0.1",
"fastify-formbody": "^5.0.0",
"fastify-helmet": "^5.3.1",
"fastify-nuxtjs": "^1.0.1",
"fastify-rate-limit": "^5.5.0",
"fastify-static": "^4.2.2",
"ffmpeg-probe": "^1.0.6",
"file-saver": "^2.0.1",
"file-type": "^16.5.0",
"fluent-ffmpeg": "^2.1.2",
"fs-jetpack": "^4.1.0",
"helmet": "^4.5.0",
"imagesloaded": "^4.1.4",
"joi": "^17.3.0",
"jsonwebtoken": "^8.5.1",
"masonry-layout": "^4.2.2",
"moment": "^2.29.1",
"morgan": "^1.10.0",
"nuxt": "^2.14.12",
"nuxt-dropzone": "^0.2.8",
"pino-pretty": "^5.0.2",
"qoa": "^0.2.0",
"randomstring": "^1.2.1",
"rotating-file-stream": "^2.1.5",
"search-query-parser": "^1.6.0",
"sharp": "^0.28.3",
"systeminformation": "^5.7.7",
@ -104,10 +106,9 @@
"cpy-cli": "^3.1.1",
"cross-env": "^7.0.3",
"eslint": "^7.24.0",
"eslint-config-aqua": "^9.0.2",
"eslint-config-marine": "^9.0.6",
"eslint-import-resolver-nuxt": "^1.0.1",
"eslint-plugin-vue": "^5.2.1",
"eslint-plugin-vue": "^7.11.1",
"jest": "^27.0.4",
"jest-serializer-vue": "^2.0.2",
"node-sass": "^6.0.0",
@ -122,27 +123,17 @@
},
"eslintConfig": {
"extends": [
"marine/node",
"marine/vue"
"marine/node"
],
"parserOptions": {
"parser": "babel-eslint",
"sourceType": "module"
},
"settings": {
"import/resolver": {
"nuxt": {
"nuxtSrcDir": "./src/site",
"extensions": [
".js",
".vue"
]
}
}
"rules": {
"comma-dangle": [
"error",
"never"
]
}
},
"prisma": {
"schema": "src/prisma/schema.prisma"
"schema": "src/api/prisma/schema.prisma"
},
"nodemonConfig": {
"watch": [

View File

@ -1,10 +1,11 @@
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import morgan from 'morgan';
// import express from 'express';
import fastify from 'fastify';
import helmet from 'fastify-helmet';
import cors from 'fastify-cors';
import formbody from 'fastify-formbody';
import fstatic from 'fastify-static';
import path from 'path';
import rfs from 'rotating-file-stream';
import rateLimit from 'express-rate-limit';
import rateLimit from 'fastify-rate-limit';
import jetpack from 'fs-jetpack';
import cron from 'cron';
// @ts-ignore - nuxt types can't be found - https://github.com/nuxt/nuxt.js/issues/7651
@ -12,7 +13,42 @@ import { loadNuxt, build } from 'nuxt';
import Routes from './structures/routes';
const server = express();
// const server = express();
const server = fastify({
trustProxy: true,
logger: {
serializers: {
req(request) {
return {
ip: request.hostname,
method: request.method,
url: request.url
};
},
res(reply) {
return {
statusCode: reply.statusCode
};
}
},
prettyPrint: {
translateTime: 'SYS:yyyy-mm-dd HH:MM:ss.l',
ignore: 'pid,hostname,reqId,req,res,responseTime',
/*
TODO:
Find a way to merge incoming request and request completed into 1 line using
messageFormat as a function instead of a string.
Maybe even set a new flag under log like log.app instead of log.info
to be able to print logs without req and res information
[2021-06-21 19:22:21.553] INFO: incoming request [localhost:5000 - GET /api/verify - ]
[2021-06-21 19:22:21.556] INFO: request completed [ - - 401]
*/
messageFormat: '{msg} [{req.ip} - {req.method} {req.url} - {res.statusCode}]'
}
},
connectionTimeout: 600000
});
const start = async () => {
// Create the folders needed for uploads
@ -21,59 +57,50 @@ const start = async () => {
jetpack.dir('uploads/thumbs/preview');
// Create the server and set it up
server.use('trust proxy');
server.use(helmet());
server.use(cors());
server.use(morgan('dev'));
server.use(express.urlencoded({ extended: true }));
server.use(express.json());
server.use(cors({ allowedHeaders: ['Accept', 'Authorization', 'Cache-Control', 'X-Requested-With', 'Content-Type', 'albumId', 'finishedChunks'] }));
server.use((req, res, next) => {
void server.register(helmet);
void server.register(cors, {
allowedHeaders: ['Accept', 'Authorization', 'Cache-Control', 'X-Requested-With', 'Content-Type', 'albumId', 'finishedChunks']
});
void server.register(formbody);
server.addHook('onRequest', (req, reply, next) => {
// This bypasses the headers.accept for album download, since it's accesed directly through the browser.
if ((req.url.includes('/api/album/') || req.url.includes('/zip')) && req.method === 'GET') return next();
// This bypasses the headers.accept if we are accessing the frontend
if (!req.url.includes('/api/') && req.method === 'GET') return next();
if (req.headers.accept?.includes('application/vnd.chibisafe.json')) return next();
return res.status(405).json({ message: 'Incorrect `Accept` header provided' });
return reply.status(405).send({ message: 'Incorrect `Accept` header provided' });
});
// Set up logs for production and dev environments
if (process.env.NODE_ENV === 'production') {
const accessLogStream = rfs.createStream('access.log', {
interval: '1d', // rotate daily
path: path.join(__dirname, '../../../logs', 'log')
});
server.use(morgan('combined', { stream: accessLogStream }));
} else {
server.use(morgan('dev'));
}
// Apply rate limiting to the api only
server.use('/api/', rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW ?? '2000', 10),
// TODO: Find a way to only apply this to /api routes
void server.register(rateLimit, {
global: false,
max: parseInt(process.env.RATE_LIMIT_MAX ?? '5', 10),
message: 'Too many requests from this IP. Slow down dude.'
}));
timeWindow: parseInt(process.env.RATE_LIMIT_WINDOW ?? '2000', 10)
});
// Scan and load routes into express
await Routes.load(server);
// Listen for incoming connections
const listen = server.listen(process.env.port, () => {
console.log(`> Chibisafe Server started on port ${process.env.port ?? 5000}.`);
});
listen.setTimeout(600000);
void server.listen(process.env.port ?? 5000);
// Serve the uploads
server.use(express.static(path.join(__dirname, '../../../uploads')));
void server.register(fstatic, {
root: path.join(__dirname, '../../uploads')
});
// TODO: Enable this after Utils is ported to TypeScript
/*
const isProd = process.env.NODE_ENV === 'production';
const nuxt = await loadNuxt(isProd ? 'start' : 'dev');
server.use(nuxt.render);
void server.register(nuxt.render);
if (!isProd) build(nuxt);
*/
// TODO: move into the database config. (we can just show the crontab line for start, later on we can add dropdowns and stuff)
new cron.CronJob('0 0 * * * *', Util.saveStatsToDb, null, true);
// new cron.CronJob('0 0 * * * *', Util.saveStatsToDb, null, true);
};
void start();

View File

@ -1,32 +1,7 @@
import { Request, Response, NextFunction } from 'express';
import prisma from '../structures/database';
import type { FastifyReply, HookHandlerDoneFunction } from 'fastify';
import type { RequestWithUser } from './auth';
export default (req: RequestWithUser, res: Response, next: NextFunction) => {
// if (this.options.adminOnly && !user.isAdmin) { return res.status(401).json({ message: 'Invalid authorization' }); }
if (!req.headers.authorization) return res.status(401).json({ message: 'No authorization header provided' });
const token = req.headers.authorization.split(' ')[1];
if (!token) return res.status(401).json({ message: 'No authorization header provided' });
// eslint-disable-next-line @typescript-eslint/no-misused-promises
JWT.verify(token, process.env.secret ?? '', async (error, decoded) => {
if (error) return res.status(401).json({ message: 'Invalid token' });
const id = (decoded as Decoded | undefined)?.sub ?? null;
if (!id) return res.status(401).json({ message: 'Invalid authorization' });
const user = await prisma.users.findFirst({
where: {
id
},
select: {
id: true,
username: true
}
});
if (!user) return res.status(401).json({ message: 'Invalid authorization' });
req.user = user;
next();
});
export default (req: RequestWithUser, res: FastifyReply, next: HookHandlerDoneFunction) => {
if (!req.user.isAdmin) return res.status(401).send({ message: 'Permission denied' });
next();
};

View File

@ -1,28 +1,29 @@
import { Request, Response, NextFunction } from 'express';
import type { FastifyRequest, FastifyReply, HookHandlerDoneFunction } from 'fastify';
import JWT from 'jsonwebtoken';
import prisma from '../structures/database';
interface Decoded {
sub: number;
}
export interface RequestWithUser extends Request {
export interface RequestWithUser extends FastifyRequest {
user: {
id: number;
username: string | null;
isAdmin: boolean | null;
};
}
export default (req: RequestWithUser, res: Response, next: NextFunction) => {
if (!req.headers.authorization) return res.status(401).json({ message: 'No authorization header provided' });
export default (req: RequestWithUser, res: FastifyReply, next: HookHandlerDoneFunction) => {
if (!req.headers.authorization) return res.status(401).send({ message: 'No authorization header provided' });
const token = req.headers.authorization.split(' ')[1];
if (!token) return res.status(401).json({ message: 'No authorization header provided' });
if (!token) return res.status(401).send({ message: 'No authorization header provided' });
// eslint-disable-next-line @typescript-eslint/no-misused-promises
JWT.verify(token, process.env.secret ?? '', async (error, decoded) => {
if (error) return res.status(401).json({ message: 'Invalid token' });
if (error) return res.status(401).send({ message: 'Invalid token' });
const id = (decoded as Decoded | undefined)?.sub ?? null;
if (!id) return res.status(401).json({ message: 'Invalid authorization' });
if (!id) return res.status(401).send({ message: 'Invalid authorization' });
const user = await prisma.users.findFirst({
where: {
@ -30,11 +31,12 @@ export default (req: RequestWithUser, res: Response, next: NextFunction) => {
},
select: {
id: true,
username: true
username: true,
isAdmin: true
}
});
if (!user) return res.status(401).json({ message: 'Invalid authorization' });
if (!user) return res.status(401).send({ message: 'User doesn\'t exist' });
req.user = user;
next();
});

View File

@ -0,0 +1,15 @@
import type { FastifyRequest, FastifyReply, HookHandlerDoneFunction } from 'fastify';
import prisma from '../structures/database';
export default async (req: FastifyRequest, res: FastifyReply, next: HookHandlerDoneFunction) => {
const banned = await prisma.bans.findFirst({
where: {
ip: req.ip
}
});
if (banned) {
return res.status(401).send({ message: 'This IP has been banned' });
}
next();
};

View File

@ -4,8 +4,8 @@ generator client {
datasource db {
provider = "sqlite"
url = "file:../../../../database/database.sqlite"
shadowDatabaseUrl = "file:../../../../database/shadow.sqlite"
url = "file:../../../database/database.sqlite"
shadowDatabaseUrl = "file:../../../database/shadow.sqlite"
}
model albums {

View File

@ -1,37 +0,0 @@
import { Response } from 'express';
import prisma from '../../../../structures/database';
import type { RequestWithUser } from '../../../../middlewares/auth';
export const middlewares = ['auth'];
export const run = async (req: RequestWithUser, res: Response) => {
if (!req.body) return res.status(400).json({ message: 'No body provided' });
const { coinId, amount, fiatId, noCostTransaction, purchasePrice, label, feePrice, purchaseDate }: {
coinId: number;
amount: number;
fiatId: number;
noCostTransaction: boolean;
purchasePrice: number;
label: string;
feePrice: number;
purchaseDate: string;
} = req.body;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!coinId || !amount || noCostTransaction === null || noCostTransaction === undefined) return res.sendStatus(400);
await prisma.wallet.create({
data: {
userId: req.user.id,
coinId,
amount,
fiatId,
purchasePrice,
label,
feePrice,
paidPrice: amount * purchasePrice,
purchaseDate
}
});
return res.sendStatus(200);
};

View File

@ -0,0 +1,44 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import prisma from '../../../structures/database';
import bcrypt from 'bcryptjs';
import JWT from 'jsonwebtoken';
interface body {
username: string;
password: string;
}
export const run = async (req: FastifyRequest, res: FastifyReply) => {
if (!req.body) return res.status(400).send({ message: 'No body provided' });
// const { username, password }: { username: string; password: string } = req.body;
const { username, password } = req.body as body;
if (!username || !password) return res.status(400).send();
const user = await prisma.users.findFirst({
where: {
username
}
});
if (!user) return res.status(401).send({ message: 'User doesn\'t exist' });
const comparePassword = await bcrypt.compare(password, user.password ?? '');
if (!comparePassword) return res.status(401).send({ message: 'Invalid authorization.' });
const jwt = JWT.sign({
iss: 'chibisafe',
sub: user.id,
iat: new Date().getTime()
}, process.env.secret ?? '', { expiresIn: '30d' });
return res.send({
message: 'Successfully logged in.',
user: {
id: user.id,
username: user.username,
apiKey: user.apiKey,
isAdmin: user.isAdmin
},
token: jwt
});
};

View File

@ -0,0 +1,45 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import prisma from '../../../structures/database';
import bcrypt from 'bcryptjs';
interface body {
username: string;
password: string;
}
export const run = async (req: FastifyRequest, res: FastifyReply) => {
if (!req.body) return res.status(400).send({ message: 'No body provided' });
const { username, password } = req.body as body;
if (!username || !password) return res.status(400).send();
if (username.length < 4 || username.length > 32) {
return res.status(400).send({ message: 'Username must have 4-32 characters' });
}
if (password.length < 6 || password.length > 64) {
return res.status(400).send({ message: 'Password must have 6-64 characters' });
}
const exists = await prisma.users.findFirst({
where: {
username
}
});
if (exists) return res.status(401).send({ message: 'Username already exists' });
let hash;
try {
hash = await bcrypt.hash(password, 10);
} catch (err) {
req.log.error(err);
return res.status(401).send({ message: 'There was a problem processing your account' });
}
await prisma.users.create({
data: {
username,
password: hash
}
});
return res.send({ message: 'The account was created successfully' });
};

View File

@ -0,0 +1,6 @@
import type { FastifyReply } from 'fastify';
import { RequestWithUser } from '../../middlewares/auth';
export const middlewares = ['auth'];
export const run = (req: RequestWithUser, res: FastifyReply) => res.status(200).send(req.user);

View File

@ -1,54 +0,0 @@
const nodePath = require('path');
const Knex = require('knex');
// eslint-disable-next-line func-names
Knex.QueryBuilder.extend('wasMutated', function() {
this.client.config.userParams.lastMutationTime = Date.now();
return this;
});
const db = Knex({
client: process.env.DB_CLIENT,
connection: {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
filename: nodePath.join(__dirname, '../../../database/database.sqlite')
},
postProcessResponse: result => {
/*
Fun fact: Depending on the database used by the user and given that I don't want
to force a specific database for everyone because of the nature of this project,
some things like different data types for booleans need to be considered like in
the implementation below where sqlite returns 1 and 0 instead of true and false.
*/
const booleanFields = ['enabled', 'enableDownload', 'isAdmin', 'nsfw', 'generateZips', 'publicMode', 'userAccounts'];
const processResponse = row => {
Object.keys(row).forEach(key => {
if (booleanFields.includes(key)) {
if (row[key] === 0) row[key] = false;
else if (row[key] === 1) row[key] = true;
}
});
return row;
};
if (Array.isArray(result)) return result.map(row => processResponse(row));
if (typeof result === 'object') return processResponse(result);
return result;
},
useNullAsDefault: process.env.DB_CLIENT === 'sqlite3',
log: {
warn: msg => {
if (typeof msg === 'string' && msg.startsWith('.returning()')) return;
console.warn(msg);
}
},
userParams: {
lastMutationTime: null
}
});
module.exports = db;

View File

@ -5,33 +5,3 @@ export interface User {
password: string;
stravaLink: string;
}
export interface Segment {
id: number;
name: string;
description: string;
route: string;
stravaLink: string;
flagCount: number;
removed: boolean;
chunkyness: number;
wayType: string;
userId: number;
}
export interface Rating {
id: number;
rating: number;
comment: string;
segmentId: number;
}
export interface Picture {
id: number;
name: string;
description?: string;
path?: string;
lat?: string;
lng?: string;
segmentId: number;
}

View File

@ -1,14 +1,20 @@
import { Application, Request, Response } from 'express';
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import jetpack from 'fs-jetpack';
import path from 'path';
export default {
load: async (server: Application) => {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
for (const routeFile of await jetpack.findAsync(path.join(__dirname, '..', 'routes'), { matching: '*.{ts,js}' })) {
load: async (server: FastifyInstance) => {
/*
While in development we only want to match routes written in TypeScript but for production
we need to change it to javascript files since they will be compiled.
TODO: Would running the TypeScript files directly with something like ts-node be a good move?
*/
const matching = `*.${process.env.NODE_ENV === 'production' ? 'j' : 't'}s`;
for (const routeFile of await jetpack.findAsync(path.join(__dirname, '..', 'routes'), { matching })) {
try {
const slash = process.platform === 'win32' ? '\\' : '/';
const replace = process.env.NODE_ENV === 'production' ? `dist${slash}` : `src${slash}`;
const replace = process.env.NODE_ENV === 'production' ? `dist${slash}` : `src${slash}api${slash}`;
const route = await import(routeFile.replace(replace, `..${slash}`));
const paths: Array<string> = routeFile.split(slash);
const method = paths[paths.length - 1].split('.')[0];
@ -16,40 +22,45 @@ export default {
// Get rid of the filename
paths.pop();
// Get rid of the src/routes part
paths.splice(0, 2);
// Get rid of the src/api/routes part
paths.splice(0, 3);
let routePath: string = paths.join(slash);
let url: string = paths.join(slash);
// Transform path variables to express variables
routePath = routePath.replace('_', ':');
url = url.replace('_', ':');
// Append the missing /
routePath = `/${routePath}`;
url = `/${url}`;
// Build final route
const prefix = route.options?.ignoreRoutePrefix ? '' : process.env.routePrefix ?? '';
routePath = `${prefix}${routePath}`;
url = `${route.options?.ignoreRoutePrefix ? '' : '/api'}${url}`;
// Run middlewares if any
// TODO: This is loading all middlewares if any, wrong.
// TODO: Also the middlewares need to be run in the correct order, auth being the first one.
// Run middlewares if any, and in order of execution
const middlewares: any[] = [];
if (route.middlewares?.length) {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
for (const middlewareFile of await jetpack.findAsync(path.join(__dirname, '..', 'middlewares'), { matching: '*.{ts,js}' })) {
const middleware = await import(middlewareFile.replace(replace, `..${slash}`));
middlewares.push(middleware.default);
// Set default middlewares that need to be included
route.middlewares.unshift('ban');
// Load the middlewares defined in the route file
for (const middleware of route.middlewares) {
const importedMiddleware = await import(path.join(__dirname, '..', 'middlewares', middleware));
middlewares.push(importedMiddleware.default);
}
}
// Register the route in Express
(server as any)[method](routePath, ...middlewares, (req: Request, res: Response) => route.run(req, res));
console.log(`Found route ${method.toUpperCase()} ${routePath}`);
// Register the route in Fastify
server.route({
method: method.toUpperCase() as any,
url,
preHandler: middlewares,
handler: (req: FastifyRequest, res: FastifyReply) => route.run(req, res)
});
server.log.info(`Found route ${method.toUpperCase()} ${url}`);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
console.log(`${routeFile} :: ERROR`);
console.log(error);
server.log.error(`${routeFile} :: ERROR`);
server.log.error(error);
}
}
}

View File

@ -1,7 +1,4 @@
require('dotenv').config();
const Joi = require('joi');
const { env } = process;
import Joi from 'joi';
const StatsGenerator = require('../utils/StatsGenerator');

View File

@ -1,20 +0,0 @@
{
"compilerOptions": {
"moduleResolution": "Node",
"esModuleInterop": true,
"sourceMap": true,
"strict": true,
"target": "esnext",
"module": "commonjs",
"jsx": "preserve",
"lib": ["esnext", "dom"],
"rootDir": "./",
"outDir": "../../dist",
"sourceRoot": "./",
"types": [
"@types/node",
"@nuxt/types"
]
},
"include": ["**/*.ts", "**/*.d.ts", "../main.ts"]
}

View File

@ -4,31 +4,17 @@
"esModuleInterop": true,
"sourceMap": true,
"strict": true,
"target": "ES2018",
"module": "ESNext",
"lib": [
"ESNext",
"ESNext.AsyncIterable",
"DOM"
],
"allowJs": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"~/*": [
"./*"
],
"@/*": [
"./*"
]
},
"target": "esnext",
"module": "commonjs",
"jsx": "preserve",
"lib": ["esnext", "dom"],
"rootDir": "./src/api",
"outDir": "./dist",
"sourceRoot": "./",
"types": [
"@nuxt/types",
"@nuxtjs/axios",
"@nuxtjs/color-mode"
]
"@types/node",
"@nuxt/types"
]
},
"exclude": [
"node_modules"
]
"include": ["**/*.ts", "**/*.d.ts"]
}