From 4ac0167c8d2b25a5bc5169617f04f2f9f3eece88 Mon Sep 17 00:00:00 2001 From: Ruben Taelman Date: Tue, 19 Jan 2021 10:14:52 +0100 Subject: [PATCH] Add optional path and url suffixes to FixedContentTypeMapper --- src/storage/mapping/FixedContentTypeMapper.ts | 57 ++- .../mapping/FixedContentTypeMapper.test.ts | 326 ++++++++++++++---- 2 files changed, 319 insertions(+), 64 deletions(-) diff --git a/src/storage/mapping/FixedContentTypeMapper.ts b/src/storage/mapping/FixedContentTypeMapper.ts index 3d5cc8994..223336d02 100644 --- a/src/storage/mapping/FixedContentTypeMapper.ts +++ b/src/storage/mapping/FixedContentTypeMapper.ts @@ -1,13 +1,37 @@ import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; +import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { BaseFileIdentifierMapper } from './BaseFileIdentifierMapper'; +import type { ResourceLink } from './FileIdentifierMapper'; +/** + * A mapper that always returns a fixed content type for files. + */ export class FixedContentTypeMapper extends BaseFileIdentifierMapper { protected readonly contentType: string; + protected readonly pathSuffix: string; + protected readonly urlSuffix: string; - public constructor(base: string, rootFilepath: string, contentType: string) { + /** + * @param base - Base URL. + * @param rootFilepath - Base file path. + * @param contentType - Fixed content type that will be used for all resources. + * @param pathSuffix - An optional suffix that will be appended to all file paths. + * Requested file paths without this suffix will be rejected. + * @param urlSuffix - An optional suffix that will be appended to all URL. + * Requested URLs without this suffix will be rejected. + */ + public constructor( + base: string, + rootFilepath: string, + contentType: string, + pathSuffix = '', + urlSuffix = '', + ) { super(base, rootFilepath); this.contentType = contentType; + this.pathSuffix = pathSuffix; + this.urlSuffix = urlSuffix; } protected async getContentTypeFromUrl(identifier: ResourceIdentifier, contentType?: string): Promise { @@ -21,4 +45,35 @@ export class FixedContentTypeMapper extends BaseFileIdentifierMapper { protected async getContentTypeFromPath(): Promise { return this.contentType; } + + public async mapUrlToDocumentPath(identifier: ResourceIdentifier, filePath: string, contentType?: string): + Promise { + // Handle URL suffix + if (this.urlSuffix) { + if (filePath.endsWith(this.urlSuffix)) { + filePath = filePath.slice(0, -this.urlSuffix.length); + } else { + this.logger.warn(`Trying to access URL ${filePath} outside without required suffix ${this.urlSuffix}`); + throw new NotFoundHttpError( + `Trying to access URL ${filePath} outside without required suffix ${this.urlSuffix}`, + ); + } + } + + return super.mapUrlToDocumentPath(identifier, filePath + this.pathSuffix, contentType); + } + + protected async getDocumentUrl(relative: string): Promise { + // Handle path suffix + if (this.pathSuffix) { + if (relative.endsWith(this.pathSuffix)) { + relative = relative.slice(0, -this.pathSuffix.length); + } else { + this.logger.warn(`Trying to access file ${relative} outside without required suffix ${this.pathSuffix}`); + throw new NotFoundHttpError(`File ${relative} is not part of the file storage at ${this.rootFilepath}`); + } + } + + return super.getDocumentUrl(relative + this.urlSuffix); + } } diff --git a/test/unit/storage/mapping/FixedContentTypeMapper.test.ts b/test/unit/storage/mapping/FixedContentTypeMapper.test.ts index 89d0e209d..985b3264b 100644 --- a/test/unit/storage/mapping/FixedContentTypeMapper.test.ts +++ b/test/unit/storage/mapping/FixedContentTypeMapper.test.ts @@ -8,89 +8,289 @@ jest.mock('fs'); describe('An FixedContentTypeMapper', (): void => { const base = 'http://test.com/'; const rootFilepath = 'uploads/'; - const mapper = new FixedContentTypeMapper(base, rootFilepath, 'text/turtle'); + describe('without suffixes', (): void => { + const mapper = new FixedContentTypeMapper(base, rootFilepath, 'text/turtle'); - describe('mapUrlToFilePath', (): void => { - it('throws 404 if the input path does not contain the base.', async(): Promise => { - await expect(mapper.mapUrlToFilePath({ path: 'invalid' })).rejects.toThrow(NotFoundHttpError); - }); + describe('mapUrlToFilePath', (): void => { + it('throws 404 if the input path does not contain the base.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: 'invalid' })).rejects.toThrow(NotFoundHttpError); + }); - it('throws 404 if the relative path does not start with a slash.', async(): Promise => { - await expect(mapper.mapUrlToFilePath({ path: `${trimTrailingSlashes(base)}test` })) - .rejects.toThrow(new BadRequestHttpError('URL needs a / after the base')); - }); + it('throws 404 if the relative path does not start with a slash.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${trimTrailingSlashes(base)}test` })) + .rejects.toThrow(new BadRequestHttpError('URL needs a / after the base')); + }); - it('throws 400 if the input path contains relative parts.', async(): Promise => { - await expect(mapper.mapUrlToFilePath({ path: `${base}test/../test2` })) - .rejects.toThrow(new BadRequestHttpError('Disallowed /.. segment in URL')); - }); + it('throws 400 if the input path contains relative parts.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}test/../test2` })) + .rejects.toThrow(new BadRequestHttpError('Disallowed /.. segment in URL')); + }); - it('returns the corresponding file path for container identifiers.', async(): Promise => { - await expect(mapper.mapUrlToFilePath({ path: `${base}container/` })).resolves.toEqual({ - identifier: { path: `${base}container/` }, - filePath: `${rootFilepath}container/`, + it('returns the corresponding file path for container identifiers.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}container/` })).resolves.toEqual({ + identifier: { path: `${base}container/` }, + filePath: `${rootFilepath}container/`, + }); + }); + + it('always returns the configured content-type.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}test` })).resolves.toEqual({ + identifier: { path: `${base}test` }, + filePath: `${rootFilepath}test`, + contentType: 'text/turtle', + }); + await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` })).resolves.toEqual({ + identifier: { path: `${base}test.ttl` }, + filePath: `${rootFilepath}test.ttl`, + contentType: 'text/turtle', + }); + await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` })).resolves.toEqual({ + identifier: { path: `${base}test.txt` }, + filePath: `${rootFilepath}test.txt`, + contentType: 'text/turtle', + }); + }); + + it('generates a file path if supported content-type was provided.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, 'text/turtle')).resolves.toEqual({ + identifier: { path: `${base}test.ttl` }, + filePath: `${rootFilepath}test.ttl`, + contentType: 'text/turtle', + }); + }); + + it('throws 400 if the given content-type is not supported.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, 'application/n-quads')).rejects + .toThrow( + new BadRequestHttpError(`Unsupported content type application/n-quads, only text/turtle is allowed`), + ); }); }); - it('always returns the configured content-type.', async(): Promise => { - await expect(mapper.mapUrlToFilePath({ path: `${base}test` })).resolves.toEqual({ - identifier: { path: `${base}test` }, - filePath: `${rootFilepath}test`, - contentType: 'text/turtle', + describe('mapFilePathToUrl', (): void => { + it('throws an error if the input path does not contain the root file path.', async(): Promise => { + await expect(mapper.mapFilePathToUrl('invalid', true)).rejects.toThrow(Error); }); - await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` })).resolves.toEqual({ - identifier: { path: `${base}test.ttl` }, - filePath: `${rootFilepath}test.ttl`, - contentType: 'text/turtle', - }); - await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` })).resolves.toEqual({ - identifier: { path: `${base}test.txt` }, - filePath: `${rootFilepath}test.txt`, - contentType: 'text/turtle', - }); - }); - it('generates a file path if supported content-type was provided.', async(): Promise => { - await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, 'text/turtle')).resolves.toEqual({ - identifier: { path: `${base}test.ttl` }, - filePath: `${rootFilepath}test.ttl`, - contentType: 'text/turtle', + it('returns a generated identifier for directories.', async(): Promise => { + await expect(mapper.mapFilePathToUrl(`${rootFilepath}container/`, true)).resolves.toEqual({ + identifier: { path: `${base}container/` }, + filePath: `${rootFilepath}container/`, + }); }); - }); - it('throws 400 if the given content-type is not supported.', async(): Promise => { - await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, 'application/n-quads')).rejects - .toThrow(new BadRequestHttpError(`Unsupported content type application/n-quads, only text/turtle is allowed`)); + it('returns files with the configured content-type.', async(): Promise => { + await expect(mapper.mapFilePathToUrl(`${rootFilepath}test`, false)).resolves.toEqual({ + identifier: { path: `${base}test` }, + filePath: `${rootFilepath}test`, + contentType: 'text/turtle', + }); + await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.ttl`, false)).resolves.toEqual({ + identifier: { path: `${base}test.ttl` }, + filePath: `${rootFilepath}test.ttl`, + contentType: 'text/turtle', + }); + await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.txt`, false)).resolves.toEqual({ + identifier: { path: `${base}test.txt` }, + filePath: `${rootFilepath}test.txt`, + contentType: 'text/turtle', + }); + }); }); }); - describe('mapFilePathToUrl', (): void => { - it('throws an error if the input path does not contain the root file path.', async(): Promise => { - await expect(mapper.mapFilePathToUrl('invalid', true)).rejects.toThrow(Error); - }); + describe('with path suffix', (): void => { + // Internally, everything uses the .ttl extension, but it's exposed without. + const mapper = new FixedContentTypeMapper(base, rootFilepath, 'text/turtle', '.ttl'); - it('returns a generated identifier for directories.', async(): Promise => { - await expect(mapper.mapFilePathToUrl(`${rootFilepath}container/`, true)).resolves.toEqual({ - identifier: { path: `${base}container/` }, - filePath: `${rootFilepath}container/`, + describe('mapUrlToFilePath', (): void => { + it('returns the corresponding file path for container identifiers.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}container/` })).resolves.toEqual({ + identifier: { path: `${base}container/` }, + filePath: `${rootFilepath}container/`, + }); + }); + + it('always returns the configured content-type.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}test` })).resolves.toEqual({ + identifier: { path: `${base}test` }, + filePath: `${rootFilepath}test.ttl`, + contentType: 'text/turtle', + }); + await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` })).resolves.toEqual({ + identifier: { path: `${base}test.ttl` }, + filePath: `${rootFilepath}test.ttl.ttl`, + contentType: 'text/turtle', + }); + await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt` })).resolves.toEqual({ + identifier: { path: `${base}test.txt` }, + filePath: `${rootFilepath}test.txt.ttl`, + contentType: 'text/turtle', + }); + }); + + it('generates a file path if supported content-type was provided.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, 'text/turtle')).resolves.toEqual({ + identifier: { path: `${base}test.ttl` }, + filePath: `${rootFilepath}test.ttl.ttl`, + contentType: 'text/turtle', + }); + }); + + it('throws 400 if the given content-type is not supported.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, 'application/n-quads')).rejects + .toThrow( + new BadRequestHttpError(`Unsupported content type application/n-quads, only text/turtle is allowed`), + ); }); }); - it('returns files with the configured content-type.', async(): Promise => { - await expect(mapper.mapFilePathToUrl(`${rootFilepath}test`, false)).resolves.toEqual({ - identifier: { path: `${base}test` }, - filePath: `${rootFilepath}test`, - contentType: 'text/turtle', + describe('mapFilePathToUrl', (): void => { + it('returns a generated identifier for directories.', async(): Promise => { + await expect(mapper.mapFilePathToUrl(`${rootFilepath}container/`, true)).resolves.toEqual({ + identifier: { path: `${base}container/` }, + filePath: `${rootFilepath}container/`, + }); }); - await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.ttl`, false)).resolves.toEqual({ - identifier: { path: `${base}test.ttl` }, - filePath: `${rootFilepath}test.ttl`, - contentType: 'text/turtle', + + it('returns files with the configured content-type.', async(): Promise => { + await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.ttl`, false)).resolves.toEqual({ + identifier: { path: `${base}test` }, + filePath: `${rootFilepath}test.ttl`, + contentType: 'text/turtle', + }); }); - await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.txt`, false)).resolves.toEqual({ - identifier: { path: `${base}test.txt` }, - filePath: `${rootFilepath}test.txt`, - contentType: 'text/turtle', + + it('throws 404 if the input path does not end with the suffix.', async(): Promise => { + await expect(mapper.mapFilePathToUrl(`${rootFilepath}test`, false)).rejects.toThrow(NotFoundHttpError); + await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.txt`, false)).rejects.toThrow(NotFoundHttpError); + }); + }); + }); + + describe('with url suffix', (): void => { + // Internally, no extensions are used, but everything is exposed with the .ttl extension + const mapper = new FixedContentTypeMapper(base, rootFilepath, 'text/turtle', undefined, '.ttl'); + + describe('mapUrlToFilePath', (): void => { + it('returns the corresponding file path for container identifiers.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}container/` })).resolves.toEqual({ + identifier: { path: `${base}container/` }, + filePath: `${rootFilepath}container/`, + }); + }); + + it('always returns the configured content-type.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` })).resolves.toEqual({ + identifier: { path: `${base}test.ttl` }, + filePath: `${rootFilepath}test`, + contentType: 'text/turtle', + }); + await expect(mapper.mapUrlToFilePath({ path: `${base}test.txt.ttl` })).resolves.toEqual({ + identifier: { path: `${base}test.txt.ttl` }, + filePath: `${rootFilepath}test.txt`, + contentType: 'text/turtle', + }); + }); + + it('generates a file path if supported content-type was provided.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, 'text/turtle')).resolves.toEqual({ + identifier: { path: `${base}test.ttl` }, + filePath: `${rootFilepath}test`, + contentType: 'text/turtle', + }); + }); + + it('throws 404 if the url does not end with the suffix.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}test.nq` }, 'text/turtle')).rejects + .toThrow(NotFoundHttpError); + await expect(mapper.mapUrlToFilePath({ path: `${base}test` }, 'text/turtle')).rejects + .toThrow(NotFoundHttpError); + }); + + it('throws 400 if the given content-type is not supported.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` }, 'application/n-quads')).rejects + .toThrow( + new BadRequestHttpError(`Unsupported content type application/n-quads, only text/turtle is allowed`), + ); + }); + }); + + describe('mapFilePathToUrl', (): void => { + it('returns a generated identifier for directories.', async(): Promise => { + await expect(mapper.mapFilePathToUrl(`${rootFilepath}container/`, true)).resolves.toEqual({ + identifier: { path: `${base}container/` }, + filePath: `${rootFilepath}container/`, + }); + }); + + it('returns files with the configured content-type.', async(): Promise => { + await expect(mapper.mapFilePathToUrl(`${rootFilepath}test`, false)).resolves.toEqual({ + identifier: { path: `${base}test.ttl` }, + filePath: `${rootFilepath}test`, + contentType: 'text/turtle', + }); + await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.ttl`, false)).resolves.toEqual({ + identifier: { path: `${base}test.ttl.ttl` }, + filePath: `${rootFilepath}test.ttl`, + contentType: 'text/turtle', + }); + await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.txt`, false)).resolves.toEqual({ + identifier: { path: `${base}test.txt.ttl` }, + filePath: `${rootFilepath}test.txt`, + contentType: 'text/turtle', + }); + }); + }); + }); + + describe('with path and url suffix', (): void => { + // Internally, everything uses the .nq extension, but it's exposed with the .ttl extension. + const mapper = new FixedContentTypeMapper(base, rootFilepath, 'text/turtle', '.nq', '.ttl'); + + describe('mapUrlToFilePath', (): void => { + it('returns the corresponding file path for container identifiers.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}container/` })).resolves.toEqual({ + identifier: { path: `${base}container/` }, + filePath: `${rootFilepath}container/`, + }); + }); + + it('always returns the configured content-type.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}test.ttl` })).resolves.toEqual({ + identifier: { path: `${base}test.ttl` }, + filePath: `${rootFilepath}test.nq`, + contentType: 'text/turtle', + }); + }); + + it('throws 404 if the url does not end with the suffix.', async(): Promise => { + await expect(mapper.mapUrlToFilePath({ path: `${base}test.nq` }, 'text/turtle')).rejects + .toThrow(NotFoundHttpError); + await expect(mapper.mapUrlToFilePath({ path: `${base}test` }, 'text/turtle')).rejects + .toThrow(NotFoundHttpError); + }); + }); + + describe('mapFilePathToUrl', (): void => { + it('returns a generated identifier for directories.', async(): Promise => { + await expect(mapper.mapFilePathToUrl(`${rootFilepath}container/`, true)).resolves.toEqual({ + identifier: { path: `${base}container/` }, + filePath: `${rootFilepath}container/`, + }); + }); + + it('returns files with the configured content-type.', async(): Promise => { + await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.nq`, false)).resolves.toEqual({ + identifier: { path: `${base}test.ttl` }, + filePath: `${rootFilepath}test.nq`, + contentType: 'text/turtle', + }); + }); + + it('throws 404 if the input path does not end with the suffix.', async(): Promise => { + await expect(mapper.mapFilePathToUrl(`${rootFilepath}test`, false)).rejects.toThrow(NotFoundHttpError); + await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.ttl`, false)).rejects.toThrow(NotFoundHttpError); }); }); });