From 2563335403c859e49682dd61c5c3564cff930103 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Thu, 28 Jan 2021 00:18:47 +0100 Subject: [PATCH] feat: Support folders in StaticAssetHandler. Closes https://github.com/solid/community-server/issues/548 --- package-lock.json | 23 +++- package.json | 1 + src/server/middleware/StaticAssetHandler.ts | 101 ++++++++++++++---- .../middleware/StaticAssetHandler.test.ts | 98 ++++++++++++++++- 4 files changed, 193 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index fc2a65c2e..e35921fef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -226,6 +226,14 @@ "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" + }, + "dependencies": { + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + } } }, "has-flag": { @@ -2395,6 +2403,14 @@ "dev": true, "requires": { "escape-string-regexp": "^1.0.5" + }, + "dependencies": { + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + } } }, "cli-boxes": { @@ -3337,10 +3353,9 @@ "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" }, "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" }, "escodegen": { "version": "1.14.3", diff --git a/package.json b/package.json index 1b6e91e06..3dc5ad75d 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "async-lock": "^1.2.4", "componentsjs": "^4.0.1", "cors": "^2.8.5", + "escape-string-regexp": "^4.0.0", "express": "^4.17.1", "fetch-sparql-endpoint": "^1.8.0", "handlebars": "^4.7.6", diff --git a/src/server/middleware/StaticAssetHandler.ts b/src/server/middleware/StaticAssetHandler.ts index 856955a4c..fb73a55e8 100644 --- a/src/server/middleware/StaticAssetHandler.ts +++ b/src/server/middleware/StaticAssetHandler.ts @@ -1,8 +1,11 @@ import { createReadStream } from 'fs'; +import escapeStringRegexp from 'escape-string-regexp'; import * as mime from 'mime-types'; import { getLoggerFor } from '../../logging/LogUtil'; import { APPLICATION_OCTET_STREAM } from '../../util/ContentTypes'; +import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { joinFilePath } from '../../util/PathUtil'; import { pipeSafely } from '../../util/StreamUtil'; import type { HttpHandlerInput } from '../HttpHandler'; import { HttpHandler } from '../HttpHandler'; @@ -12,47 +15,103 @@ import type { HttpRequest } from '../HttpRequest'; * Handler that serves static resources on specific paths. */ export class StaticAssetHandler extends HttpHandler { - private readonly assets: Record; + private readonly mappings: Record; + private readonly pathMatcher: RegExp; private readonly logger = getLoggerFor(this); /** * Creates a handler for the provided static resources. - * @param assets - A mapping from URL paths to file paths. + * @param assets - A mapping from URL paths to paths, + * where URL paths ending in a slash are interpreted as entire folders. */ public constructor(assets: Record) { super(); - this.assets = { ...assets }; + this.mappings = { ...assets }; + this.pathMatcher = this.createPathMatcher(assets); + } + + /** + * Creates a regular expression that matches the URL paths. + */ + private createPathMatcher(assets: Record): RegExp { + // Sort longest paths first to ensure the longest match has priority + const paths = Object.keys(assets) + .sort((pathA, pathB): number => pathB.length - pathA.length); + + // Collect regular expressions for files and folders separately + const files = [ '.^' ]; + const folders = [ '.^' ]; + for (const path of paths) { + (path.endsWith('/') ? folders : files).push(escapeStringRegexp(path)); + } + + // Either match an exact document or a file within a folder (stripping the query string) + return new RegExp(`^(?:(${files.join('|')})|(${folders.join('|')})([^?]+))(?:\\?.*)?$`, 'u'); + } + + /** + * Obtains the file path corresponding to the asset URL + */ + private getFilePath({ url }: HttpRequest): string { + // Verify if the URL matches any of the paths + const match = this.pathMatcher.exec(url ?? ''); + if (!match || match[0].includes('/..')) { + throw new NotImplementedHttpError(`No static resource configured at ${url}`); + } + + // The mapping is either a known document, or a file within a folder + const [ , document, folder, file ] = match; + return document ? + this.mappings[document] : + joinFilePath(this.mappings[folder], decodeURIComponent(file)); } public async canHandle({ request }: HttpHandlerInput): Promise { if (request.method !== 'GET' && request.method !== 'HEAD') { throw new NotImplementedHttpError('Only GET and HEAD requests are supported'); } - if (!(this.getAssetUrl(request) in this.assets)) { - throw new NotImplementedHttpError(`No static resource at ${request.url}`); - } + this.getFilePath(request); } public async handle({ request, response }: HttpHandlerInput): Promise { // Determine the asset to serve - const filePath = this.assets[this.getAssetUrl(request)]; + const filePath = this.getFilePath(request); this.logger.debug(`Serving ${request.url} via static asset ${filePath}`); - // Send the response headers - const contentType = mime.lookup(filePath) || APPLICATION_OCTET_STREAM; - response.writeHead(200, { 'content-type': contentType }); + // Resolve when asset loading succeeds + const asset = createReadStream(filePath, 'utf8'); + return new Promise((resolve, reject): void => { + // Write a 200 response when the asset becomes readable + asset.once('readable', (): void => { + const contentType = mime.lookup(filePath) || APPLICATION_OCTET_STREAM; + response.writeHead(200, { 'content-type': contentType }); - // For HEAD, send an empty body - if (request.method === 'HEAD') { - response.end(); - // For GET, stream the asset - } else { - const asset = createReadStream(filePath, 'utf8'); - pipeSafely(asset, response); - } - } + // With HEAD, only write the headers + if (request.method === 'HEAD') { + response.end(); + asset.destroy(); + // With GET, pipe the entire response + } else { + pipeSafely(asset, response); + } + resolve(); + }); - private getAssetUrl({ url }: HttpRequest): string { - return !url ? '' : url.replace(/\?.*/u, ''); + // Pass the error when something goes wrong + asset.once('error', (error): void => { + const { code } = error as any; + // When the file if not found or a folder, signal a 404 + if (code === 'ENOENT' || code === 'EISDIR') { + this.logger.debug(`Static asset ${filePath} not found`); + reject(new NotFoundHttpError(`Cannot find ${request.url}`)); + // In other cases, we might already have started writing, so just hang up + } else { + this.logger.warn(`Error reading asset ${filePath}: ${error.message}`, { error }); + response.end(); + asset.destroy(); + resolve(); + } + }); + }); } } diff --git a/test/unit/server/middleware/StaticAssetHandler.test.ts b/test/unit/server/middleware/StaticAssetHandler.test.ts index b36a0639e..2bb08bee1 100644 --- a/test/unit/server/middleware/StaticAssetHandler.test.ts +++ b/test/unit/server/middleware/StaticAssetHandler.test.ts @@ -1,17 +1,23 @@ -import EventEmitter from 'events'; +import { EventEmitter } from 'events'; import fs from 'fs'; +import { PassThrough } from 'stream'; import { createResponse } from 'node-mocks-http'; import streamifyArray from 'streamify-array'; import { StaticAssetHandler } from '../../../../src/server/middleware/StaticAssetHandler'; +import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; +import type { SystemError } from '../../../../src/util/errors/SystemError'; const createReadStream = jest.spyOn(fs, 'createReadStream') - .mockReturnValue(streamifyArray([ 'file contents' ]) as any); + .mockImplementation((): any => streamifyArray([ 'file contents' ])); describe('a StaticAssetHandler', (): void => { const handler = new StaticAssetHandler({ '/foo/bar/style': '/assets/styles/bar.css', '/foo/bar/main': '/assets/scripts/bar.js', '/foo/bar/unknown': '/assets/bar.unknown', + '/foo/bar/folder1/': '/assets/folders/1/', + '/foo/bar/folder2/': '/assets/folders/2', + '/foo/bar/folder2/subfolder/': '/assets/folders/3', }); afterEach(jest.clearAllMocks); @@ -59,7 +65,6 @@ describe('a StaticAssetHandler', (): void => { expect(response.getHeaders()).toHaveProperty('content-type', 'application/javascript'); await responseEnd; - expect(createReadStream).toHaveBeenCalledTimes(0); expect(response._getData()).toBe(''); }); @@ -87,15 +92,98 @@ describe('a StaticAssetHandler', (): void => { expect(createReadStream).toHaveBeenCalledWith('/assets/bar.unknown', 'utf8'); }); + it('throws a 404 when the asset does not exist.', async(): Promise => { + const request = { method: 'GET', url: '/foo/bar/main' }; + const response = createResponse({ eventEmitter: EventEmitter }); + const error = new Error() as SystemError; + error.code = 'ENOENT'; + const stream = new PassThrough(); + stream._read = (): any => stream.emit('error', error); + createReadStream.mockReturnValueOnce(stream as any); + + await expect(handler.handleSafe({ request, response } as any)).rejects + .toThrow(NotFoundHttpError); + }); + + it('throws a 404 when the asset is folder.', async(): Promise => { + const request = { method: 'GET', url: '/foo/bar/main' }; + const response = createResponse({ eventEmitter: EventEmitter }); + const error = new Error() as SystemError; + error.code = 'EISDIR'; + const stream = new PassThrough(); + stream._read = (): any => stream.emit('error', error); + createReadStream.mockReturnValueOnce(stream as any); + + await expect(handler.handleSafe({ request, response } as any)).rejects + .toThrow(NotFoundHttpError); + }); + it('handles a request for an asset that errors.', async(): Promise => { const request = { method: 'GET', url: '/foo/bar/main' }; const response = createResponse({ eventEmitter: EventEmitter }); const responseEnd = new Promise((resolve): any => response.on('end', resolve)); - await handler.handleSafe({ request, response } as any); + const error = new Error(); + const stream = new PassThrough(); + stream._read = (): any => stream.emit('error', error); + createReadStream.mockReturnValueOnce(stream as any); - createReadStream.mock.results[0].value.emit('error', new Error()); + await handler.handleSafe({ request, response } as any); await responseEnd; expect(response._getData()).toBe(''); }); + + it('handles a request to a known folder URL defined without slash.', async(): Promise => { + const request = { method: 'GET', url: '/foo/bar/folder1/abc/def.css' }; + const response = createResponse({ eventEmitter: EventEmitter }); + await handler.handleSafe({ request, response } as any); + + expect(response.statusCode).toBe(200); + expect(response.getHeaders()).toHaveProperty('content-type', 'text/css'); + + expect(createReadStream).toHaveBeenCalledTimes(1); + expect(createReadStream).toHaveBeenCalledWith('/assets/folders/1/abc/def.css', 'utf8'); + }); + + it('handles a request to a known folder URL defined with slash.', async(): Promise => { + const request = { method: 'GET', url: '/foo/bar/folder2/abc/def.css?abc=def' }; + const response = createResponse({ eventEmitter: EventEmitter }); + await handler.handleSafe({ request, response } as any); + + expect(response.statusCode).toBe(200); + expect(response.getHeaders()).toHaveProperty('content-type', 'text/css'); + + expect(createReadStream).toHaveBeenCalledTimes(1); + expect(createReadStream).toHaveBeenCalledWith('/assets/folders/2/abc/def.css', 'utf8'); + }); + + it('prefers the longest path handler.', async(): Promise => { + const request = { method: 'GET', url: '/foo/bar/folder2/subfolder/abc/def.css?' }; + const response = createResponse({ eventEmitter: EventEmitter }); + await handler.handleSafe({ request, response } as any); + + expect(response.statusCode).toBe(200); + expect(response.getHeaders()).toHaveProperty('content-type', 'text/css'); + + expect(createReadStream).toHaveBeenCalledTimes(1); + expect(createReadStream).toHaveBeenCalledWith('/assets/folders/3/abc/def.css', 'utf8'); + }); + + it('handles a request to a known folder URL with spaces.', async(): Promise => { + const request = { method: 'GET', url: '/foo/bar/folder2/a%20b%20c/def.css' }; + const response = createResponse({ eventEmitter: EventEmitter }); + await handler.handleSafe({ request, response } as any); + + expect(response.statusCode).toBe(200); + expect(response.getHeaders()).toHaveProperty('content-type', 'text/css'); + + expect(createReadStream).toHaveBeenCalledTimes(1); + expect(createReadStream).toHaveBeenCalledWith('/assets/folders/2/a b c/def.css', 'utf8'); + }); + + it('does not handle a request to a known folder URL with parent path segments.', async(): Promise => { + const request = { method: 'GET', url: '/foo/bar/folder1/../def.css' }; + const response = createResponse({ eventEmitter: EventEmitter }); + await expect(handler.canHandle({ request, response } as any)).rejects.toThrow(); + }); });