aboutsummaryrefslogtreecommitdiff
path: root/src/api/structures
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/structures')
-rw-r--r--src/api/structures/Route.js110
-rw-r--r--src/api/structures/Server.js111
2 files changed, 221 insertions, 0 deletions
diff --git a/src/api/structures/Route.js b/src/api/structures/Route.js
new file mode 100644
index 0000000..bb7ba87
--- /dev/null
+++ b/src/api/structures/Route.js
@@ -0,0 +1,110 @@
+const nodePath = require('path');
+const JWT = require('jsonwebtoken');
+const db = require('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'];
+
+ 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'
+});
+const moment = require('moment');
+const log = require('../utils/Log');
+
+class Route {
+ constructor(path, method, options) {
+ if (!path) throw new Error('Every route needs a URL associated with it.');
+ if (!method) throw new Error('Every route needs its method specified.');
+
+ this.path = path;
+ this.method = method;
+ this.options = options || {};
+ }
+
+ async authorize(req, res) {
+ const banned = await db
+ .table('bans')
+ .where({ ip: req.ip })
+ .first();
+ if (banned) return res.status(401).json({ message: 'This IP has been banned from using the service.' });
+
+ if (this.options.bypassAuth) return this.run(req, res, db);
+ // The only reason I call it token here and not Api Key is to be backwards compatible
+ // with the uploader and sharex
+ // Small price to pay.
+ if (req.headers.token) return this.authorizeApiKey(req, res, req.headers.token);
+ 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' });
+
+ return JWT.verify(token, process.env.SECRET, async (error, decoded) => {
+ if (error) {
+ log.error(error);
+ return res.status(401).json({ message: 'Invalid token' });
+ }
+ const id = decoded ? decoded.sub : '';
+ const iat = decoded ? decoded.iat : '';
+
+ const user = await db
+ .table('users')
+ .where({ id })
+ .first();
+ if (!user) return res.status(401).json({ message: 'Invalid authorization' });
+ if (iat && iat < moment(user.passwordEditedAt).format('x')) {
+ return res.status(401).json({ message: 'Token expired' });
+ }
+ if (!user.enabled) return res.status(401).json({ message: 'This account has been disabled' });
+ if (this.options.adminOnly && !user.isAdmin) { return res.status(401).json({ message: 'Invalid authorization' }); }
+
+ return this.run(req, res, db, user);
+ });
+ }
+
+ async authorizeApiKey(req, res, apiKey) {
+ if (!this.options.canApiKey) return res.status(401).json({ message: 'Api Key not allowed for this resource' });
+ const user = await db
+ .table('users')
+ .where({ apiKey })
+ .first();
+ if (!user) return res.status(401).json({ message: 'Invalid authorization' });
+ if (!user.enabled) return res.status(401).json({ message: 'This account has been disabled' });
+
+ return this.run(req, res, db, user);
+ }
+
+ run() {}
+
+ error(res, error) {
+ log.error(error);
+ return res.status(500).json({ message: 'There was a problem parsing the request' });
+ }
+}
+
+module.exports = Route;
diff --git a/src/api/structures/Server.js b/src/api/structures/Server.js
new file mode 100644
index 0000000..b8952a9
--- /dev/null
+++ b/src/api/structures/Server.js
@@ -0,0 +1,111 @@
+require('dotenv').config();
+
+if (!process.env.SERVER_PORT) {
+ console.log('Run the setup script first or fill the .env file manually before starting');
+ process.exit(0);
+}
+
+const express = require('express');
+const helmet = require('helmet');
+const cors = require('cors');
+const RateLimit = require('express-rate-limit');
+const bodyParser = require('body-parser');
+const jetpack = require('fs-jetpack');
+const path = require('path');
+const morgan = require('morgan');
+const rfs = require('rotating-file-stream');
+const log = require('../utils/Log');
+
+// eslint-disable-next-line no-unused-vars
+const rateLimiter = new RateLimit({
+ windowMs: parseInt(process.env.RATE_LIMIT_WINDOW, 10),
+ max: parseInt(process.env.RATE_LIMIT_MAX, 10),
+ delayMs: 0
+});
+
+class Server {
+ constructor() {
+ this.port = parseInt(process.env.SERVER_PORT, 10);
+ this.server = express();
+ this.server.set('trust proxy', 1);
+ this.server.use(helmet());
+ this.server.use(cors({ allowedHeaders: ['Accept', 'Authorization', 'Cache-Control', 'X-Requested-With', 'Content-Type', 'albumId'] }));
+ this.server.use((req, res, 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 && req.headers.accept.includes('application/vnd.chibisafe.json')) return next();
+ return res.status(405).json({ message: 'Incorrect `Accept` header provided' });
+ });
+ this.server.use(bodyParser.urlencoded({ extended: true }));
+ this.server.use(bodyParser.json());
+
+ if (process.env.NODE_ENV === 'production') {
+ const accessLogStream = rfs.createStream('access.log', {
+ interval: '1d', // rotate daily
+ path: path.join(__dirname, '../../../logs', 'log')
+ });
+ this.server.use(morgan('combined', { stream: accessLogStream }));
+ }
+
+ // Apply rate limiting to the api only
+ this.server.use('/api/', rateLimiter);
+
+ // Serve the uploads
+ this.server.use(express.static(path.join(__dirname, '../../../uploads')));
+ this.routesFolder = path.join(__dirname, '../routes');
+ }
+
+ registerAllTheRoutes() {
+ jetpack.find(this.routesFolder, { matching: '*.js' }).forEach(routeFile => {
+ const RouteClass = require(path.join('../../../', routeFile));
+ let routes = [RouteClass];
+ if (Array.isArray(RouteClass)) routes = RouteClass;
+ for (const File of routes) {
+ try {
+ const route = new File();
+ this.server[route.method](process.env.ROUTE_PREFIX + route.path, route.authorize.bind(route));
+ log.info(`Found route ${route.method.toUpperCase()} ${process.env.ROUTE_PREFIX}${route.path}`);
+ } catch (e) {
+ log.error(`Failed loading route from file ${routeFile} with error: ${e.message}`);
+ }
+ }
+ });
+ }
+
+ serveNuxt() {
+ // Serve the frontend if we are in production mode
+ if (process.env.NODE_ENV === 'production') {
+ this.server.use(express.static(path.join(__dirname, '../../../dist')));
+ }
+
+ /*
+ For vue router to work with express we need this fallback.
+ After all the routes are loaded and the static files handled and if the
+ user is trying to access a non-mapped route we serve the website instead
+ since it has routes of it's own that don't work if accessed directly
+ */
+ this.server.all('*', (_req, res) => {
+ try {
+ res.sendFile(path.join(__dirname, '../../../dist/index.html'));
+ } catch (error) {
+ res.json({ success: false, message: 'Something went wrong' });
+ }
+ });
+ }
+
+ start() {
+ jetpack.dir('uploads/chunks');
+ jetpack.dir('uploads/thumbs/square');
+ jetpack.dir('uploads/thumbs/preview');
+ this.registerAllTheRoutes();
+ this.serveNuxt();
+ const server = this.server.listen(this.port, () => {
+ log.success(`Backend ready and listening on port ${this.port}`);
+ });
+ server.setTimeout(600000);
+ }
+}
+
+new Server().start();