diff --git a/spec/index.spec.js b/spec/index.spec.js
index 5093a6ea25..afc1b5362e 100644
--- a/spec/index.spec.js
+++ b/spec/index.spec.js
@@ -363,7 +363,7 @@ describe('server', () => {
it('should throw when getting invalid mount', done => {
reconfigureServer({ publicServerURL: 'blabla:/some' }).catch(error => {
- expect(error).toEqual('publicServerURL should be a valid HTTPS URL starting with https://');
+ expect(error).toEqual('The option publicServerURL must be a valid URL starting with http:// or https://.');
done();
});
});
@@ -685,4 +685,171 @@ describe('server', () => {
})
.catch(done.fail);
});
+
+ describe('publicServerURL', () => {
+ it('should load publicServerURL', async () => {
+ await reconfigureServer({
+ publicServerURL: () => 'https://example.com/1',
+ });
+
+ await new Parse.Object('TestObject').save();
+
+ const config = Config.get(Parse.applicationId);
+ expect(config.publicServerURL).toEqual('https://example.com/1');
+ });
+
+ it('should load publicServerURL from Promise', async () => {
+ await reconfigureServer({
+ publicServerURL: () => Promise.resolve('https://example.com/1'),
+ });
+
+ await new Parse.Object('TestObject').save();
+
+ const config = Config.get(Parse.applicationId);
+ expect(config.publicServerURL).toEqual('https://example.com/1');
+ });
+
+ it('should handle publicServerURL function throwing error', async () => {
+ const errorMessage = 'Failed to get public server URL';
+ await reconfigureServer({
+ publicServerURL: () => {
+ throw new Error(errorMessage);
+ },
+ });
+
+ // The error should occur when trying to save an object (which triggers loadKeys in middleware)
+ await expectAsync(
+ new Parse.Object('TestObject').save()
+ ).toBeRejected();
+ });
+
+ it('should handle publicServerURL Promise rejection', async () => {
+ const errorMessage = 'Async fetch of public server URL failed';
+ await reconfigureServer({
+ publicServerURL: () => Promise.reject(new Error(errorMessage)),
+ });
+
+ // The error should occur when trying to save an object (which triggers loadKeys in middleware)
+ await expectAsync(
+ new Parse.Object('TestObject').save()
+ ).toBeRejected();
+ });
+
+ it('executes publicServerURL function on every config access', async () => {
+ let counter = 0;
+ await reconfigureServer({
+ publicServerURL: () => {
+ counter++;
+ return `https://example.com/${counter}`;
+ },
+ });
+
+ // First request - should call the function
+ await new Parse.Object('TestObject').save();
+ const config1 = Config.get(Parse.applicationId);
+ expect(config1.publicServerURL).toEqual('https://example.com/1');
+ expect(counter).toEqual(1);
+
+ // Second request - should call the function again
+ await new Parse.Object('TestObject').save();
+ const config2 = Config.get(Parse.applicationId);
+ expect(config2.publicServerURL).toEqual('https://example.com/2');
+ expect(counter).toEqual(2);
+
+ // Third request - should call the function again
+ await new Parse.Object('TestObject').save();
+ const config3 = Config.get(Parse.applicationId);
+ expect(config3.publicServerURL).toEqual('https://example.com/3');
+ expect(counter).toEqual(3);
+ });
+
+ it('executes publicServerURL function on every password reset email', async () => {
+ let counter = 0;
+ const emailCalls = [];
+
+ const emailAdapter = MockEmailAdapterWithOptions({
+ sendPasswordResetEmail: ({ link }) => {
+ emailCalls.push(link);
+ return Promise.resolve();
+ },
+ });
+
+ await reconfigureServer({
+ appName: 'test-app',
+ publicServerURL: () => {
+ counter++;
+ return `https://example.com/${counter}`;
+ },
+ emailAdapter,
+ });
+
+ // Create a user
+ const user = new Parse.User();
+ user.setUsername('user');
+ user.setPassword('pass');
+ user.setEmail('user@example.com');
+ await user.signUp();
+
+ // Should use first publicServerURL
+ const counterBefore1 = counter;
+ await Parse.User.requestPasswordReset('user@example.com');
+ await jasmine.timeout();
+ expect(emailCalls.length).toEqual(1);
+ expect(emailCalls[0]).toContain(`https://example.com/${counterBefore1 + 1}`);
+ expect(counter).toBeGreaterThanOrEqual(2);
+
+ // Should use updated publicServerURL
+ const counterBefore2 = counter;
+ await Parse.User.requestPasswordReset('user@example.com');
+ await jasmine.timeout();
+ expect(emailCalls.length).toEqual(2);
+ expect(emailCalls[1]).toContain(`https://example.com/${counterBefore2 + 1}`);
+ expect(counterBefore2).toBeGreaterThan(counterBefore1);
+ });
+
+ it('executes publicServerURL function on every verification email', async () => {
+ let counter = 0;
+ const emailCalls = [];
+
+ const emailAdapter = MockEmailAdapterWithOptions({
+ sendVerificationEmail: ({ link }) => {
+ emailCalls.push(link);
+ return Promise.resolve();
+ },
+ });
+
+ await reconfigureServer({
+ appName: 'test-app',
+ verifyUserEmails: true,
+ publicServerURL: () => {
+ counter++;
+ return `https://example.com/${counter}`;
+ },
+ emailAdapter,
+ });
+
+ // Should trigger verification email with first publicServerURL
+ const counterBefore1 = counter;
+ const user1 = new Parse.User();
+ user1.setUsername('user1');
+ user1.setPassword('pass1');
+ user1.setEmail('user1@example.com');
+ await user1.signUp();
+ await jasmine.timeout();
+ expect(emailCalls.length).toEqual(1);
+ expect(emailCalls[0]).toContain(`https://example.com/${counterBefore1 + 1}`);
+
+ // Should trigger verification email with updated publicServerURL
+ const counterBefore2 = counter;
+ const user2 = new Parse.User();
+ user2.setUsername('user2');
+ user2.setPassword('pass2');
+ user2.setEmail('user2@example.com');
+ await user2.signUp();
+ await jasmine.timeout();
+ expect(emailCalls.length).toEqual(2);
+ expect(emailCalls[1]).toContain(`https://example.com/${counterBefore2 + 1}`);
+ expect(counterBefore2).toBeGreaterThan(counterBefore1);
+ });
+ });
});
diff --git a/src/Config.js b/src/Config.js
index bf6d50626c..42b24f2d89 100644
--- a/src/Config.js
+++ b/src/Config.js
@@ -32,6 +32,11 @@ function removeTrailingSlash(str) {
return str;
}
+/**
+ * Config keys that need to be loaded asynchronously.
+ */
+const asyncKeys = ['publicServerURL'];
+
export class Config {
static get(applicationId: string, mount: string) {
const cacheInfo = AppCache.get(applicationId);
@@ -56,9 +61,42 @@ export class Config {
return config;
}
+ async loadKeys() {
+ await Promise.all(
+ asyncKeys.map(async key => {
+ if (typeof this[`_${key}`] === 'function') {
+ try {
+ this[key] = await this[`_${key}`]();
+ } catch (error) {
+ throw new Error(`Failed to resolve async config key '${key}': ${error.message}`);
+ }
+ }
+ })
+ );
+
+ const cachedConfig = AppCache.get(this.appId);
+ if (cachedConfig) {
+ const updatedConfig = { ...cachedConfig };
+ asyncKeys.forEach(key => {
+ updatedConfig[key] = this[key];
+ });
+ AppCache.put(this.appId, updatedConfig);
+ }
+ }
+
+ static transformConfiguration(serverConfiguration) {
+ for (const key of Object.keys(serverConfiguration)) {
+ if (asyncKeys.includes(key) && typeof serverConfiguration[key] === 'function') {
+ serverConfiguration[`_${key}`] = serverConfiguration[key];
+ delete serverConfiguration[key];
+ }
+ }
+ }
+
static put(serverConfiguration) {
Config.validateOptions(serverConfiguration);
Config.validateControllers(serverConfiguration);
+ Config.transformConfiguration(serverConfiguration);
AppCache.put(serverConfiguration.appId, serverConfiguration);
Config.setupPasswordValidator(serverConfiguration.passwordPolicy);
return serverConfiguration;
@@ -115,11 +153,7 @@ export class Config {
throw 'extendSessionOnUse must be a boolean value';
}
- if (publicServerURL) {
- if (!publicServerURL.startsWith('http://') && !publicServerURL.startsWith('https://')) {
- throw 'publicServerURL should be a valid HTTPS URL starting with https://';
- }
- }
+ this.validatePublicServerURL({ publicServerURL });
this.validateSessionConfiguration(sessionLength, expireInactiveSessions);
this.validateIps('masterKeyIps', masterKeyIps);
this.validateIps('maintenanceKeyIps', maintenanceKeyIps);
@@ -154,6 +188,7 @@ export class Config {
userController,
appName,
publicServerURL,
+ _publicServerURL,
emailVerifyTokenValidityDuration,
emailVerifyTokenReuseIfValid,
}) {
@@ -162,7 +197,7 @@ export class Config {
this.validateEmailConfiguration({
emailAdapter,
appName,
- publicServerURL,
+ publicServerURL: publicServerURL || _publicServerURL,
emailVerifyTokenValidityDuration,
emailVerifyTokenReuseIfValid,
});
@@ -432,6 +467,30 @@ export class Config {
}
}
+ static validatePublicServerURL({ publicServerURL, required = false }) {
+ if (!publicServerURL) {
+ if (!required) {
+ return;
+ }
+ throw 'The option publicServerURL is required.';
+ }
+
+ const type = typeof publicServerURL;
+
+ if (type === 'string') {
+ if (!publicServerURL.startsWith('http://') && !publicServerURL.startsWith('https://')) {
+ throw 'The option publicServerURL must be a valid URL starting with http:// or https://.';
+ }
+ return;
+ }
+
+ if (type === 'function') {
+ return;
+ }
+
+ throw `The option publicServerURL must be a string or function, but got ${type}.`;
+ }
+
static validateEmailConfiguration({
emailAdapter,
appName,
@@ -445,9 +504,7 @@ export class Config {
if (typeof appName !== 'string') {
throw 'An app name is required for e-mail verification and password resets.';
}
- if (typeof publicServerURL !== 'string') {
- throw 'A public server url is required for e-mail verification and password resets.';
- }
+ this.validatePublicServerURL({ publicServerURL, required: true });
if (emailVerifyTokenValidityDuration) {
if (isNaN(emailVerifyTokenValidityDuration)) {
throw 'Email verify token validity duration must be a valid number.';
@@ -757,7 +814,6 @@ export class Config {
return this.masterKey;
}
-
// TODO: Remove this function once PagesRouter replaces the PublicAPIRouter;
// the (default) endpoint has to be defined in PagesRouter only.
get pagesEndpoint() {
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index d5674eaf29..6c91b1c42c 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -495,7 +495,8 @@ module.exports.ParseServerOptions = {
},
publicServerURL: {
env: 'PARSE_PUBLIC_SERVER_URL',
- help: 'Public URL to your parse server with http:// or https://.',
+ help:
+ 'Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`.',
},
push: {
env: 'PARSE_SERVER_PUSH',
diff --git a/src/Options/docs.js b/src/Options/docs.js
index 4d268847b1..cbe3efbf39 100644
--- a/src/Options/docs.js
+++ b/src/Options/docs.js
@@ -87,7 +87,7 @@
* @property {Boolean} preventLoginWithUnverifiedEmail Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required.
Default is `false`.
Requires option `verifyUserEmails: true`.
* @property {Boolean} preventSignupWithUnverifiedEmail If set to `true` it prevents a user from signing up if the email has not yet been verified and email verification is required. In that case the server responds to the sign-up with HTTP status 400 and a Parse Error 205 `EMAIL_NOT_FOUND`. If set to `false` the server responds with HTTP status 200, and client SDKs return an unauthenticated Parse User without session token. In that case subsequent requests fail until the user's email address is verified.
Default is `false`.
Requires option `verifyUserEmails: true`.
* @property {ProtectedFields} protectedFields Protected fields that should be treated with extra security when fetching details.
- * @property {String} publicServerURL Public URL to your parse server with http:// or https://.
+ * @property {Union} publicServerURL Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`.
* @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications
* @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.
ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case.
* @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes
diff --git a/src/Options/index.js b/src/Options/index.js
index d5317646ba..54195a8c52 100644
--- a/src/Options/index.js
+++ b/src/Options/index.js
@@ -226,9 +226,9 @@ export interface ParseServerOptions {
/* If set to `true`, a `Parse.Object` that is in the payload when calling a Cloud Function will be converted to an instance of `Parse.Object`. If `false`, the object will not be converted and instead be a plain JavaScript object, which contains the raw data of a `Parse.Object` but is not an actual instance of `Parse.Object`. Default is `false`.
ℹ️ The expected behavior would be that the object is converted to an instance of `Parse.Object`, so you would normally set this option to `true`. The default is `false` because this is a temporary option that has been introduced to avoid a breaking change when fixing a bug where JavaScript objects are not converted to actual instances of `Parse.Object`.
:DEFAULT: true */
encodeParseObjectInCloudFunction: ?boolean;
- /* Public URL to your parse server with http:// or https://.
+ /* Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`.
:ENV: PARSE_PUBLIC_SERVER_URL */
- publicServerURL: ?string;
+ publicServerURL: ?(string | (() => string) | (() => Promise));
/* The options for pages such as password reset and email verification.
:DEFAULT: {} */
pages: ?PagesOptions;
diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js
index 7668562965..4f38c60b6c 100644
--- a/src/Routers/UsersRouter.js
+++ b/src/Routers/UsersRouter.js
@@ -418,7 +418,7 @@ export class UsersRouter extends ClassesRouter {
Config.validateEmailConfiguration({
emailAdapter: req.config.userController.adapter,
appName: req.config.appName,
- publicServerURL: req.config.publicServerURL,
+ publicServerURL: req.config.publicServerURL || req.config._publicServerURL,
emailVerifyTokenValidityDuration: req.config.emailVerifyTokenValidityDuration,
emailVerifyTokenReuseIfValid: req.config.emailVerifyTokenReuseIfValid,
});
diff --git a/src/middlewares.js b/src/middlewares.js
index 6479987ba4..93b16f3846 100644
--- a/src/middlewares.js
+++ b/src/middlewares.js
@@ -213,6 +213,7 @@ export async function handleParseHeaders(req, res, next) {
});
return;
}
+ await config.loadKeys();
info.app = AppCache.get(info.appId);
req.config = config;
diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts
index 7a572a2f10..13fc1bd95d 100644
--- a/types/Options/index.d.ts
+++ b/types/Options/index.d.ts
@@ -85,7 +85,7 @@ export interface ParseServerOptions {
cacheAdapter?: Adapter;
emailAdapter?: Adapter;
encodeParseObjectInCloudFunction?: boolean;
- publicServerURL?: string;
+ publicServerURL?: string | (() => string) | (() => Promise);
pages?: PagesOptions;
customPages?: CustomPagesOptions;
liveQuery?: LiveQueryOptions;
diff --git a/types/ParseServer.d.ts b/types/ParseServer.d.ts
index e504e03114..9570f0cf16 100644
--- a/types/ParseServer.d.ts
+++ b/types/ParseServer.d.ts
@@ -26,6 +26,11 @@ declare class ParseServer {
* @returns {Promise} a promise that resolves when the server is stopped
*/
handleShutdown(): Promise;
+ /**
+ * @static
+ * Allow developers to customize each request with inversion of control/dependency injection
+ */
+ static applyRequestContextMiddleware(api: any, options: any): void;
/**
* @static
* Create an express app for the parse server