diff --git a/.componentsignore b/.componentsignore index 6cd1bf43e..82822b3fe 100644 --- a/.componentsignore +++ b/.componentsignore @@ -3,6 +3,7 @@ "Adapter", "BaseActivityEmitter", "BaseHttpError", + "BaseRouterHandler", "BasicConditions", "BasicRepresentation", "ChangeMap", diff --git a/config/app/setup/optional.json b/config/app/setup/optional.json index e4e566528..3283198c3 100644 --- a/config/app/setup/optional.json +++ b/config/app/setup/optional.json @@ -15,7 +15,6 @@ "@type": "RouterHandler", "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, - "args_allowedMethods": [ "*" ], "args_allowedPathNames": [ "/setup" ], "args_handler": { "@id": "urn:solid-server:default:SetupParsingHandler" } } diff --git a/config/app/setup/required.json b/config/app/setup/required.json index 769d68a28..5fb113669 100644 --- a/config/app/setup/required.json +++ b/config/app/setup/required.json @@ -20,7 +20,6 @@ "@type": "RouterHandler", "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, - "args_allowedMethods": [ "*" ], "args_allowedPathNames": [ "/setup" ], "args_handler": { "@id": "urn:solid-server:default:SetupParsingHandler" } } diff --git a/config/http/handler/handlers/oidc.json b/config/http/handler/handlers/oidc.json index 43863c090..ef374ff05 100644 --- a/config/http/handler/handlers/oidc.json +++ b/config/http/handler/handlers/oidc.json @@ -7,7 +7,6 @@ "@type": "RouterHandler", "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, - "args_allowedMethods": [ "*" ], "args_allowedPathNames": [ "^/.oidc/.*", "^/\\.well-known/openid-configuration" ], "args_handler": { "@type": "OidcHttpHandler", diff --git a/config/identity/handler/default.json b/config/identity/handler/default.json index f3fb01568..240dbc839 100644 --- a/config/identity/handler/default.json +++ b/config/identity/handler/default.json @@ -13,7 +13,6 @@ "@type": "RouterHandler", "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, - "args_allowedMethods": [ "*" ], "args_allowedPathNames": [ "^/idp/.*" ], "args_handler": { "@id": "urn:solid-server:default:IdentityProviderParsingHandler" } }, diff --git a/config/ldp/authorization/webacl.json b/config/ldp/authorization/webacl.json index d0feb536e..2e5d099f2 100644 --- a/config/ldp/authorization/webacl.json +++ b/config/ldp/authorization/webacl.json @@ -60,7 +60,6 @@ "@type": "RouterHandler", "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, - "args_allowedMethods": [ "*" ], "args_allowedPathNames": [ "^/.*\\.acl$" ], "args_handler": { "@id": "urn:solid-server:default:LdpHandler" } } diff --git a/src/http/input/identifier/OriginalUrlExtractor.ts b/src/http/input/identifier/OriginalUrlExtractor.ts index aa94ebf09..6fcf6ab90 100644 --- a/src/http/input/identifier/OriginalUrlExtractor.ts +++ b/src/http/input/identifier/OriginalUrlExtractor.ts @@ -15,7 +15,7 @@ export interface OriginalUrlExtractorArgs { identifierStrategy: IdentifierStrategy; /** - * Specify wether the OriginalUrlExtractor should include the request query string. + * Specify whether the OriginalUrlExtractor should include the request query string. */ includeQueryString?: boolean; } diff --git a/src/index.ts b/src/index.ts index af97cf074..926f36fd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -305,6 +305,8 @@ export * from './server/middleware/WebSocketAdvertiser'; export * from './server/notifications/ActivityEmitter'; // Server/Util +export * from './server/util/BaseRouterHandler'; +export * from './server/util/OperationRouterHandler'; export * from './server/util/RedirectingHttpHandler'; export * from './server/util/RouterHandler'; diff --git a/src/server/util/BaseRouterHandler.ts b/src/server/util/BaseRouterHandler.ts new file mode 100644 index 000000000..684f45e48 --- /dev/null +++ b/src/server/util/BaseRouterHandler.ts @@ -0,0 +1,75 @@ +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; +import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; +import type { AsyncHandlerInput, AsyncHandlerOutput } from '../../util/handlers/AsyncHandler'; +import { AsyncHandler } from '../../util/handlers/AsyncHandler'; +import { trimTrailingSlashes } from '../../util/PathUtil'; + +export interface BaseRouterHandlerArgs> { + /** + * The base URL of the server. + * Not required if no value is provided for `allowedPathNames`. + */ + baseUrl?: string; + /** + * The handler to call if all checks pass. + */ + handler: T; + /** + * The allowed method(s). `*` can be used to indicate all methods are allowed. + * Default is `[ '*' ]`. + */ + allowedMethods?: string[]; + /** + * Regular expression(s) used to match the target URL. + * The base URl without trailing slash will be stripped of before applying the regular expressions, + * so the input will always start with a `/`. + * Default is `[ '.*' ]`. + */ + allowedPathNames?: string[]; +} + +/** + * Checks if a given method and path are satisfied and allows its handler to be executed if so. + * + * Implementations of this class should call `canHandleInput` in their `canHandle` call with the correct parameters. + * + * `canHandleInput` expects a ResourceIdentifier to indicate it expects the target to have been validated already. + */ +export abstract class BaseRouterHandler> + extends AsyncHandler, AsyncHandlerOutput> { + protected readonly baseUrlLength: number; + protected readonly handler: T; + protected readonly allowedMethods: string[]; + protected readonly allMethods: boolean; + protected readonly allowedPathNamesRegEx: RegExp[]; + + protected constructor(args: BaseRouterHandlerArgs) { + super(); + if (typeof args.allowedPathNames !== 'undefined' && typeof args.baseUrl !== 'string') { + throw new Error('A value for allowedPathNames requires baseUrl to be defined.'); + } + // Trimming trailing slash so regexes can start with `/` + this.baseUrlLength = trimTrailingSlashes(args.baseUrl ?? '').length; + this.handler = args.handler; + this.allowedMethods = args.allowedMethods ?? [ '*' ]; + this.allMethods = this.allowedMethods.includes('*'); + this.allowedPathNamesRegEx = (args.allowedPathNames ?? [ '.*' ]).map((pn): RegExp => new RegExp(pn, 'u')); + } + + protected async canHandleInput(input: AsyncHandlerInput, method: string, target: ResourceIdentifier): + Promise { + if (!this.allMethods && !this.allowedMethods.includes(method)) { + throw new MethodNotAllowedHttpError([ method ], `${method} is not allowed.`); + } + const pathName = target.path.slice(this.baseUrlLength); + if (!this.allowedPathNamesRegEx.some((regex): boolean => regex.test(pathName))) { + throw new NotFoundHttpError(`Cannot handle route ${pathName}`); + } + await this.handler.canHandle(input); + } + + public async handle(input: AsyncHandlerInput): Promise> { + return this.handler.handle(input); + } +} diff --git a/src/server/util/OperationRouterHandler.ts b/src/server/util/OperationRouterHandler.ts new file mode 100644 index 000000000..890693ede --- /dev/null +++ b/src/server/util/OperationRouterHandler.ts @@ -0,0 +1,16 @@ +import type { OperationHttpHandlerInput, OperationHttpHandler } from '../OperationHttpHandler'; +import type { BaseRouterHandlerArgs } from './BaseRouterHandler'; +import { BaseRouterHandler } from './BaseRouterHandler'; + +/** + * A {@link BaseRouterHandler} for an {@link OperationHttpHandler}. + */ +export class OperationRouterHandler extends BaseRouterHandler { + public constructor(args: BaseRouterHandlerArgs) { + super(args); + } + + public async canHandle(input: OperationHttpHandlerInput): Promise { + await super.canHandleInput(input, input.operation.method, input.operation.target); + } +} diff --git a/src/server/util/RouterHandler.ts b/src/server/util/RouterHandler.ts index aefc8e697..a7964474e 100644 --- a/src/server/util/RouterHandler.ts +++ b/src/server/util/RouterHandler.ts @@ -1,41 +1,23 @@ import type { TargetExtractor } from '../../http/input/identifier/TargetExtractor'; import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; -import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; -import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; -import { ensureTrailingSlash, getRelativeUrl } from '../../util/PathUtil'; -import type { HttpHandlerInput } from '../HttpHandler'; -import { HttpHandler } from '../HttpHandler'; +import type { HttpHandlerInput, HttpHandler } from '../HttpHandler'; +import { BaseRouterHandler } from './BaseRouterHandler'; +import type { BaseRouterHandlerArgs } from './BaseRouterHandler'; -export interface RouterHandlerArgs { - baseUrl: string; +export interface RouterHandlerArgs extends BaseRouterHandlerArgs { targetExtractor: TargetExtractor; - handler: HttpHandler; - allowedMethods: string[]; - allowedPathNames: string[]; } /** - * An HttpHandler that checks if a given method and path are satisfied - * and allows its handler to be executed if so. - * - * If `allowedMethods` contains '*' it will match all methods. + * A {@link BaseRouterHandler} for an {@link HttpHandler}. + * Uses a {@link TargetExtractor} to generate the target identifier. */ -export class RouterHandler extends HttpHandler { - private readonly baseUrl: string; +export class RouterHandler extends BaseRouterHandler { private readonly targetExtractor: TargetExtractor; - private readonly handler: HttpHandler; - private readonly allowedMethods: string[]; - private readonly allMethods: boolean; - private readonly allowedPathNamesRegEx: RegExp[]; public constructor(args: RouterHandlerArgs) { - super(); - this.baseUrl = ensureTrailingSlash(args.baseUrl); + super(args); this.targetExtractor = args.targetExtractor; - this.handler = args.handler; - this.allowedMethods = args.allowedMethods; - this.allMethods = args.allowedMethods.includes('*'); - this.allowedPathNamesRegEx = args.allowedPathNames.map((pn): RegExp => new RegExp(pn, 'u')); } public async canHandle(input: HttpHandlerInput): Promise { @@ -43,20 +25,7 @@ export class RouterHandler extends HttpHandler { if (!request.url) { throw new BadRequestHttpError('Cannot handle request without a url'); } - if (!request.method) { - throw new BadRequestHttpError('Cannot handle request without a method'); - } - if (!this.allMethods && !this.allowedMethods.includes(request.method)) { - throw new MethodNotAllowedHttpError([ request.method ], `${request.method} is not allowed.`); - } - const pathName = await getRelativeUrl(this.baseUrl, request, this.targetExtractor); - if (!this.allowedPathNamesRegEx.some((regex): boolean => regex.test(pathName))) { - throw new NotFoundHttpError(`Cannot handle route ${pathName}`); - } - await this.handler.canHandle(input); - } - - public async handle(input: HttpHandlerInput): Promise { - await this.handler.handle(input); + const target = await this.targetExtractor.handleSafe({ request }); + await super.canHandleInput(input, request.method ?? 'UNKNOWN', target); } } diff --git a/test/unit/server/util/BaseRouterHandler.test.ts b/test/unit/server/util/BaseRouterHandler.test.ts new file mode 100644 index 000000000..8329ad97c --- /dev/null +++ b/test/unit/server/util/BaseRouterHandler.test.ts @@ -0,0 +1,84 @@ +import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; +import type { BaseRouterHandlerArgs } from '../../../../src/server/util/BaseRouterHandler'; +import { BaseRouterHandler } from '../../../../src/server/util/BaseRouterHandler'; +import type { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler'; + +class SimpleRouterHandler extends BaseRouterHandler> { + public constructor(args: BaseRouterHandlerArgs>) { + super(args); + } + + public async canHandle(input: { method: string; target: ResourceIdentifier }): Promise { + await this.canHandleInput(input, input.method, input.target); + } +} + +describe('A BaseRouterHandler', (): void => { + const baseUrl = 'http://example.com/'; + const method = 'GET'; + const target: ResourceIdentifier = { path: 'http://example.com/foo' }; + let handler: jest.Mocked>; + let router: SimpleRouterHandler; + + beforeEach(async(): Promise => { + handler = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue('result'), + } as any; + + router = new SimpleRouterHandler({ + baseUrl, + handler, + allowedPathNames: [ '^/foo$', '^/bar$' ], + allowedMethods: [ 'GET', 'HEAD' ], + }); + }); + + it('requires the correct method.', async(): Promise => { + await expect(router.canHandle({ method: 'POST', target })).rejects.toThrow('POST is not allowed'); + }); + + it('requires the path to match a given regex.', async(): Promise => { + await expect(router.canHandle({ method, target: { path: 'http://example.com/baz' }})) + .rejects.toThrow('Cannot handle route /baz'); + }); + + it('accepts valid input.', async(): Promise => { + await expect(router.canHandle({ method, target })).resolves.toBeUndefined(); + }); + + it('requires the source handler to accept the input.', async(): Promise => { + handler.canHandle.mockRejectedValue(new Error('bad input')); + await expect(router.canHandle({ method, target })).rejects.toThrow('bad input'); + }); + + it('accepts all methods if no restrictions are defined.', async(): Promise => { + router = new SimpleRouterHandler({ + baseUrl, + handler, + allowedPathNames: [ '^/foo$', '^/bar$' ], + }); + await expect(router.canHandle({ method: 'POST', target })).resolves.toBeUndefined(); + }); + + it('accepts all paths if no restrictions are defined.', async(): Promise => { + router = new SimpleRouterHandler({ + handler, + allowedMethods: [ 'GET', 'HEAD' ], + }); + await expect(router.canHandle({ method, target: { path: 'http://example.com/baz' }})).resolves.toBeUndefined(); + }); + + it('requires a baseUrl input if there is a path restriction.', async(): Promise => { + expect((): any => new SimpleRouterHandler({ + handler, + allowedPathNames: [ '^/foo$', '^/bar$' ], + })).toThrow('A value for allowedPathNames requires baseUrl to be defined.'); + }); + + it('calls the source handler.', async(): Promise => { + await expect(router.handle({ method, target })).resolves.toBe('result'); + expect(handler.handle).toHaveBeenCalledTimes(1); + expect(handler.handle).toHaveBeenLastCalledWith({ method, target }); + }); +}); diff --git a/test/unit/server/util/OperationRouterHandler.test.ts b/test/unit/server/util/OperationRouterHandler.test.ts new file mode 100644 index 000000000..cbc80f708 --- /dev/null +++ b/test/unit/server/util/OperationRouterHandler.test.ts @@ -0,0 +1,44 @@ +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { HttpRequest } from '../../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../../src/server/HttpResponse'; +import type { OperationHttpHandler } from '../../../../src/server/OperationHttpHandler'; +import { OperationRouterHandler } from '../../../../src/server/util/OperationRouterHandler'; + +describe('An OperationRouterHandler', (): void => { + const request: HttpRequest = {} as any; + const response: HttpResponse = {} as any; + let operation: Operation; + let handler: jest.Mocked; + let router: OperationRouterHandler; + + beforeEach(async(): Promise => { + operation = { + method: 'GET', + target: { path: 'http://example.com/foo' }, + preferences: {}, + body: new BasicRepresentation(), + }; + + handler = { + canHandle: jest.fn(), + handle: jest.fn(), + } as any; + + router = new OperationRouterHandler({ + baseUrl: 'http://example.com/', + handler, + allowedPathNames: [ '^/foo$' ], + allowedMethods: [ 'GET' ], + }); + }); + + it('passes the operation values.', async(): Promise => { + await expect(router.canHandle({ operation, request, response })).resolves.toBeUndefined(); + operation.method = 'POST'; + await expect(router.canHandle({ operation, request, response })).rejects.toThrow('POST is not allowed.'); + operation.method = 'GET'; + operation.target = { path: 'http://example.com/wrong' }; + await expect(router.canHandle({ operation, request, response })).rejects.toThrow('Cannot handle route /wrong'); + }); +}); diff --git a/test/unit/server/util/RouterHandler.test.ts b/test/unit/server/util/RouterHandler.test.ts index c905efcae..ed9e6a3fb 100644 --- a/test/unit/server/util/RouterHandler.test.ts +++ b/test/unit/server/util/RouterHandler.test.ts @@ -1,131 +1,57 @@ -import { createRequest, createResponse } from 'node-mocks-http'; -import type { - AsyncHandler, - HttpHandlerInput, - HttpRequest, +import type { HttpRequest, HttpResponse, TargetExtractor, ResourceIdentifier, - RouterHandlerArgs, -} from '../../../../src'; -import { guardStream, joinUrl } from '../../../../src'; + HttpHandler } from '../../../../src'; +import { joinUrl } from '../../../../src'; import { RouterHandler } from '../../../../src/server/util/RouterHandler'; -import { StaticAsyncHandler } from '../../../util/StaticAsyncHandler'; describe('A RouterHandler', (): void => { - const baseUrl = 'http://test.com/foo/'; + const baseUrl = 'http://test.com/'; let targetExtractor: jest.Mocked; - let subHandler: AsyncHandler; - let genericRequest: HttpRequest; - let genericResponse: HttpResponse; - let genericInput: HttpHandlerInput; - let args: RouterHandlerArgs; + let request: HttpRequest; + const response: HttpResponse = {} as any; + let handler: jest.Mocked; + let router: RouterHandler; beforeEach((): void => { + request = { method: 'GET', url: '/test' } as any; + targetExtractor = { handleSafe: jest.fn(({ request: req }): ResourceIdentifier => ({ path: joinUrl(baseUrl, req.url!) })), } as any; - subHandler = new StaticAsyncHandler(true, undefined); + handler = { + canHandle: jest.fn(), + handle: jest.fn(), + } as any; - args = { + router = new RouterHandler({ baseUrl, targetExtractor, - handler: subHandler, - allowedMethods: [], - allowedPathNames: [], - }; - - genericRequest = guardStream(createRequest({ - url: '/test', - })); - genericResponse = createResponse() as HttpResponse; - genericInput = { - request: genericRequest, - response: genericResponse, - }; + handler, + allowedMethods: [ 'GET' ], + allowedPathNames: [ '^/test$' ], + }); }); - it('calls the sub handler when handle is called.', async(): Promise => { - args.allowedMethods = [ 'GET' ]; - args.allowedPathNames = [ '/test' ]; - const handler = new RouterHandler(args); - expect(await handler.handle(genericInput)).toBeUndefined(); + it('errors if there is no url.', async(): Promise => { + delete request.url; + await expect(router.canHandle({ request, response })) + .rejects.toThrow('Cannot handle request without a url'); }); - it('throws an error if the request does not have a url.', async(): Promise => { - args.allowedMethods = [ 'GET' ]; - args.allowedPathNames = [ '/test' ]; - const handler = new RouterHandler(args); - const request = guardStream(createRequest()); - await expect(handler.canHandle({ - request, - response: genericResponse, - })).rejects.toThrow('Cannot handle request without a url'); + it('passes the request method.', async(): Promise => { + await expect(router.canHandle({ request, response })).resolves.toBeUndefined(); + request.method = 'POST'; + await expect(router.canHandle({ request, response })).rejects.toThrow('POST is not allowed.'); + delete request.method; + await expect(router.canHandle({ request, response })).rejects.toThrow('UNKNOWN is not allowed.'); }); - it('throws an error if the request does not have a method.', async(): Promise => { - args.allowedMethods = [ 'GET' ]; - args.allowedPathNames = [ '/test' ]; - const handler = new RouterHandler(args); - const request = guardStream(createRequest({ - url: '/test', - })); - // @ts-expect-error manually set the method - request.method = undefined; - await expect(handler.canHandle({ - request, - response: genericResponse, - })).rejects.toThrow('Cannot handle request without a method'); - }); - - it('throws an error when there are no allowed methods or pathnames.', async(): Promise => { - args.allowedMethods = []; - args.allowedPathNames = []; - const handler = new RouterHandler(args); - await expect(handler.canHandle(genericInput)).rejects.toThrow('GET is not allowed.'); - }); - - it('throws an error when there are no allowed methods.', async(): Promise => { - args.allowedMethods = []; - args.allowedPathNames = [ '/test' ]; - const handler = new RouterHandler(args); - await expect(handler.canHandle(genericInput)).rejects.toThrow('GET is not allowed.'); - }); - - it('throws an error when there are no allowed pathnames.', async(): Promise => { - args.allowedMethods = [ 'GET' ]; - args.allowedPathNames = []; - const handler = new RouterHandler(args); - await expect(handler.canHandle(genericInput)).rejects.toThrow('Cannot handle route /test'); - }); - - it('throws an error if the RegEx string is not valid Regex.', async(): Promise => { - args.allowedMethods = [ 'GET' ]; - args.allowedPathNames = [ '[' ]; - expect((): RouterHandler => new RouterHandler(args)) - .toThrow('Invalid regular expression: /[/: Unterminated character class'); - }); - - it('throws an error if all else is successful, but the sub handler cannot handle.', async(): Promise => { - args.handler = new StaticAsyncHandler(false, undefined); - args.allowedMethods = [ 'GET' ]; - args.allowedPathNames = [ '/test' ]; - const handler = new RouterHandler(args); - await expect(handler.canHandle(genericInput)).rejects.toThrow('Not supported'); - }); - - it('does not throw an error if the sub handler is successful.', async(): Promise => { - args.allowedMethods = [ 'GET' ]; - args.allowedPathNames = [ '/test' ]; - const handler = new RouterHandler(args); - expect(await handler.canHandle(genericInput)).toBeUndefined(); - }); - - it('supports * for all methods.', async(): Promise => { - args.allowedMethods = [ '*' ]; - args.allowedPathNames = [ '/test' ]; - const handler = new RouterHandler(args); - expect(await handler.canHandle(genericInput)).toBeUndefined(); + it('generates a ResourceIdentifier based on the url.', async(): Promise => { + await expect(router.canHandle({ request, response })).resolves.toBeUndefined(); + request.url = '/wrongTest'; + await expect(router.canHandle({ request, response })).rejects.toThrow('Cannot handle route /wrongTest'); }); });