diff --git a/lib/resource-mapper.js b/lib/resource-mapper.js new file mode 100644 index 000000000..73c7660c0 --- /dev/null +++ b/lib/resource-mapper.js @@ -0,0 +1,98 @@ +const fs = require('fs') +const URL = require('url') +const { promisify } = require('util') +const { types, extensions } = require('mime-types') +const readdir = promisify(fs.readdir) + +const DEFAULT_CONTENTTYPE = 'application/octet-stream' + +// A ResourceMapper maintains the mapping between HTTP URLs and server filenames, +// following the principles of the “sweet spot” discussed in +// https://www.w3.org/DesignIssues/HTTPFilenameMapping.html +class ResourceMapper { + constructor ({ rootUrl, rootPath, includeHost }) { + this._rootUrl = removeTrailingSlash(rootUrl) + this._rootPath = removeTrailingSlash(rootPath) + this._includeHost = includeHost + this._readdir = readdir + + // If the host needs to be replaced on every call, pre-split the root URL + if (includeHost) { + const { protocol, pathname } = URL.parse(rootUrl) + this._protocol = protocol + this._rootUrl = removeTrailingSlash(pathname) + } + } + + // Maps the request for a given resource and representation format to a server file + async mapUrlToFile ({ url, contentType, createIfNotExists }) { + // Split the URL into components + const { pathname, hostname } = typeof url === 'string' ? URL.parse(url) : url + if (pathname.indexOf('/..') >= 0) { + throw new Error('Disallowed /.. segment in URL') + } + + let path + const basePath = this.getBasePath(hostname) + // Create the path for a new file + if (createIfNotExists) { + path = `${basePath}${pathname}` + // If the extension is not correct for the content type, append the correct extension + if (getContentType(pathname) !== contentType) { + path += contentType in extensions ? `$.${extensions[contentType][0]}` : '$.unknown' + } + // Determine the path of an existing file + } else { + // Read all files in the corresponding folder + const filename = /[^/]*$/.exec(pathname)[0] + const folder = `${basePath}${pathname.substr(0, pathname.length - filename.length)}` + const files = await this._readdir(folder) + + // Find a file with the same name (minus the dollar extension) + const match = files.find(f => removeDollarExtension(f) === filename) + if (!match) { + throw new Error('File not found') + } + path = `${folder}${match}` + contentType = getContentType(match) + } + + return { path, contentType: contentType || DEFAULT_CONTENTTYPE } + } + + // Maps a given server file to a URL + async mapFileToUrl ({ path, hostname }) { + // Determine the URL by chopping off everything after the dollar sign + const pathname = removeDollarExtension(path.substring(this._rootPath.length)) + const url = `${this.getBaseUrl(hostname)}${pathname}` + return { url, contentType: getContentType(path) } + } + + // Gets the base file path for the given hostname + getBasePath (hostname) { + return this._includeHost ? `${this._rootPath}/${hostname}` : this._rootPath + } + + // Gets the base URL for the given hostname + getBaseUrl (hostname) { + return this._includeHost ? `${this._protocol}//${hostname}${this._rootUrl}` : this._rootUrl + } +} + +// Removes a possible trailing slash from a path +function removeTrailingSlash (path) { + return path ? path.replace(/\/+$/, '') : '' +} + +// Removes everything beyond the dollar sign from a path +function removeDollarExtension (path) { + return path.replace(/\$.*/, '') +} + +// Gets the expected content type based on the extension of the path +function getContentType (path) { + const extension = /\.([^/.]+)$/.exec(path) + return extension && types[extension[1].toLowerCase()] || DEFAULT_CONTENTTYPE +} + +module.exports = ResourceMapper diff --git a/test/unit/resource-mapper-test.js b/test/unit/resource-mapper-test.js new file mode 100644 index 000000000..538494fca --- /dev/null +++ b/test/unit/resource-mapper-test.js @@ -0,0 +1,355 @@ +const ResourceMapper = require('../../lib/resource-mapper') +const chai = require('chai') +const { expect } = chai +chai.use(require('chai-as-promised')) + +const rootUrl = 'http://localhost/' +const rootPath = '/var/www/folder/' + +const itMapsUrl = asserter(mapsUrl) +const itMapsFile = asserter(mapsFile) + +describe('ResourceMapper', () => { + describe('A ResourceMapper instance for a single-host setup', () => { + const mapper = new ResourceMapper({ rootUrl, rootPath }) + + // PUT base cases from https://www.w3.org/DesignIssues/HTTPFilenameMapping.html + + itMapsUrl(mapper, 'a URL with an extension that matches the content type', + { + url: 'http://localhost/space/foo.html', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, "a URL with a bogus extension that doesn't match the content type", + { + url: 'http://localhost/space/foo.bar', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.bar$.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, "a URL with a real extension that doesn't match the content type", + { + url: 'http://localhost/space/foo.exe', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.exe$.html`, + contentType: 'text/html' + }) + + // Additional PUT cases + + itMapsUrl(mapper, 'a URL without content type', + { + url: 'http://localhost/space/foo.html', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.html$.unknown`, + contentType: 'application/octet-stream' + }) + + itMapsUrl(mapper, 'a URL with an alternative extension that matches the content type', + { + url: 'http://localhost/space/foo.jpeg', + contentType: 'image/jpeg', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.jpeg`, + contentType: 'image/jpeg' + }) + + itMapsUrl(mapper, 'a URL with an uppercase extension that matches the content type', + { + url: 'http://localhost/space/foo.JPG', + contentType: 'image/jpeg', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.JPG`, + contentType: 'image/jpeg' + }) + + itMapsUrl(mapper, 'a URL with a mixed-case extension that matches the content type', + { + url: 'http://localhost/space/foo.jPeG', + contentType: 'image/jpeg', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.jPeG`, + contentType: 'image/jpeg' + }) + + // GET/HEAD/POST/DELETE/PATCH base cases + + itMapsUrl(mapper, 'a URL of a non-existing file', + { + url: 'http://localhost/space/foo.html' + }, + [/* no files */], + new Error('File not found')) + + itMapsUrl(mapper, 'a URL of an existing file with extension', + { + url: 'http://localhost/space/foo.html' + }, + [ + `${rootPath}space/foo.html` + ], + { + path: `${rootPath}space/foo.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'an extensionless URL of an existing file', + { + url: 'http://localhost/space/foo' + }, + [ + `${rootPath}space/foo$.html` + ], + { + path: `${rootPath}space/foo$.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'an extensionless URL of an existing file, with multiple choices', + { + url: 'http://localhost/space/foo' + }, + [ + `${rootPath}space/foo$.html`, + `${rootPath}space/foo$.ttl`, + `${rootPath}space/foo$.png` + ], + { + path: `${rootPath}space/foo$.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'an extensionless URL of an existing file with an uppercase extension', + { + url: 'http://localhost/space/foo' + }, + [ + `${rootPath}space/foo$.HTML` + ], + { + path: `${rootPath}space/foo$.HTML`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'an extensionless URL of an existing file with a mixed-case extension', + { + url: 'http://localhost/space/foo' + }, + [ + `${rootPath}space/foo$.HtMl` + ], + { + path: `${rootPath}space/foo$.HtMl`, + contentType: 'text/html' + }) + + // Security cases + + itMapsUrl(mapper, 'a URL with an unknown content type', + { + url: 'http://localhost/space/foo.html', + contentTypes: ['text/unknown'], + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.html$.unknown`, + contentType: 'application/octet-stream' + }) + + itMapsUrl(mapper, 'a URL with a /.. path segment', + { + url: 'http://localhost/space/../bar' + }, + new Error('Disallowed /.. segment in URL')) + + // File to URL mapping + + itMapsFile(mapper, 'an HTML file', + { path: `${rootPath}space/foo.html` }, + { + url: 'http://localhost/space/foo.html', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a Turtle file', + { path: `${rootPath}space/foo.ttl` }, + { + url: 'http://localhost/space/foo.ttl', + contentType: 'text/turtle' + }) + + itMapsFile(mapper, 'an unknown file type', + { path: `${rootPath}space/foo.bar` }, + { + url: 'http://localhost/space/foo.bar', + contentType: 'application/octet-stream' + }) + + itMapsFile(mapper, 'a file with an uppercase extension', + { path: `${rootPath}space/foo.HTML` }, + { + url: 'http://localhost/space/foo.HTML', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a file with a mixed-case extension', + { path: `${rootPath}space/foo.HtMl` }, + { + url: 'http://localhost/space/foo.HtMl', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'an extensionless HTML file', + { path: `${rootPath}space/foo$.html` }, + { + url: 'http://localhost/space/foo', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'an extensionless Turtle file', + { path: `${rootPath}space/foo$.ttl` }, + { + url: 'http://localhost/space/foo', + contentType: 'text/turtle' + }) + + itMapsFile(mapper, 'an extensionless unknown file type', + { path: `${rootPath}space/foo$.bar` }, + { + url: 'http://localhost/space/foo', + contentType: 'application/octet-stream' + }) + + itMapsFile(mapper, 'an extensionless file with an uppercase extension', + { path: `${rootPath}space/foo$.HTML` }, + { + url: 'http://localhost/space/foo', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'an extensionless file with a mixed-case extension', + { path: `${rootPath}space/foo$.HtMl` }, + { + url: 'http://localhost/space/foo', + contentType: 'text/html' + }) + }) + + describe('A ResourceMapper instance for a multi-host setup', () => { + const mapper = new ResourceMapper({ rootUrl, rootPath, includeHost: true }) + + itMapsUrl(mapper, 'a URL with a host', + { + url: 'http://example.org/space/foo.html', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}example.org/space/foo.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'a URL with a host with a port', + { + url: 'http://example.org:3000/space/foo.html', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}example.org/space/foo.html`, + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a file on a host', + { + path: `${rootPath}space/foo.html`, + hostname: 'example.org' + }, + { + url: 'http://example.org/space/foo.html', + contentType: 'text/html' + }) + }) + + describe('A ResourceMapper instance for a multi-host setup with a subfolder root URL', () => { + const rootUrl = 'http://localhost/foo/bar/' + const mapper = new ResourceMapper({ rootUrl, rootPath, includeHost: true }) + + itMapsFile(mapper, 'a file on a host', + { + path: `${rootPath}space/foo.html`, + hostname: 'example.org' + }, + { + url: 'http://example.org/foo/bar/space/foo.html', + contentType: 'text/html' + }) + }) +}) + +function asserter (assert) { + const f = (...args) => assert(it, ...args) + f.skip = (...args) => assert(it.skip, ...args) + f.only = (...args) => assert(it.only, ...args) + return f +} + +function mapsUrl (it, mapper, label, options, files, expected) { + // Shift parameters if necessary + if (!expected) { + expected = files + files = [] + } + + // Mock filesystem + function mockReaddir () { + mapper._readdir = async (path) => { + expect(path).to.equal(`${rootPath}space/`) + return files.map(f => f.replace(/.*\//, '')) + } + } + + // Set up positive test + if (!(expected instanceof Error)) { + it(`maps ${label}`, async () => { + mockReaddir() + const actual = await mapper.mapUrlToFile(options) + expect(actual).to.deep.equal(expected) + }) + // Set up error test + } else { + it(`does not map ${label}`, async () => { + mockReaddir() + const actual = mapper.mapUrlToFile(options) + await expect(actual).to.be.rejectedWith(expected.message) + }) + } +} + +function mapsFile (it, mapper, label, options, expected) { + it(`maps ${label}`, async () => { + const actual = await mapper.mapFileToUrl(options) + expect(actual).to.deep.equal(expected) + }) +}