Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
167 changes: 167 additions & 0 deletions spec/index.spec.js
Original file line number Diff line number Diff line change
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);
});
});
});
55 changes: 47 additions & 8 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,25 @@ function removeTrailingSlash(str) {
return str;
}

// List of config keys that can be async (functions or promises)
const asyncKeys = ['publicServerURL'];

/**
* Helper function to resolve an async config value.
* If the value is a function, it executes it and returns the result.
* If the value is a promise, it awaits it and returns the result.
* Otherwise, it returns the raw value.
*/
async function resolveAsyncValue(value) {
if (typeof value === 'function') {
return await value();
}
if (value && typeof value.then === 'function') {
return await value;
}
return value;
}

export class Config {
static get(applicationId: string, mount: string) {
const cacheInfo = AppCache.get(applicationId);
Expand All @@ -53,9 +72,30 @@ export class Config {
config
);
config.version = version;

// Transform async keys: store original in _[key]
asyncKeys.forEach(key => {
if (config[key] !== undefined && (typeof config[key] === 'function' || (config[key] && typeof config[key].then === 'function'))) {
config[`_${key}`] = config[key];
// Will be resolved in middleware
delete config[key];
}
});

return config;
}

async loadKeys() {
await Promise.all(
asyncKeys.map(async key => {
if (this[`_${key}`] !== undefined) {
this[key] = await resolveAsyncValue(this[`_${key}`]);
}
})
);
AppCache.put(this.appId, this);
}

static put(serverConfiguration) {
Config.validateOptions(serverConfiguration);
Config.validateControllers(serverConfiguration);
Expand Down Expand Up @@ -116,7 +156,11 @@ export class Config {
}

if (publicServerURL) {
if (!publicServerURL.startsWith('http://') && !publicServerURL.startsWith('https://')) {
if (
typeof publicServerURL !== 'function' &&
!publicServerURL.startsWith('http://') &&
!publicServerURL.startsWith('https://')
) {
throw 'publicServerURL should be a valid HTTPS URL starting with https://';
}
}
Expand Down Expand Up @@ -445,7 +489,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') {
if (!publicServerURL || (typeof publicServerURL !== 'string' && typeof publicServerURL !== 'function')) {
throw 'A public server url is required for e-mail verification and password resets.';
}
if (emailVerifyTokenValidityDuration) {
Expand Down Expand Up @@ -517,11 +561,7 @@ export class Config {
}

get mount() {
var mount = this._mount;
if (this.publicServerURL) {
mount = this.publicServerURL;
}
return mount;
return this._mount;
}

set mount(newValue) {
Expand Down Expand Up @@ -757,7 +797,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
32 changes: 18 additions & 14 deletions src/Routers/PagesRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,15 @@ export class PagesRouter extends PromiseRouter {
);
}

passwordReset(req) {
async passwordReset(req) {
const config = req.config;
const publicServerURL = await config.getPublicServerURL();
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,
[pageParams.publicServerUrl]: publicServerURL,
};
return this.goToPage(req, pages.passwordReset, params);
}
Expand Down Expand Up @@ -255,7 +256,7 @@ export class PagesRouter extends PromiseRouter {
* - POST request -> redirect response (PRG pattern)
* @returns {Promise<Object>} The PromiseRouter response.
*/
goToPage(req, page, params = {}, responseType) {
async goToPage(req, page, params = {}, responseType) {
const config = req.config;

// Determine redirect either by force, response setting or request method
Expand All @@ -266,7 +267,7 @@ export class PagesRouter extends PromiseRouter {
: req.method == 'POST';

// Include default parameters
const defaultParams = this.getDefaultParams(config);
const defaultParams = await this.getDefaultParams(config);
if (Object.values(defaultParams).includes(undefined)) {
return this.notFound();
}
Expand All @@ -281,7 +282,8 @@ export class PagesRouter extends PromiseRouter {
// Compose paths and URLs
const defaultFile = page.defaultFile;
const defaultPath = this.defaultPagePath(defaultFile);
const defaultUrl = this.composePageUrl(defaultFile, config.publicServerURL);
const publicServerURL = await config.getPublicServerURL();
const defaultUrl = this.composePageUrl(defaultFile, publicServerURL);

// If custom URL is set redirect to it without localization
const customUrl = config.pages.customUrls[page.id];
Expand All @@ -300,7 +302,7 @@ export class PagesRouter extends PromiseRouter {
return Utils.getLocalizedPath(defaultPath, locale).then(({ path, subdir }) =>
redirect
? this.redirectResponse(
this.composePageUrl(defaultFile, config.publicServerURL, subdir),
this.composePageUrl(defaultFile, publicServerURL, subdir),
params
)
: this.pageResponse(path, params, placeholders)
Expand Down Expand Up @@ -529,14 +531,16 @@ export class PagesRouter extends PromiseRouter {
* @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,
}
: {};
async getDefaultParams(config) {
if (!config) {
return {};
}
const publicServerURL = await config.getPublicServerURL();
return {
[pageParams.appId]: config.appId,
[pageParams.appName]: config.appName,
[pageParams.publicServerUrl]: publicServerURL,
};
}

/**
Expand Down
31 changes: 17 additions & 14 deletions src/Routers/PublicAPIRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,26 +88,29 @@ export class PublicAPIRouter extends PromiseRouter {
);
}

changePassword(req) {
return new Promise((resolve, reject) => {
const config = Config.get(req.query.id);
async changePassword(req) {
const config = Config.get(req.query.id);

if (!config) {
this.invalidRequest();
}
if (!config) {
this.invalidRequest();
}

if (!config.publicServerURL) {
return resolve({
status: 404,
text: 'Not found.',
});
}
// Should we keep the file in memory or leave like that?
if (!config.publicServerURL) {
return {
status: 404,
text: 'Not found.',
};
}

const publicServerURL = await config.getPublicServerURL();

// Should we keep the file in memory or leave like that?
return new Promise((resolve, reject) => {
fs.readFile(path.resolve(views, 'choose_password'), 'utf-8', (err, data) => {
if (err) {
return reject(err);
}
data = data.replace('PARSE_SERVER_URL', `'${config.publicServerURL}'`);
data = data.replace('PARSE_SERVER_URL', `'${publicServerURL}'`);
resolve({
text: data,
});
Expand Down
Loading
Loading