Files
kami-parse-server/src/Routers/PagesRouter.js
Colin Ulin e0480dfa8d feat: Upgrade to express 5.0.1 (#9530)
BREAKING CHANGE: This upgrades the internally used Express framework from version 4 to 5, which may be a breaking change. If Parse Server is set up to be mounted on an Express application, we recommend to also use version 5 of the Express framework to avoid any compatibility issues. Note that even if there are no issues after upgrading, future releases of Parse Server may introduce issues if Parse Server internally relies on Express 5-specific features which are unsupported by the Express version on which it is mounted. See the Express [migration guide](https://expressjs.com/en/guide/migrating-5.html) and [release announcement](https://expressjs.com/2024/10/15/v5-release.html#breaking-changes) for more info.
2025-03-03 22:11:42 +01:00

743 lines
22 KiB
JavaScript

import PromiseRouter from '../PromiseRouter';
import Config from '../Config';
import express from 'express';
import path from 'path';
import { promises as fs } from 'fs';
import { Parse } from 'parse/node';
import Utils from '../Utils';
import mustache from 'mustache';
import Page from '../Page';
// All pages with custom page key for reference and file name
const pages = Object.freeze({
passwordReset: new Page({ id: 'passwordReset', defaultFile: 'password_reset.html' }),
passwordResetSuccess: new Page({
id: 'passwordResetSuccess',
defaultFile: 'password_reset_success.html',
}),
passwordResetLinkInvalid: new Page({
id: 'passwordResetLinkInvalid',
defaultFile: 'password_reset_link_invalid.html',
}),
emailVerificationSuccess: new Page({
id: 'emailVerificationSuccess',
defaultFile: 'email_verification_success.html',
}),
emailVerificationSendFail: new Page({
id: 'emailVerificationSendFail',
defaultFile: 'email_verification_send_fail.html',
}),
emailVerificationSendSuccess: new Page({
id: 'emailVerificationSendSuccess',
defaultFile: 'email_verification_send_success.html',
}),
emailVerificationLinkInvalid: new Page({
id: 'emailVerificationLinkInvalid',
defaultFile: 'email_verification_link_invalid.html',
}),
emailVerificationLinkExpired: new Page({
id: 'emailVerificationLinkExpired',
defaultFile: 'email_verification_link_expired.html',
}),
});
// All page parameters for reference to be used as template placeholders or query params
const pageParams = Object.freeze({
appName: 'appName',
appId: 'appId',
token: 'token',
username: 'username',
error: 'error',
locale: 'locale',
publicServerUrl: 'publicServerUrl',
});
// The header prefix to add page params as response headers
const pageParamHeaderPrefix = 'x-parse-page-param-';
// The errors being thrown
const errors = Object.freeze({
jsonFailedFileLoading: 'failed to load JSON file',
fileOutsideAllowedScope: 'not allowed to read file outside of pages directory',
});
export class PagesRouter extends PromiseRouter {
/**
* Constructs a PagesRouter.
* @param {Object} pages The pages options from the Parse Server configuration.
*/
constructor(pages = {}) {
super();
// Set instance properties
this.pagesConfig = pages;
this.pagesEndpoint = pages.pagesEndpoint ? pages.pagesEndpoint : 'apps';
this.pagesPath = pages.pagesPath
? path.resolve('./', pages.pagesPath)
: path.resolve(__dirname, '../../public');
this.loadJsonResource();
this.mountPagesRoutes();
this.mountCustomRoutes();
this.mountStaticRoute();
}
verifyEmail(req) {
const config = req.config;
const { token: rawToken } = req.query;
const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;
if (!config) {
this.invalidRequest();
}
if (!token) {
return this.goToPage(req, pages.emailVerificationLinkInvalid);
}
const userController = config.userController;
return userController.verifyEmail(token).then(
() => {
return this.goToPage(req, pages.emailVerificationSuccess);
},
() => {
return this.goToPage(req, pages.emailVerificationLinkInvalid);
}
);
}
resendVerificationEmail(req) {
const config = req.config;
const username = req.body?.username;
const token = req.body?.token;
if (!config) {
this.invalidRequest();
}
if (!username && !token) {
return this.goToPage(req, pages.emailVerificationLinkInvalid);
}
const userController = config.userController;
return userController.resendVerificationEmail(username, req, token).then(
() => {
return this.goToPage(req, pages.emailVerificationSendSuccess);
},
() => {
return this.goToPage(req, pages.emailVerificationSendFail);
}
);
}
passwordReset(req) {
const config = req.config;
const params = {
[pageParams.appId]: req.params.appId,
[pageParams.appName]: config.appName,
[pageParams.token]: req.query.token,
[pageParams.username]: req.query.username,
[pageParams.publicServerUrl]: config.publicServerURL,
};
return this.goToPage(req, pages.passwordReset, params);
}
requestResetPassword(req) {
const config = req.config;
if (!config) {
this.invalidRequest();
}
const { token: rawToken } = req.query;
const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;
if (!token) {
return this.goToPage(req, pages.passwordResetLinkInvalid);
}
return config.userController.checkResetTokenValidity(token).then(
() => {
const params = {
[pageParams.token]: token,
[pageParams.appId]: config.applicationId,
[pageParams.appName]: config.appName,
};
return this.goToPage(req, pages.passwordReset, params);
},
() => {
return this.goToPage(req, pages.passwordResetLinkInvalid);
}
);
}
resetPassword(req) {
const config = req.config;
if (!config) {
this.invalidRequest();
}
const { new_password, token: rawToken } = req.body || {};
const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;
if ((!token || !new_password) && req.xhr === false) {
return this.goToPage(req, pages.passwordResetLinkInvalid);
}
if (!token) {
throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing token');
}
if (!new_password) {
throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'Missing password');
}
return config.userController
.updatePassword(token, new_password)
.then(
() => {
return Promise.resolve({
success: true,
});
},
err => {
return Promise.resolve({
success: false,
err,
});
}
)
.then(result => {
if (req.xhr) {
if (result.success) {
return Promise.resolve({
status: 200,
response: 'Password successfully reset',
});
}
if (result.err) {
throw new Parse.Error(Parse.Error.OTHER_CAUSE, `${result.err}`);
}
}
const query = result.success
? {}
: {
[pageParams.token]: token,
[pageParams.appId]: config.applicationId,
[pageParams.error]: result.err,
[pageParams.appName]: config.appName,
};
if (result?.err === 'The password reset link has expired') {
delete query[pageParams.token];
query[pageParams.token] = token;
}
const page = result.success ? pages.passwordResetSuccess : pages.passwordReset;
return this.goToPage(req, page, query, false);
});
}
/**
* Returns page content if the page is a local file or returns a
* redirect to a custom page.
* @param {Object} req The express request.
* @param {Page} page The page to go to.
* @param {Object} [params={}] The query parameters to attach to the URL in case of
* HTTP redirect responses for POST requests, or the placeholders to fill into
* the response content in case of HTTP content responses for GET requests.
* @param {Boolean} [responseType] Is true if a redirect response should be forced,
* false if a content response should be forced, undefined if the response type
* should depend on the request type by default:
* - GET request -> content response
* - POST request -> redirect response (PRG pattern)
* @returns {Promise<Object>} The PromiseRouter response.
*/
goToPage(req, page, params = {}, responseType) {
const config = req.config;
// Determine redirect either by force, response setting or request method
const redirect = config.pages.forceRedirect
? true
: responseType !== undefined
? responseType
: req.method == 'POST';
// Include default parameters
const defaultParams = this.getDefaultParams(config);
if (Object.values(defaultParams).includes(undefined)) {
return this.notFound();
}
params = Object.assign(params, defaultParams);
// Add locale to params to ensure it is passed on with every request;
// that means, once a locale is set, it is passed on to any follow-up page,
// e.g. request_password_reset -> password_reset -> password_reset_success
const locale = this.getLocale(req);
params[pageParams.locale] = locale;
// Compose paths and URLs
const defaultFile = page.defaultFile;
const defaultPath = this.defaultPagePath(defaultFile);
const defaultUrl = this.composePageUrl(defaultFile, config.publicServerURL);
// If custom URL is set redirect to it without localization
const customUrl = config.pages.customUrls[page.id];
if (customUrl && !Utils.isPath(customUrl)) {
return this.redirectResponse(customUrl, params);
}
// Get JSON placeholders
let placeholders = {};
if (config.pages.enableLocalization && config.pages.localizationJsonPath) {
placeholders = this.getJsonPlaceholders(locale, params);
}
// Send response
if (config.pages.enableLocalization && locale) {
return Utils.getLocalizedPath(defaultPath, locale).then(({ path, subdir }) =>
redirect
? this.redirectResponse(
this.composePageUrl(defaultFile, config.publicServerURL, subdir),
params
)
: this.pageResponse(path, params, placeholders)
);
} else {
return redirect
? this.redirectResponse(defaultUrl, params)
: this.pageResponse(defaultPath, params, placeholders);
}
}
/**
* Serves a request to a static resource and localizes the resource if it
* is a HTML file.
* @param {Object} req The request object.
* @returns {Promise<Object>} The response.
*/
staticRoute(req) {
// Get requested path
const relativePath = req.params['resource'][0];
// Resolve requested path to absolute path
const absolutePath = path.resolve(this.pagesPath, relativePath);
// If the requested file is not a HTML file send its raw content
if (!absolutePath || !absolutePath.endsWith('.html')) {
return this.fileResponse(absolutePath);
}
// Get parameters
const params = this.getDefaultParams(req.config);
const locale = this.getLocale(req);
if (locale) {
params.locale = locale;
}
// Get JSON placeholders
const placeholders = this.getJsonPlaceholders(locale, params);
return this.pageResponse(absolutePath, params, placeholders);
}
/**
* Returns a translation from the JSON resource for a given locale. The JSON
* resource is parsed according to i18next syntax.
*
* Example JSON content:
* ```js
* {
* "en": { // resource for language `en` (English)
* "translation": {
* "greeting": "Hello!"
* }
* },
* "de": { // resource for language `de` (German)
* "translation": {
* "greeting": "Hallo!"
* }
* }
* "de-CH": { // resource for locale `de-CH` (Swiss German)
* "translation": {
* "greeting": "Grüezi!"
* }
* }
* }
* ```
* @param {String} locale The locale to translate to.
* @returns {Object} The translation or an empty object if no matching
* translation was found.
*/
getJsonTranslation(locale) {
// If there is no JSON resource
if (this.jsonParameters === undefined) {
return {};
}
// If locale is not set use the fallback locale
locale = locale || this.pagesConfig.localizationFallbackLocale;
// Get matching translation by locale, language or fallback locale
const language = locale.split('-')[0];
const resource =
this.jsonParameters[locale] ||
this.jsonParameters[language] ||
this.jsonParameters[this.pagesConfig.localizationFallbackLocale] ||
{};
const translation = resource.translation || {};
return translation;
}
/**
* Returns a translation from the JSON resource for a given locale with
* placeholders filled in by given parameters.
* @param {String} locale The locale to translate to.
* @param {Object} params The parameters to fill into any placeholders
* within the translations.
* @returns {Object} The translation or an empty object if no matching
* translation was found.
*/
getJsonPlaceholders(locale, params = {}) {
// If localization is disabled or there is no JSON resource
if (!this.pagesConfig.enableLocalization || !this.pagesConfig.localizationJsonPath) {
return {};
}
// Get JSON placeholders
let placeholders = this.getJsonTranslation(locale);
// Fill in any placeholders in the translation; this allows a translation
// to contain default placeholders like {{appName}} which are filled here
placeholders = JSON.stringify(placeholders);
placeholders = mustache.render(placeholders, params);
placeholders = JSON.parse(placeholders);
return placeholders;
}
/**
* Creates a response with file content.
* @param {String} path The path of the file to return.
* @param {Object} [params={}] The parameters to be included in the response
* header. These will also be used to fill placeholders.
* @param {Object} [placeholders={}] The placeholders to fill in the content.
* These will not be included in the response header.
* @returns {Object} The Promise Router response.
*/
async pageResponse(path, params = {}, placeholders = {}) {
// Get file content
let data;
try {
data = await this.readFile(path);
} catch (e) {
return this.notFound();
}
// Get config placeholders; can be an object, a function or an async function
let configPlaceholders =
typeof this.pagesConfig.placeholders === 'function'
? this.pagesConfig.placeholders(params)
: Object.prototype.toString.call(this.pagesConfig.placeholders) === '[object Object]'
? this.pagesConfig.placeholders
: {};
if (configPlaceholders instanceof Promise) {
configPlaceholders = await configPlaceholders;
}
// Fill placeholders
const allPlaceholders = Object.assign({}, configPlaceholders, placeholders);
const paramsAndPlaceholders = Object.assign({}, params, allPlaceholders);
data = mustache.render(data, paramsAndPlaceholders);
// Add placeholders in header to allow parsing for programmatic use
// of response, instead of having to parse the HTML content.
const headers = Object.entries(params).reduce((m, p) => {
if (p[1] !== undefined) {
m[`${pageParamHeaderPrefix}${p[0].toLowerCase()}`] = p[1];
}
return m;
}, {});
return { text: data, headers: headers };
}
/**
* Creates a response with file content.
* @param {String} path The path of the file to return.
* @returns {Object} The PromiseRouter response.
*/
async fileResponse(path) {
// Get file content
let data;
try {
data = await this.readFile(path);
} catch (e) {
return this.notFound();
}
return { text: data };
}
/**
* Reads and returns the content of a file at a given path. File reading to
* serve content on the static route is only allowed from the pages
* directory on downwards.
* -----------------------------------------------------------------------
* **WARNING:** All file reads in the PagesRouter must be executed by this
* wrapper because it also detects and prevents common exploits.
* -----------------------------------------------------------------------
* @param {String} filePath The path to the file to read.
* @returns {Promise<String>} The file content.
*/
async readFile(filePath) {
// Normalize path to prevent it from containing any directory changing
// UNIX patterns which could expose the whole file system, e.g.
// `http://example.com/parse/apps/../file.txt` requests a file outside
// of the pages directory scope.
const normalizedPath = path.normalize(filePath);
// Abort if the path is outside of the path directory scope
if (!normalizedPath.startsWith(this.pagesPath)) {
throw errors.fileOutsideAllowedScope;
}
return await fs.readFile(normalizedPath, 'utf-8');
}
/**
* Loads a language resource JSON file that is used for translations.
*/
loadJsonResource() {
if (this.pagesConfig.localizationJsonPath === undefined) {
return;
}
try {
const json = require(path.resolve('./', this.pagesConfig.localizationJsonPath));
this.jsonParameters = json;
} catch (e) {
throw errors.jsonFailedFileLoading;
}
}
/**
* Extracts and returns the page default parameters from the Parse Server
* configuration. These parameters are made accessible in every page served
* by this router.
* @param {Object} config The Parse Server configuration.
* @returns {Object} The default parameters.
*/
getDefaultParams(config) {
return config
? {
[pageParams.appId]: config.appId,
[pageParams.appName]: config.appName,
[pageParams.publicServerUrl]: config.publicServerURL,
}
: {};
}
/**
* Extracts and returns the locale from an express request.
* @param {Object} req The express request.
* @returns {String|undefined} The locale, or undefined if no locale was set.
*/
getLocale(req) {
const locale =
(req.query || {})[pageParams.locale] ||
(req.body || {})[pageParams.locale] ||
(req.params || {})[pageParams.locale] ||
(req.headers || {})[pageParamHeaderPrefix + pageParams.locale];
return locale;
}
/**
* Creates a response with http redirect.
* @param {Object} req The express request.
* @param {String} path The path of the file to return.
* @param {Object} params The query parameters to include.
* @returns {Object} The Promise Router response.
*/
async redirectResponse(url, params) {
// Remove any parameters with undefined value
params = Object.entries(params).reduce((m, p) => {
if (p[1] !== undefined) {
m[p[0]] = p[1];
}
return m;
}, {});
// Compose URL with parameters in query
const location = new URL(url);
Object.entries(params).forEach(p => location.searchParams.set(p[0], p[1]));
const locationString = location.toString();
// Add parameters to header to allow parsing for programmatic use
// of response, instead of having to parse the HTML content.
const headers = Object.entries(params).reduce((m, p) => {
if (p[1] !== undefined) {
m[`${pageParamHeaderPrefix}${p[0].toLowerCase()}`] = p[1];
}
return m;
}, {});
return {
status: 303,
location: locationString,
headers: headers,
};
}
defaultPagePath(file) {
return path.join(this.pagesPath, file);
}
composePageUrl(file, publicServerUrl, locale) {
let url = publicServerUrl;
url += url.endsWith('/') ? '' : '/';
url += this.pagesEndpoint + '/';
url += locale === undefined ? '' : locale + '/';
url += file;
return url;
}
notFound() {
return {
text: 'Not found.',
status: 404,
};
}
invalidRequest() {
const error = new Error();
error.status = 403;
error.message = 'unauthorized';
throw error;
}
/**
* Sets the Parse Server configuration in the request object to make it
* easily accessible throughtout request processing.
* @param {Object} req The request.
* @param {Boolean} failGracefully Is true if failing to set the config should
* not result in an invalid request response. Default is `false`.
*/
setConfig(req, failGracefully = false) {
req.config = Config.get(req.params.appId || req.query.appId);
if (!req.config && !failGracefully) {
this.invalidRequest();
}
return Promise.resolve();
}
mountPagesRoutes() {
this.route(
'GET',
`/${this.pagesEndpoint}/:appId/verify_email`,
req => {
this.setConfig(req);
},
req => {
return this.verifyEmail(req);
}
);
this.route(
'POST',
`/${this.pagesEndpoint}/:appId/resend_verification_email`,
req => {
this.setConfig(req);
},
req => {
return this.resendVerificationEmail(req);
}
);
this.route(
'GET',
`/${this.pagesEndpoint}/choose_password`,
req => {
this.setConfig(req);
},
req => {
return this.passwordReset(req);
}
);
this.route(
'POST',
`/${this.pagesEndpoint}/:appId/request_password_reset`,
req => {
this.setConfig(req);
},
req => {
return this.resetPassword(req);
}
);
this.route(
'GET',
`/${this.pagesEndpoint}/:appId/request_password_reset`,
req => {
this.setConfig(req);
},
req => {
return this.requestResetPassword(req);
}
);
}
mountCustomRoutes() {
for (const route of this.pagesConfig.customRoutes || []) {
this.route(
route.method,
`/${this.pagesEndpoint}/:appId/${route.path}`,
req => {
this.setConfig(req);
},
async req => {
const { file, query = {} } = (await route.handler(req)) || {};
// If route handler did not return a page send 404 response
if (!file) {
return this.notFound();
}
// Send page response
const page = new Page({ id: file, defaultFile: file });
return this.goToPage(req, page, query, false);
}
);
}
}
mountStaticRoute() {
this.route(
'GET',
`/${this.pagesEndpoint}/*resource`,
req => {
this.setConfig(req, true);
},
req => {
return this.staticRoute(req);
}
);
}
expressRouter() {
const router = express.Router();
router.use('/', super.expressRouter());
return router;
}
}
export default PagesRouter;
module.exports = {
PagesRouter,
pageParamHeaderPrefix,
pageParams,
pages,
};