Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions config/util/identifiers/subdomain.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
13 changes: 10 additions & 3 deletions config/util/identifiers/suffix.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
59 changes: 59 additions & 0 deletions src/storage/mapping/ContainerContentTypeMapper.ts
Original file line number Diff line number Diff line change
@@ -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<ResourceLink> {
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<ResourceLink> {
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;
}
}
}
2 changes: 1 addition & 1 deletion test/integration/Conditions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ describe.each(stores)('A server supporting conditions with %s', (name, { storeCo
});

afterAll(async(): Promise<void> => {
await teardown();
await app.stop();
await teardown();
});

it('prevents operations on existing resources with "if-none-match: *" header.', async(): Promise<void> => {
Expand Down
4 changes: 2 additions & 2 deletions test/integration/Config.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -47,7 +47,7 @@ export function getTestFolder(name: string): string {
}

export async function removeFolder(folder: string): Promise<void> {
await remove(folder);
await fsPromises.rm(folder, { force: true, maxRetries: 3, recursive: true });
}

export function getDefaultVariables(port: number, baseUrl?: string): Record<string, any> {
Expand Down
122 changes: 122 additions & 0 deletions test/unit/storage/mapping/ContainerContentTypeMapper.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, jest.Mock>;
let source: jest.Mocked<FileIdentifierMapper>;
let mapper: ContainerContentTypeMapper;

beforeEach((): void => {
jest.clearAllMocks();
fs.promises = { readdir: jest.fn() } as any;
fsPromises = fs.promises as any;
source = {
mapUrlToFilePath: jest.fn<
Promise<ResourceLink>,
Parameters<FileIdentifierMapper['mapUrlToFilePath']>
>(async(identifier: ResourceIdentifier): Promise<ResourceLink> => ({
identifier,
filePath: 'source',
isMetadata: false,
})),
mapFilePathToUrl: jest.fn<
Promise<ResourceLink>,
Parameters<FileIdentifierMapper['mapFilePathToUrl']>
>(async(): Promise<ResourceLink> => ({
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
const filePath = '/data/.internal/accounts/id$.json';
await mapper.mapFilePathToUrl(filePath, false);
expect(source.mapFilePathToUrl).toHaveBeenCalledWith(filePath, false);
});
});
Loading