diff --git a/config/util/identifiers/subdomain.json b/config/util/identifiers/subdomain.json index ac9fbba18d..a8e264f103 100644 --- a/config/util/identifiers/subdomain.json +++ b/config/util/identifiers/subdomain.json @@ -19,9 +19,7 @@ "@type": "SubdomainExtensionBasedMapper", "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, "rootFilepath": { "@id": "urn:solid-server:default:variable:rootFilePath" }, - "baseSubdomain": "www", - "overrideTypes_acl": "text/turtle", - "overrideTypes_meta": "text/turtle" + "baseSubdomain": "www" } ] } diff --git a/config/util/identifiers/suffix.json b/config/util/identifiers/suffix.json index 917a9e3f06..c60b37969a 100644 --- a/config/util/identifiers/suffix.json +++ b/config/util/identifiers/suffix.json @@ -18,9 +18,7 @@ "@id": "urn:solid-server:default:FileIdentifierMapper", "@type": "ExtensionBasedMapper", "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "rootFilepath": { "@id": "urn:solid-server:default:variable:rootFilePath" }, - "overrideTypes_acl": "text/turtle", - "overrideTypes_meta": "text/turtle" + "rootFilepath": { "@id": "urn:solid-server:default:variable:rootFilePath" } } ] } diff --git a/src/storage/mapping/ExtensionBasedMapper.ts b/src/storage/mapping/ExtensionBasedMapper.ts index c010274918..e775ca6404 100644 --- a/src/storage/mapping/ExtensionBasedMapper.ts +++ b/src/storage/mapping/ExtensionBasedMapper.ts @@ -1,18 +1,40 @@ import { promises as fsPromises } from 'fs'; import * as mime from 'mime-types'; import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; -import { TEXT_TURTLE } from '../../util/ContentTypes'; +import { DEFAULT_CUSTOM_TYPES } from '../../util/ContentTypes'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { joinFilePath, getExtension } from '../../util/PathUtil'; import { BaseFileIdentifierMapper } from './BaseFileIdentifierMapper'; import type { FileIdentifierMapperFactory, ResourceLink } from './FileIdentifierMapper'; +/** + * Supports the behaviour described in https://www.w3.org/DesignIssues/HTTPFilenameMapping.html + * Determines content-type based on the file extension. + * In case an identifier does not end on an extension matching its content-type, + * the corresponding file will be appended with the correct extension, preceded by $. + */ export class ExtensionBasedMapper extends BaseFileIdentifierMapper { - private readonly types: Record; + private readonly customTypes: Record; + private readonly customExtensions: Record; - public constructor(base: string, rootFilepath: string, overrideTypes = { acl: TEXT_TURTLE, meta: TEXT_TURTLE }) { + public constructor( + base: string, + rootFilepath: string, + customTypes?: Record, + ) { super(base, rootFilepath); - this.types = { ...mime.types, ...overrideTypes }; + + // Workaround for https://github.com/LinkedSoftwareDependencies/Components.js/issues/20 + if (!customTypes || Object.keys(customTypes).length === 0) { + this.customTypes = DEFAULT_CUSTOM_TYPES; + } else { + this.customTypes = customTypes; + } + + this.customExtensions = {}; + for (const [ extension, contentType ] of Object.entries(this.customTypes)) { + this.customExtensions[contentType] = extension; + } } protected async mapUrlToDocumentPath(identifier: ResourceIdentifier, filePath: string, contentType?: string): @@ -42,7 +64,7 @@ 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 = mime.extension(contentType); + const 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}`); @@ -57,8 +79,10 @@ export class ExtensionBasedMapper extends BaseFileIdentifierMapper { } protected async getContentTypeFromPath(filePath: string): Promise { - return this.types[getExtension(filePath).toLowerCase()] || - super.getContentTypeFromPath(filePath); + const extension = getExtension(filePath).toLowerCase(); + return mime.lookup(extension) || + this.customTypes[extension] || + await super.getContentTypeFromPath(filePath); } /** diff --git a/src/storage/mapping/SubdomainExtensionBasedMapper.ts b/src/storage/mapping/SubdomainExtensionBasedMapper.ts index 8691ca396b..3dab81ef65 100644 --- a/src/storage/mapping/SubdomainExtensionBasedMapper.ts +++ b/src/storage/mapping/SubdomainExtensionBasedMapper.ts @@ -1,6 +1,5 @@ import { toASCII, toUnicode } from 'punycode/'; import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; -import { TEXT_TURTLE } from '../../util/ContentTypes'; import { ForbiddenHttpError } from '../../util/errors/ForbiddenHttpError'; import { InternalServerError } from '../../util/errors/InternalServerError'; import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; @@ -36,8 +35,8 @@ export class SubdomainExtensionBasedMapper extends ExtensionBasedMapper { private readonly baseParts: { scheme: string; rest: string }; public constructor(base: string, rootFilepath: string, baseSubdomain = 'www', - overrideTypes = { acl: TEXT_TURTLE, meta: TEXT_TURTLE }) { - super(base, rootFilepath, overrideTypes); + customTypes?: Record) { + super(base, rootFilepath, customTypes); this.baseSubdomain = baseSubdomain; this.regex = createSubdomainRegexp(ensureTrailingSlash(base)); this.baseParts = extractScheme(ensureTrailingSlash(base)); diff --git a/src/util/ContentTypes.ts b/src/util/ContentTypes.ts index 1d0bce73dd..2fecb765e3 100644 --- a/src/util/ContentTypes.ts +++ b/src/util/ContentTypes.ts @@ -2,6 +2,7 @@ export const APPLICATION_JSON = 'application/json'; export const APPLICATION_OCTET_STREAM = 'application/octet-stream'; export const APPLICATION_SPARQL_UPDATE = 'application/sparql-update'; +export const APPLICATION_TRIG = 'application/trig'; export const APPLICATION_X_WWW_FORM_URLENCODED = 'application/x-www-form-urlencoded'; export const TEXT_HTML = 'text/html'; export const TEXT_MARKDOWN = 'text/markdown'; @@ -11,3 +12,10 @@ export const TEXT_TURTLE = 'text/turtle'; export const INTERNAL_ALL = 'internal/*'; export const INTERNAL_QUADS = 'internal/quads'; export const INTERNAL_ERROR = 'internal/error'; + +// Trig can be removed once the mime-types library is updated with the latest mime-db version +export const DEFAULT_CUSTOM_TYPES = { + acl: TEXT_TURTLE, + meta: TEXT_TURTLE, + trig: APPLICATION_TRIG, +}; diff --git a/templates/config/filesystem.json b/templates/config/filesystem.json index 1aa123acfa..e33f1a1390 100644 --- a/templates/config/filesystem.json +++ b/templates/config/filesystem.json @@ -16,9 +16,7 @@ }, "rootFilepath": { "@id": "urn:solid-server:template:variable:rootFilePath" - }, - "overrideTypes_acl": "text/turtle", - "overrideTypes_meta": "text/turtle" + } }, { diff --git a/test/unit/storage/mapping/ExtensionBasedMapper.test.ts b/test/unit/storage/mapping/ExtensionBasedMapper.test.ts index 7be7833024..51c65f598f 100644 --- a/test/unit/storage/mapping/ExtensionBasedMapper.test.ts +++ b/test/unit/storage/mapping/ExtensionBasedMapper.test.ts @@ -130,6 +130,28 @@ describe('An ExtensionBasedMapper', (): void => { await expect(result).rejects.toThrow(NotImplementedHttpError); await expect(result).rejects.toThrow('Unsupported content type fake/data'); }); + + it('supports custom types.', async(): Promise => { + const customMapper = new ExtensionBasedMapper(base, rootFilepath, { cstm: 'text/custom' }); + await expect(customMapper.mapUrlToFilePath({ path: `${base}test.cstm` }, false)) + .resolves.toEqual({ + identifier: { path: `${base}test.cstm` }, + filePath: `${rootFilepath}test.cstm`, + contentType: 'text/custom', + isMetadata: false, + }); + }); + + it('supports custom extensions.', async(): Promise => { + const customMapper = new ExtensionBasedMapper(base, rootFilepath, { cstm: 'text/custom' }); + await expect(customMapper.mapUrlToFilePath({ path: `${base}test` }, false, 'text/custom')) + .resolves.toEqual({ + identifier: { path: `${base}test` }, + filePath: `${rootFilepath}test$.cstm`, + contentType: 'text/custom', + isMetadata: false, + }); + }); }); describe('mapFilePathToUrl', (): void => { @@ -180,6 +202,17 @@ describe('An ExtensionBasedMapper', (): void => { isMetadata: false, }); }); + + it('supports custom extensions.', async(): Promise => { + const customMapper = new ExtensionBasedMapper(base, rootFilepath, { cstm: 'text/custom' }); + await expect(customMapper.mapFilePathToUrl(`${rootFilepath}test$.cstm`, false)) + .resolves.toEqual({ + identifier: { path: `${base}test` }, + filePath: `${rootFilepath}test$.cstm`, + contentType: 'text/custom', + isMetadata: false, + }); + }); }); describe('An ExtensionBasedMapperFactory', (): void => {