Add page localization (#7128)

* added localized pages; added refactored page templates; adapted test cases; introduced localization test cases

* added changelog entry

* fixed test description typo

* fixed bug in PromiseRouter where headers are not added for text reponse

* added page parameters in page headers for programmatic use

* refactored tests for PublicAPIRouter

* added mustache lib for template rendering

* fixed fs.promises module reference

* fixed template placeholder typo

* changed redirect response to provide headers instead of query parameters

* fix lint

* fixed syntax errors and typos in html templates

* removed obsolete URI encoding

* added locale inferring from request body and header

* added end-to-end localizaton test

* added server option validation; refactored pages server option

* fixed invalid redirect URL for no locale matching file

* added end-to-end localizaton tests

* adapted tests to new response content

* re-added PublicAPIRouter; added PagesRouter as experimental feature

* refactored PagesRouter test structure

* added configuration option for custom path to pages

* added configuration option for custom endpoint to pages

* fixed lint

* added tests

* added a distinct page for invalid password reset link

* renamed generic page invalidLink to expiredVerificationLink

* improved HTML files documentation

* improved HTML files documentation

* changed changelog entry for experimental feature

* improved file naming to make it more descriptive

* fixed file naming and env parameter naming

* added readme entry

* fixed readme TOC - hasn't been updated in a while

* added localization with JSON resource

* added JSON localization to feature pages (password reset, email verification)

* updated readme

* updated readme

* optimized JSON localization for feature pages; added e2e test case

* fixed readme typo

* minor refactoring of existing tests

* fixed bug where Object type was not recognized as config key type

* added feature config placeholders

* prettier

* added passing locale to page config placeholder callback

* refactored passing locale to placeholder to pass test

* added config placeholder feature to README

* fixed typo in README
This commit is contained in:
Manuel
2021-02-09 14:03:57 +01:00
committed by GitHub
parent e3ed6e4600
commit 7f47b0427e
41 changed files with 4335 additions and 2903 deletions

View File

@@ -10,8 +10,9 @@ import {
IdempotencyOptions,
FileUploadOptions,
AccountLockoutOptions,
PagesOptions,
} from './Options/Definitions';
import { isBoolean } from 'lodash';
import { isBoolean, isString } from 'lodash';
function removeTrailingSlash(str) {
if (!str) {
@@ -77,6 +78,7 @@ export class Config {
idempotencyOptions,
emailVerifyTokenReuseIfValid,
fileUpload,
pages,
}) {
if (masterKey === readOnlyMasterKey) {
throw new Error('masterKey and readOnlyMasterKey should be different');
@@ -111,6 +113,61 @@ export class Config {
this.validateMaxLimit(maxLimit);
this.validateAllowHeaders(allowHeaders);
this.validateIdempotencyOptions(idempotencyOptions);
this.validatePagesOptions(pages);
}
static validatePagesOptions(pages) {
if (Object.prototype.toString.call(pages) !== '[object Object]') {
throw 'Parse Server option pages must be an object.';
}
if (pages.enableRouter === undefined) {
pages.enableRouter = PagesOptions.enableRouter.default;
} else if (!isBoolean(pages.enableRouter)) {
throw 'Parse Server option pages.enableRouter must be a boolean.';
}
if (pages.enableLocalization === undefined) {
pages.enableLocalization = PagesOptions.enableLocalization.default;
} else if (!isBoolean(pages.enableLocalization)) {
throw 'Parse Server option pages.enableLocalization must be a boolean.';
}
if (pages.localizationJsonPath === undefined) {
pages.localizationJsonPath = PagesOptions.localizationJsonPath.default;
} else if (!isString(pages.localizationJsonPath)) {
throw 'Parse Server option pages.localizationJsonPath must be a string.';
}
if (pages.localizationFallbackLocale === undefined) {
pages.localizationFallbackLocale = PagesOptions.localizationFallbackLocale.default;
} else if (!isString(pages.localizationFallbackLocale)) {
throw 'Parse Server option pages.localizationFallbackLocale must be a string.';
}
if (pages.placeholders === undefined) {
pages.placeholders = PagesOptions.placeholders.default;
} else if (
Object.prototype.toString.call(pages.placeholders) !== '[object Object]' &&
typeof pages.placeholders !== 'function'
) {
throw 'Parse Server option pages.placeholders must be an object or a function.';
}
if (pages.forceRedirect === undefined) {
pages.forceRedirect = PagesOptions.forceRedirect.default;
} else if (!isBoolean(pages.forceRedirect)) {
throw 'Parse Server option pages.forceRedirect must be a boolean.';
}
if (pages.pagesPath === undefined) {
pages.pagesPath = PagesOptions.pagesPath.default;
} else if (!isString(pages.pagesPath)) {
throw 'Parse Server option pages.pagesPath must be a string.';
}
if (pages.pagesEndpoint === undefined) {
pages.pagesEndpoint = PagesOptions.pagesEndpoint.default;
} else if (!isString(pages.pagesEndpoint)) {
throw 'Parse Server option pages.pagesEndpoint must be a string.';
}
if (pages.customUrls === undefined) {
pages.customUrls = PagesOptions.customUrls.default;
} else if (Object.prototype.toString.call(pages.customUrls) !== '[object Object]') {
throw 'Parse Server option pages.customUrls must be an object.';
}
}
static validateIdempotencyOptions(idempotencyOptions) {

View File

@@ -289,6 +289,13 @@ module.exports.ParseServerOptions = {
action: parsers.numberParser('objectIdSize'),
default: 10,
},
pages: {
env: 'PARSE_SERVER_PAGES',
help:
'The options for pages such as password reset and email verification. Caution, this is an experimental feature that may not be appropriate for production.',
action: parsers.objectParser,
default: {},
},
passwordPolicy: {
env: 'PARSE_SERVER_PASSWORD_POLICY',
help: 'Password policy for enforcing password related rules',
@@ -417,15 +424,114 @@ module.exports.ParseServerOptions = {
help: 'Key sent with outgoing webhook calls',
},
};
module.exports.PagesOptions = {
customUrls: {
env: 'PARSE_SERVER_PAGES_CUSTOM_URLS',
help: 'The URLs to the custom pages.',
action: parsers.objectParser,
default: {},
},
enableLocalization: {
env: 'PARSE_SERVER_PAGES_ENABLE_LOCALIZATION',
help: 'Is true if pages should be localized; this has no effect on custom page redirects.',
action: parsers.booleanParser,
default: false,
},
enableRouter: {
env: 'PARSE_SERVER_PAGES_ENABLE_ROUTER',
help:
'Is true if the pages router should be enabled; this is required for any of the pages options to take effect. Caution, this is an experimental feature that may not be appropriate for production.',
action: parsers.booleanParser,
default: false,
},
forceRedirect: {
env: 'PARSE_SERVER_PAGES_FORCE_REDIRECT',
help:
'Is true if responses should always be redirects and never content, false if the response type should depend on the request type (GET request -> content response; POST request -> redirect response).',
action: parsers.booleanParser,
default: false,
},
localizationFallbackLocale: {
env: 'PARSE_SERVER_PAGES_LOCALIZATION_FALLBACK_LOCALE',
help:
'The fallback locale for localization if no matching translation is provided for the given locale. This is only relevant when providing translation resources via JSON file.',
default: 'en',
},
localizationJsonPath: {
env: 'PARSE_SERVER_PAGES_LOCALIZATION_JSON_PATH',
help:
'The path to the JSON file for localization; the translations will be used to fill template placeholders according to the locale.',
},
pagesEndpoint: {
env: 'PARSE_SERVER_PAGES_PAGES_ENDPOINT',
help: "The API endpoint for the pages. Default is 'apps'.",
default: 'apps',
},
pagesPath: {
env: 'PARSE_SERVER_PAGES_PAGES_PATH',
help:
"The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory.",
default: './public',
},
placeholders: {
env: 'PARSE_SERVER_PAGES_PLACEHOLDERS',
help:
'The placeholder keys and values which will be filled in pages; this can be a simple object or a callback function.',
action: parsers.objectParser,
default: {},
},
};
module.exports.PagesCustomUrlsOptions = {
emailVerificationLinkExpired: {
env: 'PARSE_SERVER_PAGES_CUSTOM_URL_EMAIL_VERIFICATION_LINK_EXPIRED',
help: 'The URL to the custom page for email verification -> link expired.',
},
emailVerificationLinkInvalid: {
env: 'PARSE_SERVER_PAGES_CUSTOM_URL_EMAIL_VERIFICATION_LINK_INVALID',
help: 'The URL to the custom page for email verification -> link invalid.',
},
emailVerificationSendFail: {
env: 'PARSE_SERVER_PAGES_CUSTOM_URL_EMAIL_VERIFICATION_SEND_FAIL',
help: 'The URL to the custom page for email verification -> link send fail.',
},
emailVerificationSendSuccess: {
env: 'PARSE_SERVER_PAGES_CUSTOM_URL_EMAIL_VERIFICATION_SEND_SUCCESS',
help: 'The URL to the custom page for email verification -> resend link -> success.',
},
emailVerificationSuccess: {
env: 'PARSE_SERVER_PAGES_CUSTOM_URL_EMAIL_VERIFICATION_SUCCESS',
help: 'The URL to the custom page for email verification -> success.',
},
passwordReset: {
env: 'PARSE_SERVER_PAGES_CUSTOM_URL_PASSWORD_RESET',
help: 'The URL to the custom page for password reset.',
},
passwordResetLinkInvalid: {
env: 'PARSE_SERVER_PAGES_CUSTOM_URL_PASSWORD_RESET_LINK_INVALID',
help: 'The URL to the custom page for password reset -> link invalid.',
},
passwordResetSuccess: {
env: 'PARSE_SERVER_PAGES_CUSTOM_URL_PASSWORD_RESET_SUCCESS',
help: 'The URL to the custom page for password reset -> success.',
},
};
module.exports.CustomPagesOptions = {
choosePassword: {
env: 'PARSE_SERVER_CUSTOM_PAGES_CHOOSE_PASSWORD',
help: 'choose password page path',
},
expiredVerificationLink: {
env: 'PARSE_SERVER_CUSTOM_PAGES_EXPIRED_VERIFICATION_LINK',
help: 'expired verification link page path',
},
invalidLink: {
env: 'PARSE_SERVER_CUSTOM_PAGES_INVALID_LINK',
help: 'invalid link page path',
},
invalidPasswordResetLink: {
env: 'PARSE_SERVER_CUSTOM_PAGES_INVALID_PASSWORD_RESET_LINK',
help: 'invalid password reset link page path',
},
invalidVerificationLink: {
env: 'PARSE_SERVER_CUSTOM_PAGES_INVALID_VERIFICATION_LINK',
help: 'invalid verification link page path',

View File

@@ -54,6 +54,7 @@
* @property {String} mountPath Mount path for the server, defaults to /parse
* @property {Boolean} mountPlayground Mounts the GraphQL Playground - never use this option in production
* @property {Number} objectIdSize Sets the number of characters in generated object id's, default 10
* @property {PagesOptions} pages The options for pages such as password reset and email verification. Caution, this is an experimental feature that may not be appropriate for production.
* @property {PasswordPolicyOptions} passwordPolicy Password policy for enforcing password related rules
* @property {String} playgroundPath Mount path for the GraphQL Playground, defaults to /playground
* @property {Number} port The port to run the ParseServer, defaults to 1337.
@@ -79,10 +80,37 @@
* @property {String} webhookKey Key sent with outgoing webhook calls
*/
/**
* @interface PagesOptions
* @property {PagesCustomUrlsOptions} customUrls The URLs to the custom pages.
* @property {Boolean} enableLocalization Is true if pages should be localized; this has no effect on custom page redirects.
* @property {Boolean} enableRouter Is true if the pages router should be enabled; this is required for any of the pages options to take effect. Caution, this is an experimental feature that may not be appropriate for production.
* @property {Boolean} forceRedirect Is true if responses should always be redirects and never content, false if the response type should depend on the request type (GET request -> content response; POST request -> redirect response).
* @property {String} localizationFallbackLocale The fallback locale for localization if no matching translation is provided for the given locale. This is only relevant when providing translation resources via JSON file.
* @property {String} localizationJsonPath The path to the JSON file for localization; the translations will be used to fill template placeholders according to the locale.
* @property {String} pagesEndpoint The API endpoint for the pages. Default is 'apps'.
* @property {String} pagesPath The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory.
* @property {Object} placeholders The placeholder keys and values which will be filled in pages; this can be a simple object or a callback function.
*/
/**
* @interface PagesCustomUrlsOptions
* @property {String} emailVerificationLinkExpired The URL to the custom page for email verification -> link expired.
* @property {String} emailVerificationLinkInvalid The URL to the custom page for email verification -> link invalid.
* @property {String} emailVerificationSendFail The URL to the custom page for email verification -> link send fail.
* @property {String} emailVerificationSendSuccess The URL to the custom page for email verification -> resend link -> success.
* @property {String} emailVerificationSuccess The URL to the custom page for email verification -> success.
* @property {String} passwordReset The URL to the custom page for password reset.
* @property {String} passwordResetLinkInvalid The URL to the custom page for password reset -> link invalid.
* @property {String} passwordResetSuccess The URL to the custom page for password reset -> success.
*/
/**
* @interface CustomPagesOptions
* @property {String} choosePassword choose password page path
* @property {String} expiredVerificationLink expired verification link page path
* @property {String} invalidLink invalid link page path
* @property {String} invalidPasswordResetLink invalid password reset link page path
* @property {String} invalidVerificationLink invalid verification link page path
* @property {String} linkSendFail verification link send fail page path
* @property {String} linkSendSuccess verification link send success page path

View File

@@ -138,6 +138,9 @@ export interface ParseServerOptions {
/* Public URL to your parse server with http:// or https://.
:ENV: PARSE_PUBLIC_SERVER_URL */
publicServerURL: ?string;
/* The options for pages such as password reset and email verification. Caution, this is an experimental feature that may not be appropriate for production.
:DEFAULT: {} */
pages: ?PagesOptions;
/* custom pages for password validation and reset
:DEFAULT: {} */
customPages: ?CustomPagesOptions;
@@ -226,21 +229,73 @@ export interface ParseServerOptions {
serverCloseComplete: ?() => void;
}
export interface PagesOptions {
/* Is true if the pages router should be enabled; this is required for any of the pages options to take effect. Caution, this is an experimental feature that may not be appropriate for production.
:DEFAULT: false */
enableRouter: ?boolean;
/* Is true if pages should be localized; this has no effect on custom page redirects.
:DEFAULT: false */
enableLocalization: ?boolean;
/* The path to the JSON file for localization; the translations will be used to fill template placeholders according to the locale. */
localizationJsonPath: ?string;
/* The fallback locale for localization if no matching translation is provided for the given locale. This is only relevant when providing translation resources via JSON file.
:DEFAULT: en */
localizationFallbackLocale: ?string;
/* The placeholder keys and values which will be filled in pages; this can be a simple object or a callback function.
:DEFAULT: {} */
placeholders: ?Object;
/* Is true if responses should always be redirects and never content, false if the response type should depend on the request type (GET request -> content response; POST request -> redirect response).
:DEFAULT: false */
forceRedirect: ?boolean;
/* The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory.
:DEFAULT: ./public */
pagesPath: ?string;
/* The API endpoint for the pages. Default is 'apps'.
:DEFAULT: apps */
pagesEndpoint: ?string;
/* The URLs to the custom pages.
:DEFAULT: {} */
customUrls: ?PagesCustomUrlsOptions;
}
export interface PagesCustomUrlsOptions {
/* The URL to the custom page for password reset. */
passwordReset: ?string;
/* The URL to the custom page for password reset -> link invalid. */
passwordResetLinkInvalid: ?string;
/* The URL to the custom page for password reset -> success. */
passwordResetSuccess: ?string;
/* The URL to the custom page for email verification -> success. */
emailVerificationSuccess: ?string;
/* The URL to the custom page for email verification -> link send fail. */
emailVerificationSendFail: ?string;
/* The URL to the custom page for email verification -> resend link -> success. */
emailVerificationSendSuccess: ?string;
/* The URL to the custom page for email verification -> link invalid. */
emailVerificationLinkInvalid: ?string;
/* The URL to the custom page for email verification -> link expired. */
emailVerificationLinkExpired: ?string;
}
export interface CustomPagesOptions {
/* invalid link page path */
invalidLink: ?string;
/* verify email success page path */
verifyEmailSuccess: ?string;
/* invalid verification link page path */
invalidVerificationLink: ?string;
/* verification link send success page path */
linkSendSuccess: ?string;
/* verification link send fail page path */
linkSendFail: ?string;
/* choose password page path */
choosePassword: ?string;
/* verification link send success page path */
linkSendSuccess: ?string;
/* verify email success page path */
verifyEmailSuccess: ?string;
/* password reset success page path */
passwordResetSuccess: ?string;
/* invalid verification link page path */
invalidVerificationLink: ?string;
/* expired verification link page path */
expiredVerificationLink: ?string;
/* invalid password reset link page path */
invalidPasswordResetLink: ?string;
/* for masking user-facing pages */
parseFrameURL: ?string;
}

36
src/Page.js Normal file
View File

@@ -0,0 +1,36 @@
/*eslint no-unused-vars: "off"*/
/**
* @interface Page
* Page
* Page content that is returned by PageRouter.
*/
export class Page {
/**
* @description Creates a page.
* @param {Object} params The page parameters.
* @param {String} params.id The page identifier.
* @param {String} params.defaultFile The page file name.
* @returns {Page} The page.
*/
constructor(params = {}) {
const { id, defaultFile } = params;
this._id = id;
this._defaultFile = defaultFile;
}
get id() {
return this._id;
}
get defaultFile() {
return this._defaultFile;
}
set id(v) {
this._id = v;
}
set defaultFile(v) {
this._defaultFile = v;
}
}
export default Page;

View File

@@ -27,6 +27,7 @@ import { IAPValidationRouter } from './Routers/IAPValidationRouter';
import { InstallationsRouter } from './Routers/InstallationsRouter';
import { LogsRouter } from './Routers/LogsRouter';
import { ParseLiveQueryServer } from './LiveQuery/ParseLiveQueryServer';
import { PagesRouter } from './Routers/PagesRouter';
import { PublicAPIRouter } from './Routers/PublicAPIRouter';
import { PushRouter } from './Routers/PushRouter';
import { CloudCodeRouter } from './Routers/CloudCodeRouter';
@@ -134,7 +135,8 @@ class ParseServer {
* @static
* Create an express app for the parse server
* @param {Object} options let you specify the maxUploadSize when creating the express app */
static app({ maxUploadSize = '20mb', appId, directAccess }) {
static app(options) {
const { maxUploadSize = '20mb', appId, directAccess, pages } = options;
// This app serves the Parse API directly.
// It's the equivalent of https://api.parse.com/1 in the hosted Parse API.
var api = express();
@@ -154,7 +156,13 @@ class ParseServer {
});
});
api.use('/', bodyParser.urlencoded({ extended: false }), new PublicAPIRouter().expressRouter());
api.use(
'/',
bodyParser.urlencoded({ extended: false }),
pages.enableRouter
? new PagesRouter(pages).expressRouter()
: new PublicAPIRouter().expressRouter()
);
api.use(bodyParser.json({ type: '*/*', limit: maxUploadSize }));
api.use(middlewares.allowMethodOverride);

View File

@@ -161,6 +161,12 @@ function makeExpressHandler(appId, promiseHandler) {
var status = result.status || 200;
res.status(status);
if (result.headers) {
Object.keys(result.headers).forEach(header => {
res.set(header, result.headers[header]);
});
}
if (result.text) {
res.send(result.text);
return;
@@ -175,11 +181,6 @@ function makeExpressHandler(appId, promiseHandler) {
return;
}
}
if (result.headers) {
Object.keys(result.headers).forEach(header => {
res.set(header, result.headers[header]);
});
}
res.json(result.response);
},
error => {

725
src/Routers/PagesRouter.js Normal file
View File

@@ -0,0 +1,725 @@
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();
}
verifyEmail(req) {
const config = req.config;
const { username, token: rawToken } = req.query;
const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;
if (!config) {
this.invalidRequest();
}
if (!token || !username) {
return this.goToPage(req, pages.emailVerificationLinkInvalid);
}
const userController = config.userController;
return userController.verifyEmail(username, token).then(
() => {
const params = {
[pageParams.username]: username,
};
return this.goToPage(req, pages.emailVerificationSuccess, params);
},
() => {
const params = {
[pageParams.username]: username,
};
return this.goToPage(req, pages.emailVerificationLinkExpired, params);
}
);
}
resendVerificationEmail(req) {
const config = req.config;
const username = req.body.username;
if (!config) {
this.invalidRequest();
}
if (!username) {
return this.goToPage(req, pages.emailVerificationLinkInvalid);
}
const userController = config.userController;
return userController.resendVerificationEmail(username).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 { username, token: rawToken } = req.query;
const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;
if (!username || !token) {
return this.goToPage(req, pages.passwordResetLinkInvalid);
}
return config.userController.checkResetTokenValidity(username, token).then(
() => {
const params = {
[pageParams.token]: token,
[pageParams.username]: username,
[pageParams.appId]: config.applicationId,
[pageParams.appName]: config.appName,
};
return this.goToPage(req, pages.passwordReset, params);
},
() => {
const params = {
[pageParams.username]: username,
};
return this.goToPage(req, pages.passwordResetLinkInvalid, params);
}
);
}
resetPassword(req) {
const config = req.config;
if (!config) {
this.invalidRequest();
}
const { username, new_password, token: rawToken } = req.body;
const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;
if ((!username || !token || !new_password) && req.xhr === false) {
return this.goToPage(req, pages.passwordResetLinkInvalid);
}
if (!username) {
throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'Missing username');
}
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(username, 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.username]: username,
}
: {
[pageParams.username]: username,
[pageParams.token]: token,
[pageParams.appId]: config.applicationId,
[pageParams.error]: result.err,
[pageParams.appName]: config.appName,
};
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 -> passwort_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[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 rediret.
* @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);
}
);
this.route(
'GET',
`/${this.pagesEndpoint}/(*)?`,
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,
};

123
src/Utils.js Normal file
View File

@@ -0,0 +1,123 @@
/**
* utils.js
* @file General purpose utilities
* @description General purpose utilities.
*/
const path = require('path');
const fs = require('fs').promises;
/**
* The general purpose utilities.
*/
class Utils {
/**
* @function getLocalizedPath
* @description Returns a localized file path accoring to the locale.
*
* Localized files are searched in subfolders of a given path, e.g.
*
* root/
* ├── base/ // base path to files
* │ ├── example.html // default file
* │ └── de/ // de language folder
* │ │ └── example.html // de localized file
* │ └── de-AT/ // de-AT locale folder
* │ │ └── example.html // de-AT localized file
*
* Files are matched with the locale in the following order:
* 1. Locale match, e.g. locale `de-AT` matches file in folder `de-AT`.
* 2. Language match, e.g. locale `de-AT` matches file in folder `de`.
* 3. Default; file in base folder is returned.
*
* @param {String} defaultPath The absolute file path, which is also
* the default path returned if localization is not available.
* @param {String} locale The locale.
* @returns {Promise<Object>} The object contains:
* - `path`: The path to the localized file, or the original path if
* localization is not available.
* - `subdir`: The subdirectory of the localized file, or undefined if
* there is no matching localized file.
*/
static async getLocalizedPath(defaultPath, locale) {
// Get file name and paths
const file = path.basename(defaultPath);
const basePath = path.dirname(defaultPath);
// If locale is not set return default file
if (!locale) {
return { path: defaultPath };
}
// Check file for locale exists
const localePath = path.join(basePath, locale, file);
const localeFileExists = await Utils.fileExists(localePath);
// If file for locale exists return file
if (localeFileExists) {
return { path: localePath, subdir: locale };
}
// Check file for language exists
const language = locale.split('-')[0];
const languagePath = path.join(basePath, language, file);
const languageFileExists = await Utils.fileExists(languagePath);
// If file for language exists return file
if (languageFileExists) {
return { path: languagePath, subdir: language };
}
// Return default file
return { path: defaultPath };
}
/**
* @function fileExists
* @description Checks whether a file exists.
* @param {String} path The file path.
* @returns {Promise<Boolean>} Is true if the file can be accessed, false otherwise.
*/
static async fileExists(path) {
try {
await fs.access(path);
return true;
} catch (e) {
return false;
}
}
/**
* @function isPath
* @description Evaluates whether a string is a file path (as opposed to a URL for example).
* @param {String} s The string to evaluate.
* @returns {Boolean} Returns true if the evaluated string is a path.
*/
static isPath(s) {
return /(^\/)|(^\.\/)|(^\.\.\/)/.test(s);
}
/**
* Flattens an object and crates new keys with custom delimiters.
* @param {Object} obj The object to flatten.
* @param {String} [delimiter='.'] The delimiter of the newly generated keys.
* @param {Object} result
* @returns {Object} The flattened object.
**/
static flattenObject(obj, parentKey, delimiter = '.', result = {}) {
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const newKey = parentKey ? parentKey + delimiter + key : key;
if (typeof obj[key] === 'object' && obj[key] !== null) {
this.flattenObject(obj[key], newKey, delimiter, result);
} else {
result[newKey] = obj[key];
}
}
}
return result;
}
}
module.exports = Utils;