diff --git a/config/util/identifiers/subdomain.json b/config/util/identifiers/subdomain.json index a9fc9ae9e9..7d324883e9 100644 --- a/config/util/identifiers/subdomain.json +++ b/config/util/identifiers/subdomain.json @@ -16,10 +16,17 @@ { "comment": "Only required when using a file-based backend.", "@id": "urn:solid-server:default:FileIdentifierMapper", - "@type": "SubdomainExtensionBasedMapper", - "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "rootFilepath": { "@id": "urn:solid-server:default:variable:rootFilePath" }, - "baseSubdomain": "www" + "@type": "ContainerContentTypeMapper", + "source": { + "@type": "SubdomainExtensionBasedMapper", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "rootFilepath": { "@id": "urn:solid-server:default:variable:rootFilePath" }, + "baseSubdomain": "www" + }, + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "container": "/.internal/", + "documentContentType": "application/json", + "metadataContentType": "text/turtle" } ] } diff --git a/config/util/identifiers/suffix.json b/config/util/identifiers/suffix.json index 5c2e65fe5c..036b1c992a 100644 --- a/config/util/identifiers/suffix.json +++ b/config/util/identifiers/suffix.json @@ -16,9 +16,16 @@ { "comment": "Only required when using a file-based backend.", "@id": "urn:solid-server:default:FileIdentifierMapper", - "@type": "ExtensionBasedMapper", - "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "rootFilepath": { "@id": "urn:solid-server:default:variable:rootFilePath" } + "@type": "ContainerContentTypeMapper", + "source": { + "@type": "ExtensionBasedMapper", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "rootFilepath": { "@id": "urn:solid-server:default:variable:rootFilePath" } + }, + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "container": "/.internal/", + "documentContentType": "application/json", + "metadataContentType": "text/turtle" } ] } diff --git a/src/index.ts b/src/index.ts index d9312af1bd..63fb42dfb6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -492,6 +492,7 @@ export * from './storage/keyvalue/WrappedIndexedStorage'; // Storage/Mapping export * from './storage/mapping/BaseFileIdentifierMapper'; +export * from './storage/mapping/ContainerContentTypeMapper'; export * from './storage/mapping/ExtensionBasedMapper'; export * from './storage/mapping/FileIdentifierMapper'; export * from './storage/mapping/FixedContentTypeMapper'; diff --git a/src/storage/mapping/ContainerContentTypeMapper.ts b/src/storage/mapping/ContainerContentTypeMapper.ts new file mode 100644 index 0000000000..be3d355171 --- /dev/null +++ b/src/storage/mapping/ContainerContentTypeMapper.ts @@ -0,0 +1,59 @@ +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { decodeUriPathComponents, ensureTrailingSlash, joinUrl, trimTrailingSlashes } from '../../util/PathUtil'; +import type { FileIdentifierMapper, ResourceLink } from './FileIdentifierMapper'; + +/** + * Provides the content types for resources stored in a specific container. + */ +export class ContainerContentTypeMapper implements FileIdentifierMapper { + private readonly source: FileIdentifierMapper; + private readonly documentContentType: string; + private readonly metadataContentType: string; + private readonly baseUrl: string; + private readonly containerPath: string; + + public constructor( + source: FileIdentifierMapper, + baseUrl: string, + container: string, + documentContentType: string, + metadataContentType: string, + ) { + this.source = source; + this.documentContentType = documentContentType; + this.metadataContentType = metadataContentType; + this.baseUrl = trimTrailingSlashes(baseUrl); + const containerUrl = ensureTrailingSlash(joinUrl(baseUrl, container)); + this.containerPath = decodeUriPathComponents(containerUrl.slice(this.baseUrl.length)); + } + + public async mapUrlToFilePath(identifier: ResourceIdentifier, isMetadata: boolean, contentType?: string): + Promise { + if (this.isContained(identifier)) { + const storedContentType = isMetadata ? this.metadataContentType : this.documentContentType; + if (contentType && contentType !== storedContentType) { + throw new NotImplementedHttpError( + `Unsupported content type ${contentType}, only ${storedContentType} is allowed`, + ); + } + contentType = storedContentType; + } + return this.source.mapUrlToFilePath(identifier, isMetadata, contentType); + } + + public async mapFilePathToUrl(filePath: string, isContainer: boolean): Promise { + return this.source.mapFilePathToUrl(filePath, isContainer); + } + + private isContained(identifier: ResourceIdentifier): boolean { + if (!identifier.path.startsWith(this.baseUrl)) { + return false; + } + try { + return decodeUriPathComponents(identifier.path.slice(this.baseUrl.length)).startsWith(this.containerPath); + } catch { + return false; + } + } +} diff --git a/test/integration/Conditions.test.ts b/test/integration/Conditions.test.ts index 04281a15cb..d27e65332f 100644 --- a/test/integration/Conditions.test.ts +++ b/test/integration/Conditions.test.ts @@ -63,8 +63,8 @@ describe.each(stores)('A server supporting conditions with %s', (name, { storeCo }); afterAll(async(): Promise => { - await teardown(); await app.stop(); + await teardown(); }); it('prevents operations on existing resources with "if-none-match: *" header.', async(): Promise => { diff --git a/test/integration/Config.ts b/test/integration/Config.ts index 1c0b5f373b..b217ab7499 100644 --- a/test/integration/Config.ts +++ b/test/integration/Config.ts @@ -1,6 +1,6 @@ +import { promises as fsPromises } from 'node:fs'; import type { IModuleState } from 'componentsjs'; import { ComponentsManager } from 'componentsjs'; -import { remove } from 'fs-extra'; import { joinFilePath } from '../../src/util/PathUtil'; let cachedModuleState: IModuleState; @@ -47,7 +47,7 @@ export function getTestFolder(name: string): string { } export async function removeFolder(folder: string): Promise { - await remove(folder); + await fsPromises.rm(folder, { force: true, maxRetries: 3, recursive: true }); } export function getDefaultVariables(port: number, baseUrl?: string): Record { diff --git a/test/unit/storage/mapping/ContainerContentTypeMapper.test.ts b/test/unit/storage/mapping/ContainerContentTypeMapper.test.ts new file mode 100644 index 0000000000..99bff08206 --- /dev/null +++ b/test/unit/storage/mapping/ContainerContentTypeMapper.test.ts @@ -0,0 +1,122 @@ +import fs from 'node:fs'; +import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; +import { ContainerContentTypeMapper } from '../../../../src/storage/mapping/ContainerContentTypeMapper'; +import { ExtensionBasedMapper } from '../../../../src/storage/mapping/ExtensionBasedMapper'; +import type { FileIdentifierMapper, ResourceLink } from '../../../../src/storage/mapping/FileIdentifierMapper'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; + +jest.mock('node:fs'); + +describe('A ContainerContentTypeMapper', (): void => { + const baseUrl = 'http://example.com/'; + let fsPromises: Record; + let source: jest.Mocked; + let mapper: ContainerContentTypeMapper; + + beforeEach((): void => { + jest.clearAllMocks(); + fs.promises = { readdir: jest.fn() } as any; + fsPromises = fs.promises as any; + source = { + mapUrlToFilePath: jest.fn< + Promise, + Parameters + >(async(identifier: ResourceIdentifier): Promise => ({ + identifier, + filePath: 'source', + isMetadata: false, + })), + mapFilePathToUrl: jest.fn< + Promise, + Parameters + >(async(): Promise => ({ + identifier: { path: 'source' }, + filePath: 'source', + isMetadata: false, + })), + }; + mapper = new ContainerContentTypeMapper( + source, + baseUrl, + '/.internal/', + 'application/json', + 'text/turtle', + ); + }); + + it('provides the document content type in the configured container.', async(): Promise => { + const identifier = { path: `${baseUrl}.internal/accounts/id` }; + await mapper.mapUrlToFilePath(identifier, false); + expect(source.mapUrlToFilePath).toHaveBeenCalledWith(identifier, false, 'application/json'); + }); + + it('provides the metadata content type in the configured container.', async(): Promise => { + const identifier = { path: `${baseUrl}.internal/accounts/id` }; + await mapper.mapUrlToFilePath(identifier, true); + expect(source.mapUrlToFilePath).toHaveBeenCalledWith(identifier, true, 'text/turtle'); + }); + + it('maps internal resources without changing existing file names.', async(): Promise => { + mapper = new ContainerContentTypeMapper( + new ExtensionBasedMapper(baseUrl, '/data/'), + baseUrl, + '/.internal/', + 'application/json', + 'text/turtle', + ); + await expect(mapper.mapUrlToFilePath({ path: `${baseUrl}.internal/id` }, false)).resolves.toMatchObject({ + filePath: '/data/.internal/id$.json', + contentType: 'application/json', + }); + await expect(mapper.mapUrlToFilePath({ path: `${baseUrl}.internal/id.json` }, false)).resolves.toMatchObject({ + filePath: '/data/.internal/id.json', + contentType: 'application/json', + }); + await expect(mapper.mapUrlToFilePath({ path: `${baseUrl}.internal/id` }, true)).resolves.toMatchObject({ + filePath: '/data/.internal/id.meta', + contentType: 'text/turtle', + }); + await expect(mapper.mapUrlToFilePath({ path: `${baseUrl}%2Einternal/id` }, false)).resolves.toMatchObject({ + filePath: '/data/.internal/id$.json', + contentType: 'application/json', + }); + expect(fsPromises.readdir).not.toHaveBeenCalled(); + }); + + it('rejects another content type in the configured container.', async(): Promise => { + const identifier = { path: `${baseUrl}.internal/accounts/id` }; + await expect(mapper.mapUrlToFilePath(identifier, false, 'text/plain')).rejects + .toThrow(NotImplementedHttpError); + expect(source.mapUrlToFilePath).not.toHaveBeenCalled(); + }); + + it('preserves content types outside the configured container.', async(): Promise => { + const identifier = { path: `${baseUrl}pod/resource` }; + await mapper.mapUrlToFilePath(identifier, false, 'text/plain'); + expect(source.mapUrlToFilePath).toHaveBeenCalledWith(identifier, false, 'text/plain'); + }); + + it('delegates identifiers outside the configured base URL.', async(): Promise => { + const identifier = { path: 'http://other.example/.internal/resource' }; + await mapper.mapUrlToFilePath(identifier, false); + expect(source.mapUrlToFilePath).toHaveBeenCalledWith(identifier, false, undefined); + }); + + it('delegates identifiers with invalid encoding.', async(): Promise => { + const identifier = { path: `${baseUrl}%` }; + await mapper.mapUrlToFilePath(identifier, false); + expect(source.mapUrlToFilePath).toHaveBeenCalledWith(identifier, false, undefined); + }); + + it('does not match containers with the same prefix.', async(): Promise => { + const identifier = { path: `${baseUrl}.internal-other/resource` }; + await mapper.mapUrlToFilePath(identifier, false); + expect(source.mapUrlToFilePath).toHaveBeenCalledWith(identifier, false, undefined); + }); + + it('maps file paths through the source mapper.', async(): Promise => { + const filePath = '/data/.internal/accounts/id$.json'; + await mapper.mapFilePathToUrl(filePath, false); + expect(source.mapFilePathToUrl).toHaveBeenCalledWith(filePath, false); + }); +});