mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
chore: copy redirect + tests to new api (#53999)
Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
1b43f6d91b
commit
efb8cafb06
@@ -9,7 +9,7 @@ describe('Deprecated unsubscribeEndpoints', () => {
|
||||
setupServer();
|
||||
|
||||
unsubscribeEndpoints.forEach(([endpoint, method]) => {
|
||||
test(`${method} ${endpoint} redirects to referer with "info" message`, async () => {
|
||||
test(`${method} ${endpoint} redirects to origin with "info" message`, async () => {
|
||||
const response = await superRequest(endpoint, { method }).set(
|
||||
'Referer',
|
||||
'https://www.freecodecamp.org/settings'
|
||||
@@ -17,7 +17,7 @@ describe('Deprecated unsubscribeEndpoints', () => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
expect(response.headers.location).toStrictEqual(
|
||||
'https://www.freecodecamp.org/settings' + urlEncodedMessage
|
||||
'https://www.freecodecamp.org' + urlEncodedMessage
|
||||
);
|
||||
expect(response.status).toBe(302);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
|
||||
import { HOME_LOCATION } from '../utils/env';
|
||||
import { getRedirectParams } from '../utils/redirection';
|
||||
|
||||
type Endpoint = [string, 'GET' | 'POST'];
|
||||
|
||||
@@ -28,8 +28,7 @@ export const unsubscribeDeprecated: FastifyPluginCallbackTypebox = (
|
||||
method,
|
||||
url: endpoint,
|
||||
handler: async (req, reply) => {
|
||||
// TODO: port over getRedirectParams from api-server anduser that
|
||||
const origin = req.headers.referer ?? HOME_LOCATION;
|
||||
const { origin } = getRedirectParams(req);
|
||||
void reply.redirectWithMessage(origin, {
|
||||
type: 'info',
|
||||
content:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const allowedOrigins = [
|
||||
'https://www.freecodecamp.dev',
|
||||
'https://www.freecodecamp.org',
|
||||
// pretty sure the rest of these can go?
|
||||
'https://beta.freecodecamp.dev',
|
||||
'https://beta.freecodecamp.org',
|
||||
'https://chinese.freecodecamp.dev',
|
||||
'https://chinese.freecodecamp.org'
|
||||
];
|
||||
@@ -0,0 +1,225 @@
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import {
|
||||
getReturnTo,
|
||||
normalizeParams,
|
||||
getRedirectParams,
|
||||
getPrefixedLandingPath
|
||||
} from './redirection';
|
||||
import { HOME_LOCATION } from './env';
|
||||
|
||||
const validJWTSecret = 'this is a super secret string';
|
||||
const invalidJWTSecret = 'This is not correct secret';
|
||||
const validReturnTo = 'https://www.freecodecamp.org/settings';
|
||||
const invalidReturnTo = 'https://www.freecodecamp.org.fake/settings';
|
||||
const defaultReturnTo = 'https://www.freecodecamp.org/learn';
|
||||
const defaultOrigin = 'https://www.freecodecamp.org';
|
||||
const defaultPrefix = '';
|
||||
|
||||
const defaultObject = {
|
||||
returnTo: defaultReturnTo,
|
||||
origin: defaultOrigin,
|
||||
pathPrefix: defaultPrefix
|
||||
};
|
||||
|
||||
// TODO: tidy this up (the mocking is a bit of a mess)
|
||||
describe('redirection', () => {
|
||||
describe('getReturnTo', () => {
|
||||
it('should extract returnTo from a jwt', () => {
|
||||
expect.assertions(1);
|
||||
|
||||
const encryptedReturnTo = jwt.sign(
|
||||
{ returnTo: validReturnTo, origin: defaultOrigin },
|
||||
validJWTSecret
|
||||
);
|
||||
expect(
|
||||
getReturnTo(encryptedReturnTo, validJWTSecret, defaultOrigin)
|
||||
).toStrictEqual({
|
||||
...defaultObject,
|
||||
returnTo: validReturnTo
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a default url if the secrets do not match', () => {
|
||||
const oldLog = console.log;
|
||||
expect.assertions(2);
|
||||
console.log = jest.fn();
|
||||
const encryptedReturnTo = jwt.sign(
|
||||
{ returnTo: validReturnTo },
|
||||
invalidJWTSecret
|
||||
);
|
||||
expect(
|
||||
getReturnTo(encryptedReturnTo, validJWTSecret, defaultOrigin)
|
||||
).toStrictEqual(defaultObject);
|
||||
expect(console.log).toHaveBeenCalled();
|
||||
console.log = oldLog;
|
||||
});
|
||||
|
||||
it('should return a default url for unknown origins', () => {
|
||||
expect.assertions(1);
|
||||
const encryptedReturnTo = jwt.sign(
|
||||
{ returnTo: invalidReturnTo },
|
||||
validJWTSecret
|
||||
);
|
||||
expect(
|
||||
getReturnTo(encryptedReturnTo, validJWTSecret, defaultOrigin)
|
||||
).toStrictEqual(defaultObject);
|
||||
});
|
||||
});
|
||||
describe('normalizeParams', () => {
|
||||
it('should return a {returnTo, origin, pathPrefix} object', () => {
|
||||
expect.assertions(2);
|
||||
const keys = Object.keys(normalizeParams({}));
|
||||
const expectedKeys = ['returnTo', 'origin', 'pathPrefix'];
|
||||
expect(keys.length).toBe(3);
|
||||
expect(keys).toEqual(expect.arrayContaining(expectedKeys));
|
||||
});
|
||||
it('should default to process.env.HOME_LOCATION', () => {
|
||||
expect.assertions(1);
|
||||
expect(normalizeParams({}, defaultOrigin)).toEqual(defaultObject);
|
||||
});
|
||||
it('should convert an unknown pathPrefix to ""', () => {
|
||||
expect.assertions(1);
|
||||
const brokenPrefix = {
|
||||
...defaultObject,
|
||||
pathPrefix: 'not-really-a-name'
|
||||
};
|
||||
expect(normalizeParams(brokenPrefix, defaultOrigin)).toEqual(
|
||||
defaultObject
|
||||
);
|
||||
});
|
||||
it('should not change a known pathPrefix', () => {
|
||||
expect.assertions(1);
|
||||
const spanishPrefix = {
|
||||
...defaultObject,
|
||||
pathPrefix: 'espanol'
|
||||
};
|
||||
expect(normalizeParams(spanishPrefix, defaultOrigin)).toEqual(
|
||||
spanishPrefix
|
||||
);
|
||||
});
|
||||
// we *could*, in principle, grab the path and send them to
|
||||
// process.env.HOME_LOCATION/path, but if the origin is wrong something unexpected is
|
||||
// going on. In that case it's probably best to just send them to
|
||||
// process.env.HOME_LOCATION/learn.
|
||||
it('should return default parameters if the origin is unknown', () => {
|
||||
expect.assertions(1);
|
||||
const exampleOrigin = {
|
||||
...defaultObject,
|
||||
origin: 'http://example.com',
|
||||
pathPrefix: 'espanol'
|
||||
};
|
||||
expect(normalizeParams(exampleOrigin, defaultOrigin)).toEqual(
|
||||
defaultObject
|
||||
);
|
||||
});
|
||||
it('should return default parameters if the returnTo is unknown', () => {
|
||||
expect.assertions(1);
|
||||
const exampleReturnTo = {
|
||||
...defaultObject,
|
||||
returnTo: 'http://example.com/path',
|
||||
pathPrefix: 'espanol'
|
||||
};
|
||||
expect(normalizeParams(exampleReturnTo, defaultOrigin)).toEqual(
|
||||
defaultObject
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject returnTo without trailing slashes', () => {
|
||||
const exampleReturnTo = {
|
||||
...defaultObject,
|
||||
returnTo: 'https://www.freecodecamp.dev'
|
||||
};
|
||||
expect(normalizeParams(exampleReturnTo, defaultOrigin)).toEqual(
|
||||
defaultObject
|
||||
);
|
||||
});
|
||||
|
||||
it('should not modify the returnTo if it is valid', () => {
|
||||
const exampleReturnTo = {
|
||||
...defaultObject,
|
||||
returnTo: 'https://www.freecodecamp.dev/'
|
||||
};
|
||||
expect(normalizeParams(exampleReturnTo, defaultOrigin)).toEqual(
|
||||
exampleReturnTo
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRedirectParams', () => {
|
||||
it('should decorate the request object', () => {
|
||||
const req: FastifyRequest = {
|
||||
headers: {
|
||||
referer: `https://www.freecodecamp.org/espanol/learn/rosetta-code/`
|
||||
}
|
||||
} as FastifyRequest;
|
||||
|
||||
const expectedReturn = {
|
||||
origin: 'https://www.freecodecamp.org',
|
||||
pathPrefix: 'espanol',
|
||||
returnTo: 'https://www.freecodecamp.org/espanol/learn/rosetta-code/'
|
||||
};
|
||||
|
||||
const result = getRedirectParams(req);
|
||||
expect(result).toEqual(expectedReturn);
|
||||
});
|
||||
|
||||
it('should use HOME_LOCATION with missing referer', () => {
|
||||
const req: FastifyRequest = {
|
||||
headers: {}
|
||||
} as FastifyRequest;
|
||||
|
||||
const expectedReturn = {
|
||||
returnTo: `${HOME_LOCATION}/learn`,
|
||||
origin: HOME_LOCATION,
|
||||
pathPrefix: ''
|
||||
};
|
||||
|
||||
const result = getRedirectParams(req);
|
||||
expect(result).toEqual(expectedReturn);
|
||||
});
|
||||
|
||||
it('should use HOME_LOCATION with invalid referrer', () => {
|
||||
const req: FastifyRequest = {
|
||||
headers: {
|
||||
referer: 'invalid-url'
|
||||
}
|
||||
} as FastifyRequest;
|
||||
|
||||
const expectedReturn = {
|
||||
returnTo: `${HOME_LOCATION}/learn`,
|
||||
origin: HOME_LOCATION,
|
||||
pathPrefix: ''
|
||||
};
|
||||
|
||||
const result = getRedirectParams(req);
|
||||
expect(result).toEqual(expectedReturn);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrefixedLandingPath', () => {
|
||||
it('should return the origin when no pathPrefix is provided', () => {
|
||||
const result = getPrefixedLandingPath(defaultOrigin);
|
||||
expect(result).toEqual(defaultOrigin);
|
||||
});
|
||||
|
||||
it('should append pathPrefix to origin when pathPrefix is provided', () => {
|
||||
const expectedPath = `${defaultOrigin}/learn`;
|
||||
const result = getPrefixedLandingPath(defaultOrigin, 'learn');
|
||||
expect(result).toEqual(expectedPath);
|
||||
});
|
||||
|
||||
it('should handle empty origin', () => {
|
||||
const pathPrefix = 'learn';
|
||||
const expectedPath = '/learn';
|
||||
const result = getPrefixedLandingPath('', pathPrefix);
|
||||
expect(result).toEqual(expectedPath);
|
||||
});
|
||||
|
||||
it('should handle empty pathPrefix', () => {
|
||||
const result = getPrefixedLandingPath(defaultOrigin, '');
|
||||
expect(result).toEqual(defaultOrigin);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
// import { allowedOrigins } from '../../config/allowed-origins';
|
||||
import { availableLangs } from '../../../shared/config/i18n';
|
||||
import { allowedOrigins } from './allowed-origins';
|
||||
|
||||
// process.env.HOME_LOCATION is being used as a fallback here. If the one
|
||||
// provided by the client is invalid we default to this.
|
||||
import { HOME_LOCATION } from './env';
|
||||
|
||||
/**
|
||||
* Get the returnTo value.
|
||||
*
|
||||
* @param encryptedParams - The encrypted parameters.
|
||||
* @param secret - The secret key.
|
||||
* @param _homeLocation - The home location.
|
||||
* @returns The returnTo value.
|
||||
*/
|
||||
export function getReturnTo(
|
||||
encryptedParams: string,
|
||||
secret: jwt.Secret,
|
||||
_homeLocation = process.env.HOME_LOCATION
|
||||
) {
|
||||
let params;
|
||||
try {
|
||||
params = jwt.verify(encryptedParams, secret);
|
||||
} catch (e) {
|
||||
// TODO: report to Sentry? Probably not. Remove entirely?
|
||||
console.log(e);
|
||||
// something went wrong, use default params
|
||||
params = {
|
||||
returnTo: `${_homeLocation}/learn`,
|
||||
origin: _homeLocation,
|
||||
pathPrefix: ''
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-expect-error - I'm working on it...
|
||||
return normalizeParams(params, _homeLocation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the parameters, making they're valid.
|
||||
*
|
||||
* @param arg - The parameters to normalize.
|
||||
* @param arg.returnTo - The returnTo value.
|
||||
* @param arg.origin - The origin value.
|
||||
* @param arg.pathPrefix - The pathPrefix value.
|
||||
* @param _homeLocation - The home location.
|
||||
* @returns The normalized parameters.
|
||||
*/
|
||||
export function normalizeParams(
|
||||
{
|
||||
returnTo,
|
||||
origin,
|
||||
pathPrefix
|
||||
}: { returnTo?: string; origin?: string; pathPrefix?: string },
|
||||
_homeLocation = HOME_LOCATION
|
||||
) {
|
||||
// coerce to strings, just in case something weird and nefarious is happening
|
||||
// TODO: validate, don't coerce
|
||||
returnTo = '' + returnTo;
|
||||
origin = '' + origin;
|
||||
pathPrefix = '' + pathPrefix;
|
||||
// we add the '/' to prevent returns to
|
||||
// www.freecodecamp.org.somewhere.else.com
|
||||
if (
|
||||
!returnTo ||
|
||||
!allowedOrigins.some(allowed => returnTo?.startsWith(allowed + '/'))
|
||||
) {
|
||||
returnTo = `${_homeLocation}/learn`;
|
||||
origin = _homeLocation;
|
||||
pathPrefix = '';
|
||||
}
|
||||
if (!origin || !allowedOrigins.includes(origin)) {
|
||||
returnTo = `${_homeLocation}/learn`;
|
||||
origin = _homeLocation;
|
||||
pathPrefix = '';
|
||||
}
|
||||
pathPrefix = availableLangs.client.includes(pathPrefix) ? pathPrefix : '';
|
||||
return { returnTo, origin, pathPrefix };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the prefixed landing path.
|
||||
*
|
||||
* @param origin - The origin value.
|
||||
* @param pathPrefix - The pathPrefix value.
|
||||
* @returns The prefixed landing path.
|
||||
*/
|
||||
export function getPrefixedLandingPath(origin: string, pathPrefix?: string) {
|
||||
const redirectPathSegment = pathPrefix ? `/${pathPrefix}` : '';
|
||||
return `${origin}${redirectPathSegment}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the redirect parameters.
|
||||
*
|
||||
* @param req - A fastify Request.
|
||||
* @param _normalizeParams - The function to normalize the parameters.
|
||||
* @returns The redirect parameters.
|
||||
*/
|
||||
export function getRedirectParams(
|
||||
req: FastifyRequest,
|
||||
_normalizeParams = normalizeParams
|
||||
) {
|
||||
const url = req.headers['referer'];
|
||||
// since we do not always redirect the user back to the page they were on
|
||||
// we need client locale and origin to construct the redirect url.
|
||||
let returnUrl;
|
||||
try {
|
||||
returnUrl = new URL(url ? url : HOME_LOCATION);
|
||||
} catch (e) {
|
||||
returnUrl = new URL(HOME_LOCATION);
|
||||
}
|
||||
|
||||
const origin = returnUrl.origin;
|
||||
// if this is not one of the client languages, validation will convert
|
||||
// this to '' before it is used.
|
||||
const pathPrefix = returnUrl.pathname.split('/')[1] ?? '';
|
||||
return _normalizeParams({ returnTo: returnUrl.href, origin, pathPrefix });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the redirect base and return URL have the same path.
|
||||
*
|
||||
* @param redirectBase - The redirect base URL.
|
||||
* @param returnUrl - The return URL.
|
||||
* @returns A boolean indicating whether the paths are the same.
|
||||
*/
|
||||
export function haveSamePath(
|
||||
redirectBase: string | URL,
|
||||
returnUrl: string | URL
|
||||
) {
|
||||
const base = new URL(redirectBase);
|
||||
const url = new URL(returnUrl);
|
||||
return base.pathname === url.pathname;
|
||||
}
|
||||
Reference in New Issue
Block a user