From 468e11d906e051a463d24ea21a7ad4db53dde5f5 Mon Sep 17 00:00:00 2001 From: Wouter Termont Date: Mon, 11 Apr 2022 09:49:36 +0200 Subject: [PATCH] feat: Add RedirectingHttpHandler * feat: redirect handler Signed-off-by: Wouter Termont * chore: rewrite reduction as loop Signed-off-by: Wouter Termont * chore: remove example import Signed-off-by: Wouter Termont * feat: add more redirect flavours Signed-off-by: Wouter Termont * chore: RedirectingHttpHandler with RedirectAllHttpHandler Signed-off-by: Wouter Termont * chore: replace RedirectAllHttpHandler with RedirectingHttpHandler * chore: revert 5956385 (chore: replace RedirectAllHttpHandler with RedirectingHttpHandler) This reverts commit 5956385c4180e8e8914b9199c4ed6ca8ae7d39fb. * docs: complete constructor params Signed-off-by: Wouter Termont --- config/http/handler/handlers/redirect.json | 20 ++++ src/index.ts | 1 + src/server/RedirectingHttpHandler.ts | 109 ++++++++++++++++++ src/util/errors/FoundHttpError.ts | 1 + src/util/errors/MovedPermanentlyHttpError.ts | 1 + src/util/errors/PermanentRedirectHttpError.ts | 16 +++ src/util/errors/SeeOtherHttpError.ts | 17 +++ src/util/errors/TemporaryRedirectHttpError.ts | 16 +++ .../server/RedirectingHttpHandler.test.ts | 97 ++++++++++++++++ .../util/errors/RedirectHttpError.test.ts | 6 + 10 files changed, 284 insertions(+) create mode 100644 config/http/handler/handlers/redirect.json create mode 100644 src/server/RedirectingHttpHandler.ts create mode 100644 src/util/errors/PermanentRedirectHttpError.ts create mode 100644 src/util/errors/SeeOtherHttpError.ts create mode 100644 src/util/errors/TemporaryRedirectHttpError.ts create mode 100644 test/unit/server/RedirectingHttpHandler.test.ts diff --git a/config/http/handler/handlers/redirect.json b/config/http/handler/handlers/redirect.json new file mode 100644 index 000000000..4a715487b --- /dev/null +++ b/config/http/handler/handlers/redirect.json @@ -0,0 +1,20 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Example handler to configure redirect patterns.", + "@id": "urn:solid-server:default:RedirectHandler", + "@type": "RedirectingHttpHandler", + "redirects": [ + { + "RedirectingHttpHandler:_redirects_key": "/from/(.*)", + "RedirectingHttpHandler:_redirects_value": "/to/$1" + } + ], + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, + "responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, + "statusCode": "303" + } + ] +} diff --git a/src/index.ts b/src/index.ts index 86f2cf068..c09603d59 100644 --- a/src/index.ts +++ b/src/index.ts @@ -258,6 +258,7 @@ export * from './server/HttpResponse'; export * from './server/HttpServerFactory'; export * from './server/OperationHttpHandler'; export * from './server/ParsingHttpHandler'; +export * from './server/RedirectingHttpHandler'; export * from './server/WebSocketHandler'; export * from './server/WebSocketServerFactory'; diff --git a/src/server/RedirectingHttpHandler.ts b/src/server/RedirectingHttpHandler.ts new file mode 100644 index 000000000..059157f8a --- /dev/null +++ b/src/server/RedirectingHttpHandler.ts @@ -0,0 +1,109 @@ +import type { TargetExtractor } from '../http/input/identifier/TargetExtractor'; +import { RedirectResponseDescription } from '../http/output/response/RedirectResponseDescription'; +import type { ResponseWriter } from '../http/output/ResponseWriter'; +import { getLoggerFor } from '../logging/LogUtil'; +import { FoundHttpError } from '../util/errors/FoundHttpError'; +import { MovedPermanentlyHttpError } from '../util/errors/MovedPermanentlyHttpError'; +import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; +import { PermanentRedirectHttpError } from '../util/errors/PermanentRedirectHttpError'; +import type { RedirectHttpError } from '../util/errors/RedirectHttpError'; +import { SeeOtherHttpError } from '../util/errors/SeeOtherHttpError'; +import { TemporaryRedirectHttpError } from '../util/errors/TemporaryRedirectHttpError'; +import { getRelativeUrl, joinUrl } from '../util/PathUtil'; +import type { HttpHandlerInput } from './HttpHandler'; +import { HttpHandler } from './HttpHandler'; +import type { HttpRequest } from './HttpRequest'; + +const redirectErrorFactories: Record<301 | 302 | 303 | 307 | 308, (location: string) => RedirectHttpError> = { + 301: (location: string): RedirectHttpError => new MovedPermanentlyHttpError(location), + 302: (location: string): RedirectHttpError => new FoundHttpError(location), + 303: (location: string): RedirectHttpError => new SeeOtherHttpError(location), + 307: (location: string): RedirectHttpError => new TemporaryRedirectHttpError(location), + 308: (location: string): RedirectHttpError => new PermanentRedirectHttpError(location), +}; + +/** + * Handler that redirects paths matching given patterns + * to their corresponding URL, substituting selected groups. + */ +export class RedirectingHttpHandler extends HttpHandler { + private readonly logger = getLoggerFor(this); + private readonly redirects: { + regex: RegExp; + redirectPattern: string; + }[]; + + /** + * Creates a handler for the provided redirects. + * @param redirects - A mapping between URL patterns. + * @param targetExtractor - To extract the target from the request. + * @param responseWriter - To write the redirect to the response. + * @param statusCode - Desired 30x redirection code (defaults to 308). + */ + public constructor( + redirects: Record, + private readonly baseUrl: string, + private readonly targetExtractor: TargetExtractor, + private readonly responseWriter: ResponseWriter, + private readonly statusCode: 301 | 302 | 303 | 307 | 308 = 308, + ) { + super(); + + // Create an array of (regexp, redirect) pairs + this.redirects = Object.keys(redirects).map( + (pattern): { regex: RegExp; redirectPattern: string } => ({ + regex: new RegExp(pattern, 'u'), + redirectPattern: redirects[pattern], + }), + ); + } + + public async canHandle({ request }: HttpHandlerInput): Promise { + // Try to find redirect for target URL + await this.findRedirect(request); + } + + public async handle({ request, response }: HttpHandlerInput): Promise { + // Try to find redirect for target URL + const redirect = await this.findRedirect(request); + + // Send redirect response + this.logger.info(`Redirecting ${request.url} to ${redirect}`); + const result = new RedirectResponseDescription(redirectErrorFactories[this.statusCode](redirect)); + await this.responseWriter.handleSafe({ response, result }); + } + + private async findRedirect(request: HttpRequest): Promise { + // Retrieve target relative to base URL + const target = await getRelativeUrl(this.baseUrl, request, this.targetExtractor); + + // Get groups and redirect of first matching pattern + let result; + for (const { regex, redirectPattern } of this.redirects) { + const match = regex.exec(target); + if (match) { + result = { match, redirectPattern }; + break; + } + } + + // Only return if a redirect is configured for the requested URL + if (!result) { + throw new NotImplementedHttpError(`No redirect configured for ${target}`); + } + + // Build redirect URL from regexp result + const { match, redirectPattern } = result; + const redirect = match.reduce( + (prev, param, index): string => prev.replace(`$${index}`, param), + redirectPattern, + ); + + // Don't redirect if target is already correct + if (redirect === target) { + throw new NotImplementedHttpError('Target is already correct.'); + } + + return /^(?:[a-z]+:)?\/\//ui.test(redirect) ? redirect : joinUrl(this.baseUrl, redirect); + } +} diff --git a/src/util/errors/FoundHttpError.ts b/src/util/errors/FoundHttpError.ts index 9e33035d1..2a8e0498e 100644 --- a/src/util/errors/FoundHttpError.ts +++ b/src/util/errors/FoundHttpError.ts @@ -3,6 +3,7 @@ import { RedirectHttpError } from './RedirectHttpError'; /** * Error used for resources that have been moved temporarily. + * Methods other than GET may or may not be changed to GET in subsequent requests. */ export class FoundHttpError extends RedirectHttpError { public constructor(location: string, message?: string, options?: HttpErrorOptions) { diff --git a/src/util/errors/MovedPermanentlyHttpError.ts b/src/util/errors/MovedPermanentlyHttpError.ts index 70f88f243..32e438b56 100644 --- a/src/util/errors/MovedPermanentlyHttpError.ts +++ b/src/util/errors/MovedPermanentlyHttpError.ts @@ -3,6 +3,7 @@ import { RedirectHttpError } from './RedirectHttpError'; /** * Error used for resources that have been moved permanently. + * Methods other than GET may or may not be changed to GET in subsequent requests. */ export class MovedPermanentlyHttpError extends RedirectHttpError { public constructor(location: string, message?: string, options?: HttpErrorOptions) { diff --git a/src/util/errors/PermanentRedirectHttpError.ts b/src/util/errors/PermanentRedirectHttpError.ts new file mode 100644 index 000000000..f9e809c65 --- /dev/null +++ b/src/util/errors/PermanentRedirectHttpError.ts @@ -0,0 +1,16 @@ +import type { HttpErrorOptions } from './HttpError'; +import { RedirectHttpError } from './RedirectHttpError'; + +/** + * Error used for resources that have been moved permanently. + * Method and body should not be changed in subsequent requests. + */ +export class PermanentRedirectHttpError extends RedirectHttpError { + public constructor(location: string, message?: string, options?: HttpErrorOptions) { + super(308, location, 'PermanentRedirectHttpError', message, options); + } + + public static isInstance(error: any): error is PermanentRedirectHttpError { + return RedirectHttpError.isInstance(error) && error.statusCode === 308; + } +} diff --git a/src/util/errors/SeeOtherHttpError.ts b/src/util/errors/SeeOtherHttpError.ts new file mode 100644 index 000000000..0fb6b8b88 --- /dev/null +++ b/src/util/errors/SeeOtherHttpError.ts @@ -0,0 +1,17 @@ +import type { HttpErrorOptions } from './HttpError'; +import { RedirectHttpError } from './RedirectHttpError'; + +/** + * Error used to redirect not to the requested resource itself, but to another page, + * for example a representation of a real-world object. + * The method used to display this redirected page is always GET. + */ +export class SeeOtherHttpError extends RedirectHttpError { + public constructor(location: string, message?: string, options?: HttpErrorOptions) { + super(303, location, 'SeeOtherHttpError', message, options); + } + + public static isInstance(error: any): error is SeeOtherHttpError { + return RedirectHttpError.isInstance(error) && error.statusCode === 303; + } +} diff --git a/src/util/errors/TemporaryRedirectHttpError.ts b/src/util/errors/TemporaryRedirectHttpError.ts new file mode 100644 index 000000000..559f60fe4 --- /dev/null +++ b/src/util/errors/TemporaryRedirectHttpError.ts @@ -0,0 +1,16 @@ +import type { HttpErrorOptions } from './HttpError'; +import { RedirectHttpError } from './RedirectHttpError'; + +/** + * Error used for resources that have been moved temporarily. + * Method and body should not be changed in subsequent requests. + */ +export class TemporaryRedirectHttpError extends RedirectHttpError { + public constructor(location: string, message?: string, options?: HttpErrorOptions) { + super(307, location, 'TemporaryRedirectHttpError', message, options); + } + + public static isInstance(error: any): error is TemporaryRedirectHttpError { + return RedirectHttpError.isInstance(error) && error.statusCode === 307; + } +} diff --git a/test/unit/server/RedirectingHttpHandler.test.ts b/test/unit/server/RedirectingHttpHandler.test.ts new file mode 100644 index 000000000..585bf7e28 --- /dev/null +++ b/test/unit/server/RedirectingHttpHandler.test.ts @@ -0,0 +1,97 @@ +import type { TargetExtractor } from '../../../src/http/input/identifier/TargetExtractor'; +import type { ResponseWriter } from '../../../src/http/output/ResponseWriter'; +import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; +import type { HttpRequest } from '../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../src/server/HttpResponse'; +import { RedirectingHttpHandler } from '../../../src/server/RedirectingHttpHandler'; +import { joinUrl } from '../../../src/util/PathUtil'; +import { SOLID_HTTP } from '../../../src/util/Vocabularies'; + +describe('A RedirectingHttpHandler', (): void => { + const baseUrl = 'http://test.com/'; + const request = { method: 'GET' } as HttpRequest; + const response = {} as HttpResponse; + let targetExtractor: jest.Mocked; + let responseWriter: jest.Mocked; + let handler: RedirectingHttpHandler; + + beforeEach(async(): Promise => { + targetExtractor = { + handleSafe: jest.fn(({ request: req }): ResourceIdentifier => ({ path: joinUrl(baseUrl, req.url!) })), + } as any; + + responseWriter = { handleSafe: jest.fn() } as any; + + handler = new RedirectingHttpHandler({ + '/one': '/two', + '/from/(.*)': 'http://to/t$1', + '/f([aeiou]+)/b([aeiou]+)r': '/f$2/b$1r', + '/s(.)me': '/s$1me', + }, baseUrl, targetExtractor, responseWriter); + }); + + afterEach(jest.clearAllMocks); + + it('does not handle requests without URL.', async(): Promise => { + await expect(handler.canHandle({ request, response })) + .rejects.toThrow('Url must be a string. Received undefined'); + await expect(handler.handle({ request, response })) + .rejects.toThrow('Url must be a string. Received undefined'); + }); + + it('does not handle requests with unconfigured URLs.', async(): Promise => { + request.url = '/other'; + await expect(handler.canHandle({ request, response })) + .rejects.toThrow('No redirect configured for /other'); + await expect(handler.handle({ request, response })) + .rejects.toThrow('No redirect configured for /other'); + }); + + it('does not handle requests redirecting to their own target URL.', async(): Promise => { + request.url = '/same'; + await expect(handler.canHandle({ request, response })) + .rejects.toThrow('Target is already correct.'); + await expect(handler.handle({ request, response })) + .rejects.toThrow('Target is already correct.'); + }); + + it('handles requests to a known URL.', async(): Promise => { + request.url = '/one'; + + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); + expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ + response, + result: expect.objectContaining({ statusCode: 308 }), + }); + const { metadata } = responseWriter.handleSafe.mock.calls[0][0].result; + expect(metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(joinUrl(baseUrl, '/two')); + }); + + it('handles correctly substitutes group patterns.', async(): Promise => { + request.url = '/fa/boor'; + + await handler.handle({ request, response }); + const { metadata } = responseWriter.handleSafe.mock.calls[0][0].result; + expect(metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(joinUrl(baseUrl, '/foo/bar')); + }); + + it('redirects to an absolute url if provided.', async(): Promise => { + request.url = '/from/here'; + + await handler.handle({ request, response }); + const { metadata } = responseWriter.handleSafe.mock.calls[0][0].result; + expect(metadata?.get(SOLID_HTTP.terms.location)?.value).toBe('http://to/there'); + }); + + it.each([ 301, 302, 303, 307, 308 ])('redirects with the provided status code: %i.', async(code): Promise => { + request.url = '/one'; + (handler as any).statusCode = code; + + await handler.handle({ request, response }); + expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ + response, + result: expect.objectContaining({ statusCode: code }), + }); + }); +}); diff --git a/test/unit/util/errors/RedirectHttpError.test.ts b/test/unit/util/errors/RedirectHttpError.test.ts index 5536c86fc..5011e7bff 100644 --- a/test/unit/util/errors/RedirectHttpError.test.ts +++ b/test/unit/util/errors/RedirectHttpError.test.ts @@ -1,7 +1,10 @@ import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; import type { HttpErrorOptions } from '../../../../src/util/errors/HttpError'; import { MovedPermanentlyHttpError } from '../../../../src/util/errors/MovedPermanentlyHttpError'; +import { PermanentRedirectHttpError } from '../../../../src/util/errors/PermanentRedirectHttpError'; import { RedirectHttpError } from '../../../../src/util/errors/RedirectHttpError'; +import { SeeOtherHttpError } from '../../../../src/util/errors/SeeOtherHttpError'; +import { TemporaryRedirectHttpError } from '../../../../src/util/errors/TemporaryRedirectHttpError'; class FixedRedirectHttpError extends RedirectHttpError { public constructor(location: string, message?: string, options?: HttpErrorOptions) { @@ -13,6 +16,9 @@ describe('RedirectHttpError', (): void => { const errors: [string, number, typeof FixedRedirectHttpError][] = [ [ 'MovedPermanentlyHttpError', 301, MovedPermanentlyHttpError ], [ 'FoundHttpError', 302, FoundHttpError ], + [ 'SeeOtherHttpError', 303, SeeOtherHttpError ], + [ 'TemporaryRedirectHttpError', 307, TemporaryRedirectHttpError ], + [ 'PermanentRedirectHttpError', 308, PermanentRedirectHttpError ], ]; describe.each(errors)('%s', (name, statusCode, constructor): void => {