diff options
Diffstat (limited to 'src/api/structures')
| -rw-r--r-- | src/api/structures/Route.js | 110 | ||||
| -rw-r--r-- | src/api/structures/Server.js | 111 |
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(); |