Repository: solid/community-server
Pull request #559 (2021-01-27 23:20:03)

Support folders in StaticAssetHandler
Closes https://github.com/solid/community-server/issues/548


Patch:
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<string, string>;
+  private readonly mappings: Record<string, string>;
+  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<string, string>) {
     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<string, string>): 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<void> {
     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<void> {
     // 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 });
-
-    // 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);
-    }
-  }
+    // 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 });
+
+        // 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();
+        }
+      });
+    });
   }
 }


Test patch:
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<void> => {
+    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<void> => {
+    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<void> => {
     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<void> => {
+    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<void> => {
+    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<void> => {
+    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<void> => {
+    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<void> => {
+    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();
+  });
 });