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:
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
36
src/Page.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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
725
src/Routers/PagesRouter.js
Normal 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
123
src/Utils.js
Normal 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;
|
||||
Reference in New Issue
Block a user