mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Support folders in StaticAssetHandler.
Closes https://github.com/solid/community-server/issues/548
This commit is contained in:
committed by
Joachim Van Herwegen
parent
6346760d1d
commit
2563335403
23
package-lock.json
generated
23
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user