Skip to content

Commit 3a42fd4

Browse files
committed
Add LegacyResourceMapper.
1 parent eb29ddd commit 3a42fd4

File tree

3 files changed

+306
-36
lines changed

3 files changed

+306
-36
lines changed

lib/legacy-resource-mapper.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
const ResourceMapper = require('./resource-mapper')
2+
3+
// A LegacyResourceMapper models the old mapping between HTTP URLs and server filenames,
4+
// and is intended to be replaced by ResourceMapper
5+
class LegacyResourceMapper extends ResourceMapper {
6+
constructor (options) {
7+
super(Object.assign({ defaultContentType: 'text/turtle' }, options))
8+
}
9+
10+
// Maps the request for a given resource and representation format to a server file
11+
async mapUrlToFile ({ url }) {
12+
return { path: this._getFullPath(url), contentType: this._getContentTypeByExtension(url) }
13+
}
14+
15+
// Preserve dollars in paths
16+
_removeDollarExtension (path) {
17+
return path
18+
}
19+
}
20+
21+
module.exports = LegacyResourceMapper

lib/resource-mapper.js

Lines changed: 39 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,42 +4,35 @@ const { promisify } = require('util')
44
const { types, extensions } = require('mime-types')
55
const readdir = promisify(fs.readdir)
66

7-
const DEFAULT_CONTENTTYPE = 'application/octet-stream'
8-
97
// A ResourceMapper maintains the mapping between HTTP URLs and server filenames,
108
// following the principles of the “sweet spot” discussed in
119
// https://www.w3.org/DesignIssues/HTTPFilenameMapping.html
1210
class ResourceMapper {
13-
constructor ({ rootUrl, rootPath, includeHost }) {
14-
this._rootUrl = removeTrailingSlash(rootUrl)
15-
this._rootPath = removeTrailingSlash(rootPath)
11+
constructor ({ rootUrl, rootPath, includeHost, defaultContentType = 'application/octet-stream' }) {
12+
this._rootUrl = this._removeTrailingSlash(rootUrl)
13+
this._rootPath = this._removeTrailingSlash(rootPath)
1614
this._includeHost = includeHost
1715
this._readdir = readdir
16+
this._defaultContentType = defaultContentType
1817

1918
// If the host needs to be replaced on every call, pre-split the root URL
2019
if (includeHost) {
2120
const { protocol, pathname } = URL.parse(rootUrl)
2221
this._protocol = protocol
23-
this._rootUrl = removeTrailingSlash(pathname)
22+
this._rootUrl = this._removeTrailingSlash(pathname)
2423
}
2524
}
2625

2726
// Maps the request for a given resource and representation format to a server file
2827
async mapUrlToFile ({ url, contentType, createIfNotExists }) {
29-
// Determine the full URL
30-
const { pathname, hostname } = typeof url === 'string' ? URL.parse(url) : url
31-
const fullPath = decodeURIComponent(`${this.getBasePath(hostname)}${pathname}`)
32-
if (fullPath.indexOf('/..') >= 0) {
33-
throw new Error('Disallowed /.. segment in URL')
34-
}
35-
36-
// Determine the path and content type
28+
const fullPath = this._getFullPath(url)
3729
let path
30+
31+
// Create the path for a new file
3832
if (createIfNotExists) {
39-
// Create the path for a new file
4033
path = fullPath
4134
// If the extension is not correct for the content type, append the correct extension
42-
if (getContentType(path) !== contentType) {
35+
if (this._getContentTypeByExtension(path) !== contentType) {
4336
path += contentType in extensions ? `$.${extensions[contentType][0]}` : '$.unknown'
4437
}
4538
// Determine the path of an existing file
@@ -50,23 +43,23 @@ class ResourceMapper {
5043
const files = await this._readdir(folder)
5144

5245
// Find a file with the same name (minus the dollar extension)
53-
const match = files.find(f => removeDollarExtension(f) === filename)
46+
const match = files.find(f => this._removeDollarExtension(f) === filename)
5447
if (!match) {
5548
throw new Error('File not found')
5649
}
5750
path = `${folder}${match}`
58-
contentType = getContentType(match)
51+
contentType = this._getContentTypeByExtension(match)
5952
}
6053

61-
return { path, contentType: contentType || DEFAULT_CONTENTTYPE }
54+
return { path, contentType: contentType || this._defaultContentType }
6255
}
6356

6457
// Maps a given server file to a URL
6558
async mapFileToUrl ({ path, hostname }) {
6659
// Determine the URL by chopping off everything after the dollar sign
67-
const pathname = removeDollarExtension(path.substring(this._rootPath.length))
60+
const pathname = this._removeDollarExtension(path.substring(this._rootPath.length))
6861
const url = `${this.getBaseUrl(hostname)}${encodeURI(pathname)}`
69-
return { url, contentType: getContentType(path) }
62+
return { url, contentType: this._getContentTypeByExtension(path) }
7063
}
7164

7265
// Gets the base file path for the given hostname
@@ -78,24 +71,34 @@ class ResourceMapper {
7871
getBaseUrl (hostname) {
7972
return this._includeHost ? `${this._protocol}//${hostname}${this._rootUrl}` : this._rootUrl
8073
}
81-
}
8274

83-
// Removes a possible trailing slash from a path
84-
function removeTrailingSlash (path) {
85-
const lastPos = path.length - 1
86-
return lastPos < 0 || path[lastPos] !== '/' ? path : path.substr(0, lastPos)
87-
}
75+
// Determine the full file path corresponding to a URL
76+
_getFullPath (url) {
77+
const { pathname, hostname } = typeof url === 'string' ? URL.parse(url) : url
78+
const fullPath = decodeURIComponent(`${this.getBasePath(hostname)}${pathname}`)
79+
if (fullPath.indexOf('/..') >= 0) {
80+
throw new Error('Disallowed /.. segment in URL')
81+
}
82+
return fullPath
83+
}
8884

89-
// Removes everything beyond the dollar sign from a path
90-
function removeDollarExtension (path) {
91-
const dollarPos = path.lastIndexOf('$')
92-
return dollarPos < 0 ? path : path.substr(0, dollarPos)
93-
}
85+
// Gets the expected content type based on the extension of the path
86+
_getContentTypeByExtension (path) {
87+
const extension = /\.([^/.]+)$/.exec(path)
88+
return extension && types[extension[1].toLowerCase()] || this._defaultContentType
89+
}
9490

95-
// Gets the expected content type based on the extension of the path
96-
function getContentType (path) {
97-
const extension = /\.([^/.]+)$/.exec(path)
98-
return extension && types[extension[1].toLowerCase()] || DEFAULT_CONTENTTYPE
91+
// Removes a possible trailing slash from a path
92+
_removeTrailingSlash (path) {
93+
const lastPos = path.length - 1
94+
return lastPos < 0 || path[lastPos] !== '/' ? path : path.substr(0, lastPos)
95+
}
96+
97+
// Removes everything beyond the dollar sign from a path
98+
_removeDollarExtension (path) {
99+
const dollarPos = path.lastIndexOf('$')
100+
return dollarPos < 0 ? path : path.substr(0, dollarPos)
101+
}
99102
}
100103

101104
module.exports = ResourceMapper
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
const LegacyResourceMapper = require('../../lib/legacy-resource-mapper')
2+
const chai = require('chai')
3+
const { expect } = chai
4+
chai.use(require('chai-as-promised'))
5+
6+
const rootUrl = 'http://localhost/'
7+
const rootPath = '/var/www/folder/'
8+
9+
const itMapsUrl = asserter(mapsUrl)
10+
const itMapsFile = asserter(mapsFile)
11+
12+
describe('LegacyResourceMapper', () => {
13+
describe('A LegacyResourceMapper instance for a single-host setup', () => {
14+
const mapper = new LegacyResourceMapper({ rootUrl, rootPath })
15+
16+
// adapted PUT base cases from https://www.w3.org/DesignIssues/HTTPFilenameMapping.html
17+
18+
itMapsUrl(mapper, 'a URL with an extension that matches the content type',
19+
{
20+
url: 'http://localhost/space/foo.html',
21+
contentType: 'text/html',
22+
createIfNotExists: true
23+
},
24+
{
25+
path: `${rootPath}space/foo.html`,
26+
contentType: 'text/html'
27+
})
28+
29+
// Additional PUT cases
30+
31+
itMapsUrl(mapper, 'a URL without content type',
32+
{
33+
url: 'http://localhost/space/foo.html',
34+
createIfNotExists: true
35+
},
36+
{
37+
path: `${rootPath}space/foo.html`,
38+
contentType: 'text/html'
39+
})
40+
41+
itMapsUrl(mapper, 'a URL with an alternative extension that matches the content type',
42+
{
43+
url: 'http://localhost/space/foo.jpeg',
44+
contentType: 'image/jpeg',
45+
createIfNotExists: true
46+
},
47+
{
48+
path: `${rootPath}space/foo.jpeg`,
49+
contentType: 'image/jpeg'
50+
})
51+
52+
itMapsUrl(mapper, 'a URL with an uppercase extension that matches the content type',
53+
{
54+
url: 'http://localhost/space/foo.JPG',
55+
contentType: 'image/jpeg',
56+
createIfNotExists: true
57+
},
58+
{
59+
path: `${rootPath}space/foo.JPG`,
60+
contentType: 'image/jpeg'
61+
})
62+
63+
itMapsUrl(mapper, 'a URL with a mixed-case extension that matches the content type',
64+
{
65+
url: 'http://localhost/space/foo.jPeG',
66+
contentType: 'image/jpeg',
67+
createIfNotExists: true
68+
},
69+
{
70+
path: `${rootPath}space/foo.jPeG`,
71+
contentType: 'image/jpeg'
72+
})
73+
74+
// GET/HEAD/POST/DELETE/PATCH base cases
75+
76+
itMapsUrl(mapper, 'a URL of an existing file with extension',
77+
{
78+
url: 'http://localhost/space/foo.html'
79+
},
80+
{
81+
path: `${rootPath}space/foo.html`,
82+
contentType: 'text/html'
83+
})
84+
85+
itMapsUrl(mapper, 'an extensionless URL of an existing file',
86+
{
87+
url: 'http://localhost/space/foo'
88+
},
89+
{
90+
path: `${rootPath}space/foo`,
91+
contentType: 'text/turtle'
92+
})
93+
94+
itMapsUrl(mapper, 'a URL of an existing file with encoded characters',
95+
{
96+
url: 'http://localhost/space/foo%20bar%20bar.html'
97+
},
98+
{
99+
path: `${rootPath}space/foo bar bar.html`,
100+
contentType: 'text/html'
101+
})
102+
103+
itMapsUrl(mapper, 'a URL of a new file with encoded characters',
104+
{
105+
url: 'http://localhost/space%2Ffoo%20bar%20bar.html',
106+
contentType: 'text/html',
107+
createIfNotExists: true
108+
},
109+
{
110+
path: `${rootPath}space/foo bar bar.html`,
111+
contentType: 'text/html'
112+
})
113+
114+
// Security cases
115+
116+
itMapsUrl(mapper, 'a URL with a /.. path segment',
117+
{
118+
url: 'http://localhost/space/../bar'
119+
},
120+
new Error('Disallowed /.. segment in URL'))
121+
122+
itMapsUrl(mapper, 'a URL with an encoded /.. path segment',
123+
{
124+
url: 'http://localhost/space%2F..%2Fbar'
125+
},
126+
new Error('Disallowed /.. segment in URL'))
127+
128+
// File to URL mapping
129+
130+
itMapsFile(mapper, 'an HTML file',
131+
{ path: `${rootPath}space/foo.html` },
132+
{
133+
url: 'http://localhost/space/foo.html',
134+
contentType: 'text/html'
135+
})
136+
137+
itMapsFile(mapper, 'a Turtle file',
138+
{ path: `${rootPath}space/foo.ttl` },
139+
{
140+
url: 'http://localhost/space/foo.ttl',
141+
contentType: 'text/turtle'
142+
})
143+
144+
itMapsFile(mapper, 'a file with an uppercase extension',
145+
{ path: `${rootPath}space/foo.HTML` },
146+
{
147+
url: 'http://localhost/space/foo.HTML',
148+
contentType: 'text/html'
149+
})
150+
151+
itMapsFile(mapper, 'a file with a mixed-case extension',
152+
{ path: `${rootPath}space/foo.HtMl` },
153+
{
154+
url: 'http://localhost/space/foo.HtMl',
155+
contentType: 'text/html'
156+
})
157+
158+
itMapsFile(mapper, 'a file with disallowed IRI characters',
159+
{ path: `${rootPath}space/foo bar bar.html` },
160+
{
161+
url: 'http://localhost/space/foo%20bar%20bar.html',
162+
contentType: 'text/html'
163+
})
164+
})
165+
166+
describe('A LegacyResourceMapper instance for a multi-host setup', () => {
167+
const mapper = new LegacyResourceMapper({ rootUrl, rootPath, includeHost: true })
168+
169+
itMapsUrl(mapper, 'a URL with a host',
170+
{
171+
url: 'http://example.org/space/foo.html',
172+
contentType: 'text/html',
173+
createIfNotExists: true
174+
},
175+
{
176+
path: `${rootPath}example.org/space/foo.html`,
177+
contentType: 'text/html'
178+
})
179+
180+
itMapsUrl(mapper, 'a URL with a host with a port',
181+
{
182+
url: 'http://example.org:3000/space/foo.html',
183+
contentType: 'text/html',
184+
createIfNotExists: true
185+
},
186+
{
187+
path: `${rootPath}example.org/space/foo.html`,
188+
contentType: 'text/html'
189+
})
190+
191+
itMapsFile(mapper, 'a file on a host',
192+
{
193+
path: `${rootPath}space/foo.html`,
194+
hostname: 'example.org'
195+
},
196+
{
197+
url: 'http://example.org/space/foo.html',
198+
contentType: 'text/html'
199+
})
200+
})
201+
202+
describe('A LegacyResourceMapper instance for a multi-host setup with a subfolder root URL', () => {
203+
const rootUrl = 'http://localhost/foo/bar/'
204+
const mapper = new LegacyResourceMapper({ rootUrl, rootPath, includeHost: true })
205+
206+
itMapsFile(mapper, 'a file on a host',
207+
{
208+
path: `${rootPath}space/foo.html`,
209+
hostname: 'example.org'
210+
},
211+
{
212+
url: 'http://example.org/foo/bar/space/foo.html',
213+
contentType: 'text/html'
214+
})
215+
})
216+
})
217+
218+
function asserter (assert) {
219+
const f = (...args) => assert(it, ...args)
220+
f.skip = (...args) => assert(it.skip, ...args)
221+
f.only = (...args) => assert(it.only, ...args)
222+
return f
223+
}
224+
225+
function mapsUrl (it, mapper, label, options, expected) {
226+
// Set up positive test
227+
if (!(expected instanceof Error)) {
228+
it(`maps ${label}`, async () => {
229+
const actual = await mapper.mapUrlToFile(options)
230+
expect(actual).to.deep.equal(expected)
231+
})
232+
// Set up error test
233+
} else {
234+
it(`does not map ${label}`, async () => {
235+
const actual = mapper.mapUrlToFile(options)
236+
await expect(actual).to.be.rejectedWith(expected.message)
237+
})
238+
}
239+
}
240+
241+
function mapsFile (it, mapper, label, options, expected) {
242+
it(`maps ${label}`, async () => {
243+
const actual = await mapper.mapFileToUrl(options)
244+
expect(actual).to.deep.equal(expected)
245+
})
246+
}

0 commit comments

Comments
 (0)