diff --git a/src/storage/accessors/FileDataAccessor.ts b/src/storage/accessors/FileDataAccessor.ts index 545a17a83..6dddda4c6 100644 --- a/src/storage/accessors/FileDataAccessor.ts +++ b/src/storage/accessors/FileDataAccessor.ts @@ -12,7 +12,7 @@ import { UnsupportedMediaTypeHttpError } from '../../util/errors/UnsupportedMedi import { guardStream } from '../../util/GuardedStream'; import type { Guarded } from '../../util/GuardedStream'; import { parseContentType } from '../../util/HeaderUtil'; -import { joinFilePath, isContainerIdentifier } from '../../util/PathUtil'; +import { joinFilePath, isContainerIdentifier, isContainerPath } from '../../util/PathUtil'; import { parseQuads, serializeQuads } from '../../util/QuadUtil'; import { addResourceMetadata, updateModifiedDate } from '../../util/ResourceUtil'; import { toLiteral, toNamedTerm } from '../../util/TermUtil'; @@ -159,8 +159,14 @@ export class FileDataAccessor implements DataAccessor { */ private async getFileMetadata(link: ResourceLink, stats: Stats): Promise { - return (await this.getBaseMetadata(link, stats, false)) - .set(CONTENT_TYPE_TERM, link.contentType); + const metadata = await this.getBaseMetadata(link, stats, false); + // If the resource is using an unsupported contentType, the original contentType was written to the metadata file. + // As a result, we should only set the contentType derived from the file path, + // when no previous metadata entry for contentType is present. + if (typeof metadata.contentType === 'undefined') { + metadata.set(CONTENT_TYPE_TERM, link.contentType); + } + return metadata; } /** @@ -188,7 +194,12 @@ export class FileDataAccessor implements DataAccessor { metadata.remove(RDF.terms.type, LDP.terms.Container); metadata.remove(RDF.terms.type, LDP.terms.BasicContainer); metadata.removeAll(DC.terms.modified); - metadata.removeAll(CONTENT_TYPE_TERM); + // When writing metadata for a document, only remove the content-type when dealing with a supported media type. + // A media type is supported if the FileIdentifierMapper can correctly store it. + // This allows restoring the appropriate content-type on data read (see getFileMetadata). + if (isContainerPath(link.filePath) || typeof link.contentType !== 'undefined') { + metadata.removeAll(CONTENT_TYPE_TERM); + } const quads = metadata.quads(); const metadataLink = await this.resourceMapper.mapUrlToFilePath(link.identifier, true); let wroteMetadata: boolean; diff --git a/src/storage/mapping/BaseFileIdentifierMapper.ts b/src/storage/mapping/BaseFileIdentifierMapper.ts index a48dfe570..a6f10ffa2 100644 --- a/src/storage/mapping/BaseFileIdentifierMapper.ts +++ b/src/storage/mapping/BaseFileIdentifierMapper.ts @@ -23,6 +23,8 @@ export class BaseFileIdentifierMapper implements FileIdentifierMapper { protected readonly logger = getLoggerFor(this); protected readonly baseRequestURI: string; protected readonly rootFilepath: string; + // Extension to use as a fallback when the media type is not supported (could be made configurable). + protected readonly unknownMediaTypeExtension = 'unknown'; public constructor(base: string, rootFilepath: string) { this.baseRequestURI = trimTrailingSlashes(base); @@ -85,7 +87,10 @@ export class BaseFileIdentifierMapper implements FileIdentifierMapper { */ protected async mapUrlToDocumentPath(identifier: ResourceIdentifier, filePath: string, contentType?: string): Promise { - contentType = await this.getContentTypeFromUrl(identifier, contentType); + // Don't try to get content-type from URL when the file path refers to a document with unknown media type. + if (!filePath.endsWith(`.${this.unknownMediaTypeExtension}`)) { + contentType = await this.getContentTypeFromUrl(identifier, contentType); + } this.logger.debug(`The path for ${identifier.path} is ${filePath}`); return { identifier, filePath, contentType, isMetadata: this.isMetadataPath(filePath) }; } diff --git a/src/storage/mapping/ExtensionBasedMapper.ts b/src/storage/mapping/ExtensionBasedMapper.ts index 6b3747a44..ba59acf82 100644 --- a/src/storage/mapping/ExtensionBasedMapper.ts +++ b/src/storage/mapping/ExtensionBasedMapper.ts @@ -64,10 +64,12 @@ export class ExtensionBasedMapper extends BaseFileIdentifierMapper { // If the extension of the identifier matches a different content-type than the one that is given, // we need to add a new extension to match the correct type. } else if (contentType !== await this.getContentTypeFromPath(filePath)) { - const extension: string = mime.extension(contentType) || this.customExtensions[contentType]; + let extension: string = mime.extension(contentType) || this.customExtensions[contentType]; if (!extension) { - this.logger.warn(`No extension found for ${contentType}`); - throw new NotImplementedHttpError(`Unsupported content type ${contentType}`); + // When no extension is found for the provided content-type, use a fallback extension. + extension = this.unknownMediaTypeExtension; + // Signal the fallback by setting the content-type to undefined in the output link. + contentType = undefined; } filePath += `$.${extension}`; } diff --git a/test/integration/FileBackendEncodedSlashHandling.test.ts b/test/integration/FileBackend.test.ts similarity index 80% rename from test/integration/FileBackendEncodedSlashHandling.test.ts rename to test/integration/FileBackend.test.ts index ad09a3992..e7f42ac8a 100644 --- a/test/integration/FileBackendEncodedSlashHandling.test.ts +++ b/test/integration/FileBackend.test.ts @@ -10,10 +10,10 @@ import { removeFolder, } from './Config'; -const port = getPort('FileBackendEncodedSlashHandling'); +const port = getPort('FileBackend'); const baseUrl = `http://localhost:${port}/`; -const rootFilePath = getTestFolder('file-backend-encoded-slash-handling'); +const rootFilePath = getTestFolder('file-backend'); describe('A server with a file backend storage', (): void => { let app: App; @@ -146,4 +146,35 @@ describe('A server with a file backend storage', (): void => { const check4 = await pathExists(`${rootFilePath}/bar%2Ffoo$.txt`); expect(check4).toBe(true); }); + + it('supports content types for which no extension mapping can be found (and falls back to using .unknown).', + async(): Promise => { + const url = `${baseUrl}test`; + const res = await fetch(url, { + method: 'PUT', + headers: { + 'content-type': 'unknown/some-type', + }, + body: 'abc', + }); + expect(res.status).toBe(201); + expect(res.headers.get('location')).toBe(`${baseUrl}test`); + + // Check if the document can be retrieved + const check1 = await fetch(`${baseUrl}test`, { + method: 'GET', + headers: { + accept: '*/*', + }, + }); + const body = await check1.text(); + expect(check1.status).toBe(200); + expect(body).toBe('abc'); + // The content-type should be unknown/some-type. + expect(check1.headers.get('content-type')).toBe('unknown/some-type'); + + // Check if the expected file was created + const check2 = await pathExists(`${rootFilePath}/test$.unknown`); + expect(check2).toBe(true); + }); }); diff --git a/test/unit/storage/mapping/ExtensionBasedMapper.test.ts b/test/unit/storage/mapping/ExtensionBasedMapper.test.ts index 51c65f598..389b3dfca 100644 --- a/test/unit/storage/mapping/ExtensionBasedMapper.test.ts +++ b/test/unit/storage/mapping/ExtensionBasedMapper.test.ts @@ -125,11 +125,16 @@ describe('An ExtensionBasedMapper', (): void => { }); }); - it('throws 501 if the given content-type is not recognized.', async(): Promise => { - const result = mapper.mapUrlToFilePath({ path: `${base}test.txt` }, false, 'fake/data'); - await expect(result).rejects.toThrow(NotImplementedHttpError); - await expect(result).rejects.toThrow('Unsupported content type fake/data'); - }); + it('falls back to custom extension for unknown types (for which no custom mapping exists).', + async(): Promise => { + const result = mapper.mapUrlToFilePath({ path: `${base}test` }, false, 'unknown/content-type'); + await expect(result).resolves.toEqual({ + identifier: { path: `${base}test` }, + filePath: `${rootFilepath}test$.unknown`, + contentType: undefined, + isMetadata: false, + }); + }); it('supports custom types.', async(): Promise => { const customMapper = new ExtensionBasedMapper(base, rootFilepath, { cstm: 'text/custom' }); diff --git a/test/util/Util.ts b/test/util/Util.ts index c6563b3ef..ff25bf333 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -8,7 +8,7 @@ const portNames = [ 'ContentNegotiation', 'DynamicPods', 'ExpiringDataCleanup', - 'FileBackendEncodedSlashHandling', + 'FileBackend', 'GlobalQuota', 'Identity', 'LpdHandlerWithAuth',