Skip to content

Commit b47dc3f

Browse files
committed
feat: Have ExtensionBasedMapper handle extensions correctly
1 parent 0644f8d commit b47dc3f

File tree

5 files changed

+382
-114
lines changed

5 files changed

+382
-114
lines changed

src/storage/ExtensionBasedMapper.ts

Lines changed: 136 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1+
import { promises as fsPromises } from 'fs';
12
import { posix } from 'path';
2-
import { types } from 'mime-types';
3+
import * as mime from 'mime-types';
34
import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
45
import { APPLICATION_OCTET_STREAM, TEXT_TURTLE } from '../util/ContentTypes';
56
import { ConflictHttpError } from '../util/errors/ConflictHttpError';
67
import { NotFoundHttpError } from '../util/errors/NotFoundHttpError';
8+
import { UnsupportedHttpError } from '../util/errors/UnsupportedHttpError';
79
import { trimTrailingSlashes } from '../util/Util';
8-
import type { FileIdentifierMapper } from './FileIdentifierMapper';
10+
import type { FileIdentifierMapper, ResourceLink } from './FileIdentifierMapper';
911

1012
const { join: joinPath, normalize: normalizePath } = posix;
1113

@@ -22,52 +24,136 @@ export interface ResourcePath {
2224
documentName?: string;
2325
}
2426

27+
/**
28+
* A mapper that stores the content-type of resources in the file path extension.
29+
* In case the extension of the identifier does not correspond to the correct content-type,
30+
* a new extension will be appended (with a `$` in front of it).
31+
* E.g. if the path is `input.ttl` with content-type `text/plain`, the path would actually be `input.ttl$.txt`.
32+
* This new extension is stripped again when generating an identifier.
33+
*/
2534
export class ExtensionBasedMapper implements FileIdentifierMapper {
26-
private readonly base: string;
27-
private readonly prootFilepath: string;
35+
private readonly baseRequestURI: string;
36+
private readonly rootFilepath: string;
2837
private readonly types: Record<string, any>;
2938

3039
public constructor(base: string, rootFilepath: string, overrideTypes = { acl: TEXT_TURTLE, metadata: TEXT_TURTLE }) {
31-
this.base = base;
32-
this.prootFilepath = rootFilepath;
33-
this.types = { ...types, ...overrideTypes };
34-
}
35-
36-
public get baseRequestURI(): string {
37-
return trimTrailingSlashes(this.base);
38-
}
39-
40-
public get rootFilepath(): string {
41-
return trimTrailingSlashes(this.prootFilepath);
40+
this.baseRequestURI = trimTrailingSlashes(base);
41+
this.rootFilepath = trimTrailingSlashes(normalizePath(rootFilepath));
42+
this.types = { ...mime.types, ...overrideTypes };
4243
}
4344

4445
/**
45-
* Strips the baseRequestURI from the identifier and checks if the stripped base URI matches the store's one.
46-
* @param identifier - Incoming identifier.
46+
* Maps the given resource identifier / URL to a file path.
47+
* Determines the content-type if no content-type was provided.
48+
* For containers the content-type input gets ignored.
49+
* @param identifier - The input identifier.
50+
* @param contentType - The (optional) content-type of the resource.
4751
*
48-
* @throws {@link NotFoundHttpError}
49-
* If the identifier does not match the baseRequestURI path of the store.
50-
*
51-
* @returns Absolute path of the file.
52+
* @returns A ResourceLink with all the necessary metadata.
5253
*/
53-
public mapUrlToFilePath(identifier: ResourceIdentifier, id = ''): string {
54-
return this.getAbsolutePath(this.getRelativePath(identifier), id);
54+
public async mapUrlToFilePath(identifier: ResourceIdentifier, contentType?: string): Promise<ResourceLink> {
55+
let path = this.getRelativePath(identifier);
56+
57+
if (!path.startsWith('/')) {
58+
throw new UnsupportedHttpError('URL needs a / after the base.');
59+
}
60+
61+
if (path.includes('/..')) {
62+
throw new UnsupportedHttpError('Disallowed /.. segment in URL.');
63+
}
64+
65+
path = this.getAbsolutePath(path);
66+
67+
// Container
68+
if (identifier.path.endsWith('/')) {
69+
return {
70+
identifier,
71+
filePath: path,
72+
};
73+
}
74+
75+
// Would conflict with how new extensions get stored
76+
if (/\$\.\w+$/u.test(path)) {
77+
throw new UnsupportedHttpError('Identifiers cannot contain a dollar sign before their extension.');
78+
}
79+
80+
// Existing file
81+
if (!contentType) {
82+
const [ , folder, documentName ] = /^(.*\/)(.*)$/u.exec(path)!;
83+
84+
let fileName: string | undefined;
85+
try {
86+
const files = await fsPromises.readdir(folder);
87+
fileName = files.find(
88+
(file): boolean =>
89+
file.startsWith(documentName) && /^(?:\$\..+)?$/u.test(file.slice(documentName.length)),
90+
);
91+
} catch {
92+
// Parent folder does not exist (or is not a folder)
93+
throw new NotFoundHttpError();
94+
}
95+
96+
// File doesn't exist
97+
if (!fileName) {
98+
throw new NotFoundHttpError();
99+
}
100+
101+
return {
102+
identifier,
103+
filePath: joinPath(folder, fileName),
104+
contentType: this.getContentTypeFromExtension(fileName),
105+
};
106+
}
107+
108+
// If the extension of the identifier matches a different content-type than the one that is given,
109+
// we need to add a new extension to match the correct type.
110+
if (contentType !== this.getContentTypeFromExtension(path)) {
111+
const extension = mime.extension(contentType);
112+
if (!extension) {
113+
throw new UnsupportedHttpError(`Unsupported content-type ${contentType}.`);
114+
}
115+
path = `${path}$.${extension}`;
116+
}
117+
118+
return {
119+
identifier,
120+
filePath: path,
121+
contentType,
122+
};
55123
}
56124

57125
/**
58-
* Strips the rootFilepath path from the filepath and adds the baseRequestURI in front of it.
59-
* @param path - The file path.
126+
* Maps the given file path to an URL and determines the content-type
127+
* @param filePath - The input file path.
128+
* @param isContainer - If the path corresponds to a file.
60129
*
61-
* @throws {@Link Error}
62-
* If the file path does not match the rootFilepath path of the store.
63-
*
64-
* @returns Url of the file.
130+
* @returns A ResourceLink with all the necessary metadata.
65131
*/
66-
public mapFilePathToUrl(path: string): string {
67-
if (!path.startsWith(this.rootFilepath)) {
68-
throw new Error(`File ${path} is not part of the file storage at ${this.rootFilepath}.`);
132+
public async mapFilePathToUrl(filePath: string, isContainer: boolean): Promise<ResourceLink> {
133+
if (!filePath.startsWith(this.rootFilepath)) {
134+
throw new Error(`File ${filePath} is not part of the file storage at ${this.rootFilepath}.`);
69135
}
70-
return this.baseRequestURI + path.slice(this.rootFilepath.length);
136+
137+
let relative = filePath.slice(this.rootFilepath.length);
138+
if (isContainer) {
139+
return {
140+
identifier: { path: encodeURI(this.baseRequestURI + relative) },
141+
filePath,
142+
};
143+
}
144+
145+
// Files
146+
const extension = this.getExtension(relative);
147+
const contentType = this.getContentTypeFromExtension(relative);
148+
if (extension && relative.endsWith(`$.${extension}`)) {
149+
relative = relative.slice(0, -(extension.length + 2));
150+
}
151+
152+
return {
153+
identifier: { path: encodeURI(this.baseRequestURI + relative) },
154+
filePath,
155+
contentType,
156+
};
71157
}
72158

73159
/**
@@ -76,9 +162,19 @@ export class ExtensionBasedMapper implements FileIdentifierMapper {
76162
*
77163
* @returns Content type of the file.
78164
*/
79-
public getContentTypeFromExtension(path: string): string {
165+
private getContentTypeFromExtension(path: string): string {
166+
const extension = this.getExtension(path);
167+
return (extension && this.types[extension.toLowerCase()]) || APPLICATION_OCTET_STREAM;
168+
}
169+
170+
/**
171+
* Extracts the extension (without dot) from a path.
172+
* Custom functin since `path.extname` does not work on all cases (e.g. ".acl")
173+
* @param path - Input path to parse.
174+
*/
175+
private getExtension(path: string): string | null {
80176
const extension = /\.([^./]+)$/u.exec(path);
81-
return (extension && this.types[extension[1].toLowerCase()]) || APPLICATION_OCTET_STREAM;
177+
return extension && extension[1];
82178
}
83179

84180
/**
@@ -88,7 +184,7 @@ export class ExtensionBasedMapper implements FileIdentifierMapper {
88184
*
89185
* @returns Absolute path of the file.
90186
*/
91-
public getAbsolutePath(path: string, identifier = ''): string {
187+
private getAbsolutePath(path: string, identifier = ''): string {
92188
return joinPath(this.rootFilepath, path, identifier);
93189
}
94190

@@ -105,7 +201,7 @@ export class ExtensionBasedMapper implements FileIdentifierMapper {
105201
if (!identifier.path.startsWith(this.baseRequestURI)) {
106202
throw new NotFoundHttpError();
107203
}
108-
return identifier.path.slice(this.baseRequestURI.length);
204+
return decodeURI(identifier.path).slice(this.baseRequestURI.length);
109205
}
110206

111207
/**
@@ -116,7 +212,7 @@ export class ExtensionBasedMapper implements FileIdentifierMapper {
116212
* @throws {@link ConflictHttpError}
117213
* If the root identifier is passed.
118214
*
119-
* @returns A ResourcePath object containing path and (optional) slug fields.
215+
* @returns A ResourcePath object containing (absolute) path and (optional) slug fields.
120216
*/
121217
public extractDocumentName(identifier: ResourceIdentifier): ResourcePath {
122218
const [ , containerPath, documentName ] = /^(.*\/)([^/]+\/?)?$/u.exec(this.getRelativePath(identifier)) ?? [];
@@ -125,9 +221,9 @@ export class ExtensionBasedMapper implements FileIdentifierMapper {
125221
throw new ConflictHttpError('Container with that identifier already exists (root).');
126222
}
127223
return {
128-
containerPath: normalizePath(containerPath),
224+
containerPath: this.getAbsolutePath(normalizePath(containerPath)),
129225

130-
// If documentName is not undefined, return normalized documentName
226+
// If documentName is defined, return normalized documentName
131227
documentName: typeof documentName === 'string' ? normalizePath(documentName) : undefined,
132228
};
133229
}
Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,40 @@
11
import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
22

3+
export interface ResourceLink {
4+
/**
5+
* Identifier of a resource.
6+
*/
7+
identifier: ResourceIdentifier;
8+
/**
9+
* File path of a resource.
10+
*/
11+
filePath: string;
12+
/**
13+
* Content-type for a data resource (not defined for containers).
14+
*/
15+
contentType?: string;
16+
}
17+
318
/**
419
* Supports mapping a file to an URL and back.
520
*/
621
export interface FileIdentifierMapper {
722
/**
8-
* Maps the given file path to an URL.
9-
* @param file - The input file path.
23+
* Maps the given file path to an URL and determines the content-type
24+
* @param filePath - The input file path.
25+
* @param isContainer - If the path corresponds to a file.
1026
*
11-
* @returns The URL as a string.
27+
* @returns A ResourceLink with all the necessary metadata.
1228
*/
13-
mapFilePathToUrl: (filePath: string) => string;
29+
mapFilePathToUrl: (filePath: string, isContainer: boolean) => Promise<ResourceLink>;
1430
/**
1531
* Maps the given resource identifier / URL to a file path.
16-
* @param url - The input URL.
32+
* Determines the content-type if no content-type was provided.
33+
* For containers the content-type input gets ignored.
34+
* @param identifier - The input identifier.
35+
* @param contentType - The (optional) content-type of the resource.
1736
*
18-
* @returns The file path as a string.
37+
* @returns A ResourceLink with all the necessary metadata.
1938
*/
20-
mapUrlToFilePath: (identifier: ResourceIdentifier) => string;
39+
mapUrlToFilePath: (identifier: ResourceIdentifier, contentType?: string) => Promise<ResourceLink>;
2140
}

0 commit comments

Comments
 (0)