From 36ff95e6b289c854635e4a842a8550d9ee88ef24 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 5 Jun 2023 16:04:54 +0200 Subject: [PATCH] feat: StaticAssetHandler can link a container to a document --- RELEASE_NOTES.md | 13 +++++-- config/app/README.md | 1 + .../app/init/initialize-prefilled-root.json | 10 +---- config/app/init/initialize-root.json | 10 +---- config/app/init/static-root.json | 19 ++++++++++ src/server/middleware/StaticAssetHandler.ts | 18 +++++++-- .../middleware/StaticAssetHandler.test.ts | 38 ++++++++++++------- 7 files changed, 72 insertions(+), 37 deletions(-) create mode 100644 config/app/init/static-root.json diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index cb5802b97d..2be303a519 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -4,7 +4,9 @@ ### New features -- ... +- The `StaticAssetHandler` can now be used to link static pages to containers. + This can be used to set a static page for the root container of a server. + See the `/config/app/init/static-root.json` config for an example. ### Data migration @@ -18,15 +20,18 @@ The `@context` needs to be updated to `https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld`. The following changes pertain to the imports in the default configs: - - ... + +- There is a new `static-root.json` import option for `app/init`, setting a static page for the root container. The following changes are relevant for v5 custom configs that replaced certain features. - - ... + +- `/app/init/*` imports have changed. Functionality remained the same though. ### Interface changes These changes are relevant if you wrote custom modules for the server that depend on existing interfaces. - - ... + +- ... ## v6.0.0 diff --git a/config/app/README.md b/config/app/README.md index e3978a41cc..4962643e0b 100644 --- a/config/app/README.md +++ b/config/app/README.md @@ -10,6 +10,7 @@ Contains a list of initializer that need to be run when starting the server. * *initialize-root*: Makes sure the root container has the necessary resources to function properly. This is only relevant if setup is disabled but root container access is still required. * *initialize-prefilled-root*: Similar to `initialize-root` but adds some introductory resources to the root container. +* *static-root*: Shows a static introduction page at the server root. This is not a Solid resource. ## Main diff --git a/config/app/init/initialize-prefilled-root.json b/config/app/init/initialize-prefilled-root.json index c04ec1f69e..5708f7c88f 100644 --- a/config/app/init/initialize-prefilled-root.json +++ b/config/app/init/initialize-prefilled-root.json @@ -1,23 +1,17 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", "import": [ - "css:config/app/init/base/init.json", + "css:config/app/init/default.json", "css:config/app/init/initializers/prefilled-root.json" ], "@graph": [ { - "comment": "These handlers are called only for the Primary process whenever the server is started, and can be used to ensure that all necessary resources for booting are available. (in singlethreaded mode, these are always called)", + "comment": "Initializes the root container resource.", "@id": "urn:solid-server:default:PrimaryParallelInitializer", "@type": "ParallelHandler", "handlers": [ { "@id": "urn:solid-server:default:RootInitializer" } ] - }, - { - "comment": "These handlers are called only for the workers processes whenever the server is started, and can be used to ensure that all necessary resources for booting are available. (in singlethreaded mode, these are always called)", - "@id": "urn:solid-server:default:WorkerParallelInitializer", - "@type": "ParallelHandler", - "handlers": [ ] } ] } diff --git a/config/app/init/initialize-root.json b/config/app/init/initialize-root.json index 06e9ce5da6..6fe5144aa0 100644 --- a/config/app/init/initialize-root.json +++ b/config/app/init/initialize-root.json @@ -1,23 +1,17 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", "import": [ - "css:config/app/init/base/init.json", + "css:config/app/init/default.json", "css:config/app/init/initializers/root.json" ], "@graph": [ { - "comment": "These handlers are called only for the Primary process whenever the server is started, and can be used to ensure that all necessary resources for booting are available. (in singlethreaded mode, these are always called)", + "comment": "Initializes the root container resource.", "@id": "urn:solid-server:default:PrimaryParallelInitializer", "@type": "ParallelHandler", "handlers": [ { "@id": "urn:solid-server:default:RootInitializer" } ] - }, - { - "comment": "These handlers are called only for the workers processes whenever the server is started, and can be used to ensure that all necessary resources for booting are available. (in singlethreaded mode, these are always called)", - "@id": "urn:solid-server:default:WorkerParallelInitializer", - "@type": "ParallelHandler", - "handlers": [ ] } ] } diff --git a/config/app/init/static-root.json b/config/app/init/static-root.json new file mode 100644 index 0000000000..f519656ce7 --- /dev/null +++ b/config/app/init/static-root.json @@ -0,0 +1,19 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "import": [ + "css:config/app/init/default.json" + ], + "@graph": [ + { + "comment": "Sets the root of the server to a static page.", + "@id": "urn:solid-server:default:StaticAssetHandler", + "@type": "StaticAssetHandler", + "assets": [ + { + "StaticAssetHandler:_assets_key": "/", + "StaticAssetHandler:_assets_value": "@css:templates/root/prefilled/base/index.html" + } + ] + } + ] +} diff --git a/src/server/middleware/StaticAssetHandler.ts b/src/server/middleware/StaticAssetHandler.ts index 3e023fd376..5a1377f02c 100644 --- a/src/server/middleware/StaticAssetHandler.ts +++ b/src/server/middleware/StaticAssetHandler.ts @@ -3,6 +3,7 @@ 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 { InternalServerError } from '../../util/errors/InternalServerError'; import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { ensureTrailingSlash, joinFilePath, resolveAssetPath, trimLeadingSlashes } from '../../util/PathUtil'; @@ -16,6 +17,7 @@ import type { HttpRequest } from '../HttpRequest'; * Relative file paths are assumed to be relative to cwd. * Relative file paths can be preceded by `@css:`, e.g. `@css:foo/bar`, * in case they need to be relative to the module root. + * File paths ending in a slash assume the target is a folder and map all of its contents. */ export class StaticAssetHandler extends HttpHandler { private readonly mappings: Record; @@ -27,6 +29,7 @@ export class StaticAssetHandler extends HttpHandler { * Creates a handler for the provided static resources. * @param assets - A mapping from URL paths to paths, * where URL paths ending in a slash are interpreted as entire folders. + * @param baseUrl - The base URL of the server. * @param options - Cache expiration time in seconds. */ public constructor(assets: Record, baseUrl: string, options: { expires?: number } = {}) { @@ -49,11 +52,19 @@ export class StaticAssetHandler extends HttpHandler { const paths = Object.keys(this.mappings) .sort((pathA, pathB): number => pathB.length - pathA.length); - // Collect regular expressions for files and folders separately + // Collect regular expressions for files and folders separately. + // The arrays need initial values to prevent matching everything, as they will if these are empty. const files = [ '.^' ]; const folders = [ '.^' ]; for (const path of paths) { - (path.endsWith('/') ? folders : files).push(escapeStringRegexp(path)); + const filePath = this.mappings[path]; + if (filePath.endsWith('/') && !path.endsWith('/')) { + throw new InternalServerError( + `Server is misconfigured: StaticAssetHandler can not ` + + `have a file path ending on a slash if the URL does not, but received ${path} and ${filePath}`, + ); + } + (filePath.endsWith('/') ? folders : files).push(escapeStringRegexp(path)); } // Either match an exact document or a file within a folder (stripping the query string) @@ -72,7 +83,8 @@ export class StaticAssetHandler extends HttpHandler { // The mapping is either a known document, or a file within a folder const [ , document, folder, file ] = match; - return document ? + + return typeof document === 'string' ? this.mappings[document] : joinFilePath(this.mappings[folder], decodeURIComponent(file)); } diff --git a/test/unit/server/middleware/StaticAssetHandler.test.ts b/test/unit/server/middleware/StaticAssetHandler.test.ts index 8efe925cc1..c5d34aab81 100644 --- a/test/unit/server/middleware/StaticAssetHandler.test.ts +++ b/test/unit/server/middleware/StaticAssetHandler.test.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import { PassThrough, Readable } from 'stream'; import { createResponse } from 'node-mocks-http'; import { StaticAssetHandler } from '../../../../src/server/middleware/StaticAssetHandler'; +import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; import type { SystemError } from '../../../../src/util/errors/SystemError'; import { getModuleRoot, joinFilePath } from '../../../../src/util/PathUtil'; @@ -12,14 +13,15 @@ const createReadStream = jest.spyOn(fs, 'createReadStream') describe('A StaticAssetHandler', (): void => { const handler = new StaticAssetHandler({ + '/': '/assets/README.md', '/foo/bar/style': '/assets/styles/bar.css', '/foo/bar/main': '/assets/scripts/bar.js', '/foo/bar/unknown': '/assets/bar.unknown', '/foo/bar/cwd': 'paths/cwd.txt', '/foo/bar/module': '@css:paths/module.txt', - '/foo/bar/folder1/': '/assets/folders/1/', - '/foo/bar/folder2/': '/assets/folders/2', - '/foo/bar/folder2/subfolder/': '/assets/folders/3', + '/foo/bar/document/': '/assets/document.txt', + '/foo/bar/folder/': '/assets/folders/1/', + '/foo/bar/folder/subfolder/': '/assets/folders/2/', }, 'http://localhost:3000'); afterEach(jest.clearAllMocks); @@ -159,20 +161,28 @@ describe('A StaticAssetHandler', (): void => { 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' }; + it('handles URLs with a trailing slash that link to a document.', async(): Promise => { + const request = { method: 'GET', url: '/foo/bar/document/' }; 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'); + expect(response.getHeaders()).toHaveProperty('content-type', 'text/plain'); + await responseEnd; expect(createReadStream).toHaveBeenCalledTimes(1); - expect(createReadStream).toHaveBeenCalledWith('/assets/folders/1/abc/def.css'); + expect(createReadStream).toHaveBeenCalledWith('/assets/document.txt'); + expect(response._getData()).toBe('file contents'); + }); + + it('requires folders to be linked to URLs ending on a slash.', async(): Promise => { + expect((): StaticAssetHandler => new StaticAssetHandler({ '/foo': '/bar/' }, 'http://example.com/')) + .toThrow(InternalServerError); }); 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 request = { method: 'GET', url: '/foo/bar/folder/abc/def.css?abc=def' }; const response = createResponse({ eventEmitter: EventEmitter }); await handler.handleSafe({ request, response } as any); @@ -180,11 +190,11 @@ describe('A StaticAssetHandler', (): void => { expect(response.getHeaders()).toHaveProperty('content-type', 'text/css'); expect(createReadStream).toHaveBeenCalledTimes(1); - expect(createReadStream).toHaveBeenCalledWith('/assets/folders/2/abc/def.css'); + expect(createReadStream).toHaveBeenCalledWith('/assets/folders/1/abc/def.css'); }); it('prefers the longest path handler.', async(): Promise => { - const request = { method: 'GET', url: '/foo/bar/folder2/subfolder/abc/def.css?' }; + const request = { method: 'GET', url: '/foo/bar/folder/subfolder/abc/def.css?' }; const response = createResponse({ eventEmitter: EventEmitter }); await handler.handleSafe({ request, response } as any); @@ -192,11 +202,11 @@ describe('A StaticAssetHandler', (): void => { expect(response.getHeaders()).toHaveProperty('content-type', 'text/css'); expect(createReadStream).toHaveBeenCalledTimes(1); - expect(createReadStream).toHaveBeenCalledWith('/assets/folders/3/abc/def.css'); + expect(createReadStream).toHaveBeenCalledWith('/assets/folders/2/abc/def.css'); }); 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 request = { method: 'GET', url: '/foo/bar/folder/a%20b%20c/def.css' }; const response = createResponse({ eventEmitter: EventEmitter }); await handler.handleSafe({ request, response } as any); @@ -204,11 +214,11 @@ describe('A StaticAssetHandler', (): void => { expect(response.getHeaders()).toHaveProperty('content-type', 'text/css'); expect(createReadStream).toHaveBeenCalledTimes(1); - expect(createReadStream).toHaveBeenCalledWith('/assets/folders/2/a b c/def.css'); + expect(createReadStream).toHaveBeenCalledWith('/assets/folders/1/a b c/def.css'); }); 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 request = { method: 'GET', url: '/foo/bar/folder/../def.css' }; const response = createResponse({ eventEmitter: EventEmitter }); await expect(handler.canHandle({ request, response } as any)).rejects.toThrow(); });