Repository: solid/community-server
Pull request #533 (2021-01-20 21:48:42)

Add StaticAssetHandler
Allows serving CSS, JS, etc. for https://github.com/solid/community-server/issues/510


Patch:
diff --git a/config/config-default.json b/config/config-default.json
index 880e6f02b..2e05b7501 100644
--- a/config/config-default.json
+++ b/config/config-default.json
@@ -16,6 +16,7 @@
     "files-scs:config/presets/middleware.json",
     "files-scs:config/presets/pod-management.json",
     "files-scs:config/presets/representation-conversion.json",
+    "files-scs:config/presets/static.json",
     "files-scs:config/presets/storage/backend/storage-memory.json",
     "files-scs:config/presets/storage-wrapper.json",
     "files-scs:config/presets/cli-params.json"
diff --git a/config/config-file.json b/config/config-file.json
index e002a8fbd..03e140e9c 100644
--- a/config/config-file.json
+++ b/config/config-file.json
@@ -16,6 +16,7 @@
     "files-scs:config/presets/middleware.json",
     "files-scs:config/presets/pod-management.json",
     "files-scs:config/presets/representation-conversion.json",
+    "files-scs:config/presets/static.json",
     "files-scs:config/presets/storage/backend/storage-filesystem.json",
     "files-scs:config/presets/storage-wrapper.json",
     "files-scs:config/presets/cli-params.json"
diff --git a/config/config-path-routing.json b/config/config-path-routing.json
index 8d9e8c7ab..1f0c804f9 100644
--- a/config/config-path-routing.json
+++ b/config/config-path-routing.json
@@ -16,6 +16,7 @@
     "files-scs:config/presets/middleware.json",
     "files-scs:config/presets/pod-management.json",
     "files-scs:config/presets/representation-conversion.json",
+    "files-scs:config/presets/static.json",
     "files-scs:config/presets/storage/backend/storage-filesystem.json",
     "files-scs:config/presets/storage/backend/storage-memory.json",
     "files-scs:config/presets/storage/backend/storage-sparql-endpoint.json",
diff --git a/config/config-rdf-to-sparql-endpoint.json b/config/config-rdf-to-sparql-endpoint.json
index 9486c8e93..f7b6acf24 100644
--- a/config/config-rdf-to-sparql-endpoint.json
+++ b/config/config-rdf-to-sparql-endpoint.json
@@ -16,6 +16,7 @@
     "files-scs:config/presets/middleware.json",
     "files-scs:config/presets/pod-management.json",
     "files-scs:config/presets/representation-conversion.json",
+    "files-scs:config/presets/static.json",
     "files-scs:config/presets/storage/backend/storage-filesystem.json",
     "files-scs:config/presets/storage/backend/storage-sparql-endpoint.json",
     "files-scs:config/presets/storage/routing/quad-type-routing.json",
diff --git a/config/config-sparql-endpoint.json b/config/config-sparql-endpoint.json
index 29609334e..be0edf08f 100644
--- a/config/config-sparql-endpoint.json
+++ b/config/config-sparql-endpoint.json
@@ -16,6 +16,7 @@
     "files-scs:config/presets/middleware.json",
     "files-scs:config/presets/pod-management.json",
     "files-scs:config/presets/representation-conversion.json",
+    "files-scs:config/presets/static.json",
     "files-scs:config/presets/storage/backend/storage-sparql-endpoint.json",
     "files-scs:config/presets/storage-wrapper.json",
     "files-scs:config/presets/cli-params.json"
diff --git a/config/presets/http.json b/config/presets/http.json
index e27281aaa..b773ce799 100644
--- a/config/presets/http.json
+++ b/config/presets/http.json
@@ -28,6 +28,9 @@
         {
           "@type": "WaterfallHandler",
           "WaterfallHandler:_handlers": [
+            {
+              "@id": "urn:solid-server:default:StaticAssetHandler"
+            },
             {
               "@id": "urn:solid-server:default:PodManagerHandler"
             },
diff --git a/config/presets/representation-conversion.json b/config/presets/representation-conversion.json
index 110524901..4e765487a 100644
--- a/config/presets/representation-conversion.json
+++ b/config/presets/representation-conversion.json
@@ -82,7 +82,11 @@
       "@type": "WaterfallHandler",
       "WaterfallHandler:_handlers": [
         {
-          "@type": "IfNeededConverter"
+          "@id": "urn:solid-server:default:IndexConverter",
+        },
+        {
+          "@type": "IfNeededConverter",
+          "comment": "Only continue converting if the requester cannot accept the available content type",
         },
         {
           "@id": "urn:solid-server:default:ContentTypeReplacer"
diff --git a/config/presets/static.json b/config/presets/static.json
new file mode 100644
index 000000000..aa253b1ec
--- /dev/null
+++ b/config/presets/static.json
@@ -0,0 +1,15 @@
+{
+  "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^0.0.0/components/context.jsonld",
+  "@graph": [
+    {
+      "@id": "urn:solid-server:default:IndexConverter",
+      "@type": "UnsupportedAsyncHandler",
+      "comment": "This value can be used to set a custom handler for index files"
+    },
+    {
+      "@id": "urn:solid-server:default:StaticAssetHandler",
+      "@type": "UnsupportedAsyncHandler",
+      "comment": "This value can be used to set a custom handler for static assets"
+    }
+  ]
+}
diff --git a/src/index.ts b/src/index.ts
index 3ffdeaf01..360024465 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -121,6 +121,7 @@ export * from './server/WebSocketHandler';
 // Server/Middleware
 export * from './server/middleware/CorsHandler';
 export * from './server/middleware/HeaderHandler';
+export * from './server/middleware/StaticAssetHandler';
 export * from './server/middleware/WebSocketAdvertiser';
 
 // Storage/Accessors
diff --git a/src/server/middleware/StaticAssetHandler.ts b/src/server/middleware/StaticAssetHandler.ts
new file mode 100644
index 000000000..856955a4c
--- /dev/null
+++ b/src/server/middleware/StaticAssetHandler.ts
@@ -0,0 +1,58 @@
+import { createReadStream } from 'fs';
+import * as mime from 'mime-types';
+import { getLoggerFor } from '../../logging/LogUtil';
+import { APPLICATION_OCTET_STREAM } from '../../util/ContentTypes';
+import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
+import { pipeSafely } from '../../util/StreamUtil';
+import type { HttpHandlerInput } from '../HttpHandler';
+import { HttpHandler } from '../HttpHandler';
+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 logger = getLoggerFor(this);
+
+  /**
+   * Creates a handler for the provided static resources.
+   * @param assets - A mapping from URL paths to file paths.
+   */
+  public constructor(assets: Record<string, string>) {
+    super();
+    this.assets = { ...assets };
+  }
+
+  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}`);
+    }
+  }
+
+  public async handle({ request, response }: HttpHandlerInput): Promise<void> {
+    // Determine the asset to serve
+    const filePath = this.assets[this.getAssetUrl(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);
+    }
+  }
+
+  private getAssetUrl({ url }: HttpRequest): string {
+    return !url ? '' : url.replace(/\?.*/u, '');
+  }
+}


Test patch:
diff --git a/test/integration/config/ldp-with-auth.json b/test/integration/config/ldp-with-auth.json
index 2d2457bd1..f2eaf13cd 100644
--- a/test/integration/config/ldp-with-auth.json
+++ b/test/integration/config/ldp-with-auth.json
@@ -11,6 +11,7 @@
     "files-scs:config/presets/ldp/response-writer.json",
     "files-scs:config/presets/ldp/request-parser.json",
     "files-scs:config/presets/representation-conversion.json",
+    "files-scs:config/presets/static.json",
     "files-scs:config/presets/storage/backend/storage-memory.json",
     "files-scs:config/presets/storage/backend/storage-filesystem.json",
     "files-scs:config/presets/storage/backend/storage-sparql-endpoint.json",
diff --git a/test/integration/config/server-middleware.json b/test/integration/config/server-middleware.json
index 11a9e357b..72a1ff5d0 100644
--- a/test/integration/config/server-middleware.json
+++ b/test/integration/config/server-middleware.json
@@ -3,6 +3,7 @@
   "import": [
     "files-scs:config/presets/http.json",
     "files-scs:config/presets/middleware.json",
+    "files-scs:config/presets/static.json",
     "files-scs:config/presets/cli-params.json"
   ],
   "@graph": [
diff --git a/test/integration/config/server-without-auth.json b/test/integration/config/server-without-auth.json
index 1e215c9af..096fb874d 100644
--- a/test/integration/config/server-without-auth.json
+++ b/test/integration/config/server-without-auth.json
@@ -12,6 +12,7 @@
     "files-scs:config/presets/middleware.json",
     "files-scs:config/presets/pod-management.json",
     "files-scs:config/presets/representation-conversion.json",
+    "files-scs:config/presets/static.json",
     "files-scs:config/presets/storage/backend/storage-memory.json",
     "files-scs:config/presets/storage-wrapper.json",
     "files-scs:config/presets/cli-params.json"
diff --git a/test/unit/server/middleware/StaticAssetHandler.test.ts b/test/unit/server/middleware/StaticAssetHandler.test.ts
new file mode 100644
index 000000000..b36a0639e
--- /dev/null
+++ b/test/unit/server/middleware/StaticAssetHandler.test.ts
@@ -0,0 +1,101 @@
+import EventEmitter from 'events';
+import fs from 'fs';
+import { createResponse } from 'node-mocks-http';
+import streamifyArray from 'streamify-array';
+import { StaticAssetHandler } from '../../../../src/server/middleware/StaticAssetHandler';
+
+const createReadStream = jest.spyOn(fs, 'createReadStream')
+  .mockReturnValue(streamifyArray([ 'file contents' ]) as any);
+
+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',
+  });
+
+  afterEach(jest.clearAllMocks);
+
+  it('does not handle POST requests.', async(): Promise<void> => {
+    const request = { method: 'POST' };
+    await expect(handler.canHandle({ request } as any)).rejects
+      .toThrow('Only GET and HEAD requests are supported');
+  });
+
+  it('does not handle requests without URL.', async(): Promise<void> => {
+    const request = { method: 'GET' };
+    await expect(handler.canHandle({ request } as any)).rejects
+      .toThrow('No static resource');
+  });
+
+  it('does not handle requests with unconfigured URLs.', async(): Promise<void> => {
+    const request = { method: 'GET', url: '/other' };
+    await expect(handler.canHandle({ request } as any)).rejects
+      .toThrow('No static resource');
+  });
+
+  it('handles a GET request to a known URL.', async(): Promise<void> => {
+    const request = { method: 'GET', url: '/foo/bar/style' };
+    const response = createResponse({ eventEmitter: EventEmitter });
+    const responseEnd = new Promise((resolve): any => response.on('end', resolve));
+    await handler.handleSafe({ request, response } as any);
+
+    expect(response.statusCode).toBe(200);
+    expect(response.getHeaders()).toHaveProperty('content-type', 'text/css');
+
+    await responseEnd;
+    expect(createReadStream).toHaveBeenCalledTimes(1);
+    expect(createReadStream).toHaveBeenCalledWith('/assets/styles/bar.css', 'utf8');
+    expect(response._getData()).toBe('file contents');
+  });
+
+  it('handles a HEAD request to a known URL.', async(): Promise<void> => {
+    const request = { method: 'HEAD', 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);
+
+    expect(response.statusCode).toBe(200);
+    expect(response.getHeaders()).toHaveProperty('content-type', 'application/javascript');
+
+    await responseEnd;
+    expect(createReadStream).toHaveBeenCalledTimes(0);
+    expect(response._getData()).toBe('');
+  });
+
+  it('handles a request to a known URL with a query string.', async(): Promise<void> => {
+    const request = { method: 'GET', url: '/foo/bar/style?abc=xyz' };
+    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/styles/bar.css', 'utf8');
+  });
+
+  it('handles a request for an asset with an unknown content type.', async(): Promise<void> => {
+    const request = { method: 'GET', url: '/foo/bar/unknown' };
+    const response = createResponse({ eventEmitter: EventEmitter });
+    await handler.handleSafe({ request, response } as any);
+
+    expect(response.statusCode).toBe(200);
+    expect(response.getHeaders()).toHaveProperty('content-type', 'application/octet-stream');
+
+    expect(createReadStream).toHaveBeenCalledTimes(1);
+    expect(createReadStream).toHaveBeenCalledWith('/assets/bar.unknown', 'utf8');
+  });
+
+  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);
+
+    createReadStream.mock.results[0].value.emit('error', new Error());
+
+    await responseEnd;
+    expect(response._getData()).toBe('');
+  });
+});