CommunitySolidServer/src/server/middleware/StaticAssetHandler.ts
Joachim Van Herwegen 6248ed0938 refactor: Replace linting configurations
The previous package was outdated, preventing us from updating TS.
This one also lints YAML and JSON,
and applies many more rules to the test files,
explaining all the changes in this PR.
2023-11-02 09:49:17 +01:00

167 lines
6.5 KiB
TypeScript

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 { InternalServerError } from '../../util/errors/InternalServerError';
import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import type { SystemError } from '../../util/errors/SystemError';
import { ensureTrailingSlash, joinFilePath, resolveAssetPath, trimLeadingSlashes } from '../../util/PathUtil';
import { pipeSafely } from '../../util/StreamUtil';
import type { HttpHandlerInput } from '../HttpHandler';
import { HttpHandler } from '../HttpHandler';
import type { HttpRequest } from '../HttpRequest';
/**
* Used to link file paths with relative URLs.
* By using a separate class instead of a key/value map it is easier to replace values in Components.js.
*/
export class StaticAssetEntry {
public constructor(
public readonly relativeUrl: string,
public readonly filePath: string,
) { }
}
/**
* Handler that serves static resources on specific paths.
* Relative file paths are assumed to be relative to the current working directory.
* 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>;
private readonly pathMatcher: RegExp;
private readonly expires: number;
private readonly logger = getLoggerFor(this);
/**
* Creates a handler for the provided static resources.
* @param assets - A list of {@link StaticAssetEntry}.
* @param baseUrl - The base URL of the server.
* @param options - Specific options.
* @param options.expires - Cache expiration time in seconds.
*/
public constructor(assets: StaticAssetEntry[], baseUrl: string, options: { expires?: number } = {}) {
super();
this.mappings = {};
const rootPath = ensureTrailingSlash(new URL(baseUrl).pathname);
for (const { relativeUrl, filePath } of assets) {
this.mappings[trimLeadingSlashes(relativeUrl)] = resolveAssetPath(filePath);
}
this.pathMatcher = this.createPathMatcher(rootPath);
this.expires = Number.isInteger(options.expires) ? Math.max(0, options.expires!) : 0;
}
/**
* Creates a regular expression that matches the URL paths.
*/
private createPathMatcher(rootPath: string): RegExp {
// Sort longest paths first to ensure the longest match has priority
const paths = Object.keys(this.mappings)
.sort((pathA, pathB): number => pathB.length - pathA.length);
// 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) {
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)
return new RegExp(`^${rootPath}(?:(${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 typeof document === 'string' ?
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');
}
this.getFilePath(request);
}
public async handle({ request, response }: HttpHandlerInput): Promise<void> {
// Determine the asset to serve
const filePath = this.getFilePath(request);
this.logger.debug(`Serving ${request.url} via static asset ${filePath}`);
// Resolve when asset loading succeeds
const asset = createReadStream(filePath);
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, {
// eslint-disable-next-line ts/naming-convention
'content-type': contentType,
...this.getCacheHeaders(),
});
// 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();
});
// Pass the error when something goes wrong
asset.once('error', (error): void => {
const { code } = error as SystemError;
// 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}`);
response.end();
asset.destroy();
resolve();
}
});
});
}
private getCacheHeaders(): Record<string, string> {
return this.expires <= 0 ?
{} :
{
// eslint-disable-next-line ts/naming-convention
'cache-control': `max-age=${this.expires}`,
expires: new Date(Date.now() + (this.expires * 1000)).toUTCString(),
};
}
}