Skip to content

Commit

Permalink
feat: StaticAssetHandler can link a container to a document
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Jun 6, 2023
1 parent 0c90d18 commit 36ff95e
Show file tree
Hide file tree
Showing 7 changed files with 72 additions and 37 deletions.
13 changes: 9 additions & 4 deletions RELEASE_NOTES.md
Expand Up @@ -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

Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions config/app/README.md
Expand Up @@ -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

Expand Down
10 changes: 2 additions & 8 deletions 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": [ ]
}
]
}
10 changes: 2 additions & 8 deletions 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": [ ]
}
]
}
19 changes: 19 additions & 0 deletions 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"
}
]
}
]
}
18 changes: 15 additions & 3 deletions src/server/middleware/StaticAssetHandler.ts
Expand Up @@ -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';
Expand All @@ -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<string, string>;
Expand All @@ -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<string, string>, baseUrl: string, options: { expires?: number } = {}) {
Expand All @@ -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)
Expand All @@ -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));
}
Expand Down
38 changes: 24 additions & 14 deletions test/unit/server/middleware/StaticAssetHandler.test.ts
Expand Up @@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -159,56 +161,64 @@ describe('A StaticAssetHandler', (): void => {
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' };
it('handles URLs with a trailing slash that link to a document.', async(): Promise<void> => {
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<void> => {
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<void> => {
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);

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');
expect(createReadStream).toHaveBeenCalledWith('/assets/folders/1/abc/def.css');
});

it('prefers the longest path handler.', async(): Promise<void> => {
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);

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');
expect(createReadStream).toHaveBeenCalledWith('/assets/folders/2/abc/def.css');
});

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 request = { method: 'GET', url: '/foo/bar/folder/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');
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<void> => {
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();
});
Expand Down

0 comments on commit 36ff95e

Please sign in to comment.