diff --git a/src/backend/src/api/eggspress.js b/src/backend/src/api/eggspress.js index fca0bbf98..5840ef9fd 100644 --- a/src/backend/src/api/eggspress.js +++ b/src/backend/src/api/eggspress.js @@ -1,198 +1,2 @@ -/* - * Copyright (C) 2024 Puter Technologies Inc. - * - * This file is part of Puter. - * - * Puter is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -const express = require('express'); -const multer = require('multer'); -const multest = require('@heyputer/multest'); -const api_error_handler = require('../api/api_error_handler.js'); - -const fsBeforeMW = require('../middleware/fs'); -const APIError = require('./APIError.js'); -const { Context } = require('../util/context.js'); - -/** - * eggspress() is a factory function for creating express routers. - * - * @param {*} route the route to the router - * @param {*} settings the settings for the router. The following - * properties are supported: - * - auth: whether or not to use the auth middleware - * - fs: whether or not to use the fs middleware - * - json: whether or not to use the json middleware - * - customArgs: custom arguments to pass to the router - * - allowedMethods: the allowed HTTP methods - * @param {*} handler the handler for the router - * @returns {express.Router} the router - */ -module.exports = function eggspress (route, settings, handler) { - const router = express.Router(); - const mw = []; - const afterMW = []; - - // These flags enable specific middleware. - if ( settings.abuse ) mw.push(require('../middleware/abuse')(settings.abuse)); - if ( settings.auth ) mw.push(require('../middleware/auth')); - if ( settings.auth2 ) mw.push(require('../middleware/auth2')); - if ( settings.fs ) { - mw.push(fsBeforeMW); - } - if ( settings.verified ) mw.push(require('../middleware/verified')); - if ( settings.json ) mw.push(express.json()); - - // The `files` setting is an array of strings. Each string is the name - // of a multipart field that contains files. `multer` is used to parse - // the multipart request and store the files in `req.files`. - if ( settings.files ) { - for ( const key of settings.files ) { - mw.push(multer().array(key)); - } - } - - if ( settings.multest ) { - mw.push(multest()); - } - - // The `multipart_jsons` setting is an array of strings. Each string - // is the name of a multipart field that contains JSON. This middleware - // parses the JSON in each field and stores the result in `req.body`. - if ( settings.multipart_jsons ) { - for ( const key of settings.multipart_jsons ) { - mw.push((req, res, next) => { - try { - if ( ! Array.isArray(req.body[key]) ) { - req.body[key] = [JSON.parse(req.body[key])]; - } else { - req.body[key] = req.body[key].map(JSON.parse); - } - } catch (e) { - return res.status(400).send({ - error: { - message: `Invalid JSON in multipart field ${key}` - } - }); - } - next(); - }); - } - } - - // The `alias` setting is an object. Each key is the name of a - // parameter. Each value is the name of a parameter that should - // be aliased to the key. - if ( settings.alias ) { - for ( const alias in settings.alias ) { - const target = settings.alias[alias]; - mw.push((req, res, next) => { - const values = req.method === 'GET' ? req.query : req.body; - if ( values[alias] ) { - values[target] = values[alias]; - } - next(); - }); - } - } - - // The `parameters` setting is an object. Each key is the name of a - // parameter. Each value is a `Param` object. The `Param` object - // specifies how to validate the parameter. - if ( settings.parameters ) { - for ( const key in settings.parameters ) { - const param = settings.parameters[key]; - mw.push(async (req, res, next) => { - if ( ! req.values ) req.values = {}; - - const values = req.method === 'GET' ? req.query : req.body; - const getParam = (key) => values[key]; - try { - const result = await param.consolidate({ req, getParam }); - req.values[key] = result; - } catch (e) { - api_error_handler(e, req, res, next); - return; - } - next(); - }); - } - } - - // what if I wanted to pass arguments to, for example, `json`? - if ( settings.customArgs ) mw.push(settings.customArgs); - - if ( settings.alarm_timeout ) { - mw.push((req, res, next) => { - setTimeout(() => { - if ( ! res.headersSent ) { - const log = req.services.get('log-service').create('eggspress:timeout'); - const errors = req.services.get('error-service').create(log); - let id = Array.isArray(route) ? route[0] : route; - id = id.replace(/\//g, '_'); - errors.report(id, { - source: new Error('Response timed out.'), - message: 'Response timed out.', - trace: true, - alarm: true, - }); - } - }, settings.alarm_timeout); - next(); - }); - } - - if ( settings.response_timeout ) { - mw.push((req, res, next) => { - setTimeout(() => { - if ( ! res.headersSent ) { - api_error_handler(APIError.create('response_timeout'), req, res, next); - } - }, settings.response_timeout); - next(); - }); - } - - if ( settings.mw ) mw.push(...settings.mw); - - const errorHandledHandler = async function (req, res, next) { - if ( settings.subdomain ) { - if ( require('../helpers').subdomain(req) !== settings.subdomain ) { - return next(); - } - } - try { - const expected_ctx = res.locals.ctx; - const received_ctx = Context.get(undefined, { allow_fallback: true }); - - if ( expected_ctx != received_ctx ) { - await expected_ctx.arun(async () => { - await handler(req, res, next); - }); - } else await handler(req, res, next); - } catch (e) { - api_error_handler(e, req, res, next); - } - }; - - if ( settings.allowedMethods.includes('GET') ) { - router.get(route, ...mw, errorHandledHandler, ...afterMW); - } - - if ( settings.allowedMethods.includes('POST') ) { - router.post(route, ...mw, errorHandledHandler, ...afterMW); - } - - return router; -} \ No newline at end of file +// This file is a legacy alias +module.exports = require('../modules/web/lib/eggspress.js'); diff --git a/src/backend/src/modules/web/WebServerService.js b/src/backend/src/modules/web/WebServerService.js index a9fd059a4..de1daaf89 100644 --- a/src/backend/src/modules/web/WebServerService.js +++ b/src/backend/src/modules/web/WebServerService.js @@ -104,7 +104,7 @@ class WebServerService extends BaseService { // error handling middleware goes last, as per the // expressjs documentation: // https://expressjs.com/en/guide/error-handling.html - this.app.use(require('../../api/api_error_handler.js')); + this.app.use(require('./lib/api_error_handler.js')); const { jwt_auth } = require('../../helpers.js'); diff --git a/src/backend/src/modules/web/lib/__lib__.js b/src/backend/src/modules/web/lib/__lib__.js new file mode 100644 index 000000000..2dc2a60ec --- /dev/null +++ b/src/backend/src/modules/web/lib/__lib__.js @@ -0,0 +1,4 @@ +module.exports = { + eggspress: require("./eggspress"), + api_error_handler: require("./api_error_handler"), +}; diff --git a/src/backend/src/modules/web/lib/api_error_handler.js b/src/backend/src/modules/web/lib/api_error_handler.js new file mode 100644 index 000000000..2e8f16ce9 --- /dev/null +++ b/src/backend/src/modules/web/lib/api_error_handler.js @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2024 Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const APIError = require('../../../api/APIError.js'); + +/** + * api_error_handler() is an express error handler for API errors. + * It adheres to the express error handler signature and should be + * used as the last middleware in an express app. + * + * Since Express 5 is not yet released, this function is used by + * eggspress() to handle errors instead of as a middleware. + * + * @todo remove this function and use express error handling + * when Express 5 is released + * + * @param {*} err + * @param {*} req + * @param {*} res + * @param {*} next + * @returns + */ +module.exports = function api_error_handler (err, req, res, next) { + if (res.headersSent) { + console.error('error after headers were sent:', err); + return next(err) + } + + // API errors might have a response to help the + // developer resolve the issue. + if ( err instanceof APIError ) { + return err.write(res); + } + + if ( + typeof err === 'object' && + ! (err instanceof Error) && + err.hasOwnProperty('message') + ) { + const apiError = APIError.create(400, err); + return apiError.write(res); + } + + console.error('internal server error:', err); + + const services = globalThis.services; + if ( services && services.has('alarm') ) { + const alarm = services.get('alarm'); + alarm.create('api_error_handler', err.message, { + error: err, + url: req.url, + method: req.method, + body: req.body, + headers: req.headers, + }); + } + + req.__error_handled = true; + + // Other errors should provide as little information + // to the client as possible for security reasons. + return res.send(500, 'Internal Server Error'); +}; diff --git a/src/backend/src/modules/web/lib/eggspress.js b/src/backend/src/modules/web/lib/eggspress.js new file mode 100644 index 000000000..5cfb220fb --- /dev/null +++ b/src/backend/src/modules/web/lib/eggspress.js @@ -0,0 +1,199 @@ +/* + * Copyright (C) 2024 Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const express = require('express'); +const multer = require('multer'); +const multest = require('@heyputer/multest'); +const api_error_handler = require('./api_error_handler.js'); + +const fsBeforeMW = require('../../../middleware/fs.js'); +const APIError = require('../../../api/APIError.js'); +const { Context } = require('../../../util/context.js'); +const { subdomain } = require('../../../helpers.js'); + +/** + * eggspress() is a factory function for creating express routers. + * + * @param {*} route the route to the router + * @param {*} settings the settings for the router. The following + * properties are supported: + * - auth: whether or not to use the auth middleware + * - fs: whether or not to use the fs middleware + * - json: whether or not to use the json middleware + * - customArgs: custom arguments to pass to the router + * - allowedMethods: the allowed HTTP methods + * @param {*} handler the handler for the router + * @returns {express.Router} the router + */ +module.exports = function eggspress (route, settings, handler) { + const router = express.Router(); + const mw = []; + const afterMW = []; + + // These flags enable specific middleware. + if ( settings.abuse ) mw.push(require('../../../middleware/abuse')(settings.abuse)); + if ( settings.auth ) mw.push(require('../../../middleware/auth')); + if ( settings.auth2 ) mw.push(require('../../../middleware/auth2')); + if ( settings.fs ) { + mw.push(fsBeforeMW); + } + if ( settings.verified ) mw.push(require('../../../middleware/verified')); + if ( settings.json ) mw.push(express.json()); + + // The `files` setting is an array of strings. Each string is the name + // of a multipart field that contains files. `multer` is used to parse + // the multipart request and store the files in `req.files`. + if ( settings.files ) { + for ( const key of settings.files ) { + mw.push(multer().array(key)); + } + } + + if ( settings.multest ) { + mw.push(multest()); + } + + // The `multipart_jsons` setting is an array of strings. Each string + // is the name of a multipart field that contains JSON. This middleware + // parses the JSON in each field and stores the result in `req.body`. + if ( settings.multipart_jsons ) { + for ( const key of settings.multipart_jsons ) { + mw.push((req, res, next) => { + try { + if ( ! Array.isArray(req.body[key]) ) { + req.body[key] = [JSON.parse(req.body[key])]; + } else { + req.body[key] = req.body[key].map(JSON.parse); + } + } catch (e) { + return res.status(400).send({ + error: { + message: `Invalid JSON in multipart field ${key}` + } + }); + } + next(); + }); + } + } + + // The `alias` setting is an object. Each key is the name of a + // parameter. Each value is the name of a parameter that should + // be aliased to the key. + if ( settings.alias ) { + for ( const alias in settings.alias ) { + const target = settings.alias[alias]; + mw.push((req, res, next) => { + const values = req.method === 'GET' ? req.query : req.body; + if ( values[alias] ) { + values[target] = values[alias]; + } + next(); + }); + } + } + + // The `parameters` setting is an object. Each key is the name of a + // parameter. Each value is a `Param` object. The `Param` object + // specifies how to validate the parameter. + if ( settings.parameters ) { + for ( const key in settings.parameters ) { + const param = settings.parameters[key]; + mw.push(async (req, res, next) => { + if ( ! req.values ) req.values = {}; + + const values = req.method === 'GET' ? req.query : req.body; + const getParam = (key) => values[key]; + try { + const result = await param.consolidate({ req, getParam }); + req.values[key] = result; + } catch (e) { + api_error_handler(e, req, res, next); + return; + } + next(); + }); + } + } + + // what if I wanted to pass arguments to, for example, `json`? + if ( settings.customArgs ) mw.push(settings.customArgs); + + if ( settings.alarm_timeout ) { + mw.push((req, res, next) => { + setTimeout(() => { + if ( ! res.headersSent ) { + const log = req.services.get('log-service').create('eggspress:timeout'); + const errors = req.services.get('error-service').create(log); + let id = Array.isArray(route) ? route[0] : route; + id = id.replace(/\//g, '_'); + errors.report(id, { + source: new Error('Response timed out.'), + message: 'Response timed out.', + trace: true, + alarm: true, + }); + } + }, settings.alarm_timeout); + next(); + }); + } + + if ( settings.response_timeout ) { + mw.push((req, res, next) => { + setTimeout(() => { + if ( ! res.headersSent ) { + api_error_handler(APIError.create('response_timeout'), req, res, next); + } + }, settings.response_timeout); + next(); + }); + } + + if ( settings.mw ) mw.push(...settings.mw); + + const errorHandledHandler = async function (req, res, next) { + if ( settings.subdomain ) { + if ( subdomain(req) !== settings.subdomain ) { + return next(); + } + } + try { + const expected_ctx = res.locals.ctx; + const received_ctx = Context.get(undefined, { allow_fallback: true }); + + if ( expected_ctx != received_ctx ) { + await expected_ctx.arun(async () => { + await handler(req, res, next); + }); + } else await handler(req, res, next); + } catch (e) { + api_error_handler(e, req, res, next); + } + }; + + if ( settings.allowedMethods.includes('GET') ) { + router.get(route, ...mw, errorHandledHandler, ...afterMW); + } + + if ( settings.allowedMethods.includes('POST') ) { + router.post(route, ...mw, errorHandledHandler, ...afterMW); + } + + return router; +} \ No newline at end of file diff --git a/src/backend/src/routers/filesystem_api/write.js b/src/backend/src/routers/filesystem_api/write.js index 2bb289b7f..de0f4f65b 100644 --- a/src/backend/src/routers/filesystem_api/write.js +++ b/src/backend/src/routers/filesystem_api/write.js @@ -25,7 +25,6 @@ const { Context } = require('../../util/context.js'); const Busboy = require('busboy'); const { TeePromise } = require('../../util/promise.js'); const APIError = require('../../api/APIError.js'); -const api_error_handler = require('../../api/api_error_handler.js'); const { valid_file_size } = require('../../util/validutil.js'); // -----------------------------------------------------------------------// @@ -172,13 +171,8 @@ module.exports = eggspress(['/up', '/write'], { const values = req.method === 'GET' ? req.query : req.body; const getParam = (key) => values[key]; - try { - const result = await param.consolidate({ req, getParam }); - req.values[key] = result; - } catch (e) { - api_error_handler(e, req, res, next); - return; - } + const result = await param.consolidate({ req, getParam }); + req.values[key] = result; } if ( req.body.size === undefined ) { diff --git a/src/backend/src/routers/hosting/puter-site.js b/src/backend/src/routers/hosting/puter-site.js index a2680a6be..d9d81bf48 100644 --- a/src/backend/src/routers/hosting/puter-site.js +++ b/src/backend/src/routers/hosting/puter-site.js @@ -17,7 +17,7 @@ * along with this program. If not, see . */ const { AdvancedBase } = require("@heyputer/putility"); -const api_error_handler = require("../../api/api_error_handler"); +const api_error_handler = require("../../modules/web/lib/api_error_handler"); const config = require("../../config"); const { get_user, get_app, id2path } = require("../../helpers"); const { Context } = require("../../util/context");