Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 168 additions & 1 deletion spec/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Expand Down Expand Up @@ -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('[email protected]');
await user.signUp();

// Should use first publicServerURL
const counterBefore1 = counter;
await Parse.User.requestPasswordReset('[email protected]');
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('[email protected]');
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('[email protected]');
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('[email protected]');
await user2.signUp();
await jasmine.timeout();
expect(emailCalls.length).toEqual(2);
expect(emailCalls[1]).toContain(`https://example.com/${counterBefore2 + 1}`);
expect(counterBefore2).toBeGreaterThan(counterBefore1);
});
});
});
76 changes: 66 additions & 10 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -154,6 +188,7 @@ export class Config {
userController,
appName,
publicServerURL,
_publicServerURL,
emailVerifyTokenValidityDuration,
emailVerifyTokenReuseIfValid,
}) {
Expand All @@ -162,7 +197,7 @@ export class Config {
this.validateEmailConfiguration({
emailAdapter,
appName,
publicServerURL,
publicServerURL: publicServerURL || _publicServerURL,
emailVerifyTokenValidityDuration,
emailVerifyTokenReuseIfValid,
});
Expand Down Expand Up @@ -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,
Expand All @@ -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.';
Expand Down Expand Up @@ -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() {
Expand Down
3 changes: 2 additions & 1 deletion src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`. <br><br>ℹ️ 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<string>));
/* The options for pages such as password reset and email verification.
:DEFAULT: {} */
pages: ?PagesOptions;
Expand Down
2 changes: 1 addition & 1 deletion src/Routers/UsersRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
1 change: 1 addition & 0 deletions src/middlewares.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export async function handleParseHeaders(req, res, next) {
});
return;
}
await config.loadKeys();

info.app = AppCache.get(info.appId);
req.config = config;
Expand Down
2 changes: 1 addition & 1 deletion types/Options/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export interface ParseServerOptions {
cacheAdapter?: Adapter<CacheAdapter>;
emailAdapter?: Adapter<MailAdapter>;
encodeParseObjectInCloudFunction?: boolean;
publicServerURL?: string;
publicServerURL?: string | (() => string) | (() => Promise<string>);
pages?: PagesOptions;
customPages?: CustomPagesOptions;
liveQuery?: LiveQueryOptions;
Expand Down
5 changes: 5 additions & 0 deletions types/ParseServer.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ declare class ParseServer {
* @returns {Promise<void>} a promise that resolves when the server is stopped
*/
handleShutdown(): Promise<void>;
/**
* @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
Expand Down
Loading