1+ import { promises as fsPromises } from 'fs' ;
12import { posix } from 'path' ;
2- import { types } from 'mime-types' ;
3+ import * as mime from 'mime-types' ;
34import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier' ;
45import { APPLICATION_OCTET_STREAM , TEXT_TURTLE } from '../util/ContentTypes' ;
56import { ConflictHttpError } from '../util/errors/ConflictHttpError' ;
67import { NotFoundHttpError } from '../util/errors/NotFoundHttpError' ;
8+ import { UnsupportedHttpError } from '../util/errors/UnsupportedHttpError' ;
79import { trimTrailingSlashes } from '../util/Util' ;
8- import type { FileIdentifierMapper } from './FileIdentifierMapper' ;
10+ import type { FileIdentifierMapper , ResourceLink } from './FileIdentifierMapper' ;
911
1012const { 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+ */
2534export 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 }
0 commit comments