diff --git a/config/presets/http.json b/config/presets/http.json index af0c58f66..c97d33bcf 100644 --- a/config/presets/http.json +++ b/config/presets/http.json @@ -51,7 +51,7 @@ "StaticAssetHandler:_assets": [ { "StaticAssetHandler:_assets_key": "/favicon.ico", - "StaticAssetHandler:_assets_value": "./templates/root/favicon.ico" + "StaticAssetHandler:_assets_value": "$PACKAGE_ROOT/templates/root/favicon.ico" } ] } diff --git a/src/server/middleware/StaticAssetHandler.ts b/src/server/middleware/StaticAssetHandler.ts index c6293c5e7..b91d31c93 100644 --- a/src/server/middleware/StaticAssetHandler.ts +++ b/src/server/middleware/StaticAssetHandler.ts @@ -5,7 +5,7 @@ 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 { joinFilePath, resolveAssetPath } from '../../util/PathUtil'; import { pipeSafely } from '../../util/StreamUtil'; import type { HttpHandlerInput } from '../HttpHandler'; import { HttpHandler } from '../HttpHandler'; @@ -13,6 +13,9 @@ import type { HttpRequest } from '../HttpRequest'; /** * Handler that serves static resources on specific paths. + * Relative file paths are assumed to be relative to cwd. + * Relative file paths can be preceded by $PACKAGE_ROOT/, e.g. $PACKAGE_ROOT/foo/bar, + * in case they need to be relative to the module root. */ export class StaticAssetHandler extends HttpHandler { private readonly mappings: Record; @@ -26,7 +29,10 @@ export class StaticAssetHandler extends HttpHandler { */ public constructor(assets: Record) { super(); - this.mappings = { ...assets }; + this.mappings = {}; + for (const [ url, path ] of Object.entries(assets)) { + this.mappings[url] = resolveAssetPath(path); + } this.pathMatcher = this.createPathMatcher(assets); } diff --git a/src/util/PathUtil.ts b/src/util/PathUtil.ts index 60fb6bb00..7bc41ea64 100644 --- a/src/util/PathUtil.ts +++ b/src/util/PathUtil.ts @@ -165,3 +165,23 @@ export function createSubdomainRegexp(baseUrl: string): RegExp { const { scheme, rest } = extractScheme(baseUrl); return new RegExp(`^${scheme}(?:([^/]+)\\.)?${rest}`, 'u'); } + +/** + * Returns the folder corresponding to the root of the Community Solid Server module + */ +export function getModuleRoot(): string { + return joinFilePath(__dirname, '../../'); +} + +/** + * Converts file path inputs into absolute paths. + * Works similar to `absoluteFilePath` but paths that start with '$PACKAGE_ROOT/' + * will be relative to the module directory instead of the cwd. + */ +export function resolveAssetPath(path: string): string { + const modulePath = '$PACKAGE_ROOT/'; + if (path.startsWith(modulePath)) { + return joinFilePath(getModuleRoot(), path.slice(modulePath.length)); + } + return absoluteFilePath(path); +} diff --git a/test/unit/server/middleware/StaticAssetHandler.test.ts b/test/unit/server/middleware/StaticAssetHandler.test.ts index 7b8ca071c..1d7690446 100644 --- a/test/unit/server/middleware/StaticAssetHandler.test.ts +++ b/test/unit/server/middleware/StaticAssetHandler.test.ts @@ -6,15 +6,18 @@ 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'; +import { getModuleRoot, joinFilePath } from '../../../../src/util/PathUtil'; const createReadStream = jest.spyOn(fs, 'createReadStream') .mockImplementation((): any => streamifyArray([ 'file contents' ])); -describe('a StaticAssetHandler', (): void => { +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/cwd': 'paths/cwd.txt', + '/foo/bar/module': '$PACKAGE_ROOT/paths/module.txt', '/foo/bar/folder1/': '/assets/folders/1/', '/foo/bar/folder2/': '/assets/folders/2', '/foo/bar/folder2/subfolder/': '/assets/folders/3', @@ -92,6 +95,30 @@ describe('a StaticAssetHandler', (): void => { expect(createReadStream).toHaveBeenCalledWith('/assets/bar.unknown'); }); + it('handles a request to a known URL with a relative file path.', async(): Promise => { + const request = { method: 'GET', url: '/foo/bar/cwd' }; + const response = createResponse({ eventEmitter: EventEmitter }); + await handler.handleSafe({ request, response } as any); + + expect(response.statusCode).toBe(200); + expect(response.getHeaders()).toHaveProperty('content-type', 'text/plain'); + + expect(createReadStream).toHaveBeenCalledTimes(1); + expect(createReadStream).toHaveBeenCalledWith(joinFilePath(process.cwd(), '/paths/cwd.txt')); + }); + + it('handles a request to a known URL with a relative to module file path.', async(): Promise => { + const request = { method: 'GET', url: '/foo/bar/module' }; + const response = createResponse({ eventEmitter: EventEmitter }); + await handler.handleSafe({ request, response } as any); + + expect(response.statusCode).toBe(200); + expect(response.getHeaders()).toHaveProperty('content-type', 'text/plain'); + + expect(createReadStream).toHaveBeenCalledTimes(1); + expect(createReadStream).toHaveBeenCalledWith(joinFilePath(getModuleRoot(), '/paths/module.txt')); + }); + it('throws a 404 when the asset does not exist.', async(): Promise => { const request = { method: 'GET', url: '/foo/bar/main' }; const response = createResponse({ eventEmitter: EventEmitter }); diff --git a/test/unit/util/PathUtil.test.ts b/test/unit/util/PathUtil.test.ts index b75b9be21..c6be92d3e 100644 --- a/test/unit/util/PathUtil.test.ts +++ b/test/unit/util/PathUtil.test.ts @@ -1,10 +1,11 @@ +import { existsSync } from 'fs'; import { absoluteFilePath, createSubdomainRegexp, decodeUriPathComponents, encodeUriPathComponents, - ensureTrailingSlash, extractScheme, getExtension, isContainerIdentifier, isContainerPath, + ensureTrailingSlash, extractScheme, getExtension, getModuleRoot, isContainerIdentifier, isContainerPath, joinFilePath, - normalizeFilePath, + normalizeFilePath, resolveAssetPath, toCanonicalUriPath, trimTrailingSlashes, } from '../../../src/util/PathUtil'; @@ -129,4 +130,35 @@ describe('PathUtil', (): void => { expect(regex.exec('http://alicetest.com/foo/')).toBeNull(); }); }); + + describe('#getModuleRoot', (): void => { + it('returns the root folder of the module.', async(): Promise => { + // Note that this test only makes sense as long as the dist folder is on the same level as the src folder + const root = getModuleRoot(); + const packageJson = joinFilePath(root, 'package.json'); + expect(existsSync(packageJson)).toBe(true); + }); + }); + + describe('#resolvePathInput', (): void => { + it('interprets paths relative to the module root when starting with $PACKAGE_ROOT/.', async(): Promise => { + expect(resolveAssetPath('$PACKAGE_ROOT/foo/bar')).toBe(joinFilePath(getModuleRoot(), '/foo/bar')); + }); + + it('handles ../ paths with $PACKAGE_ROOT/.', async(): Promise => { + expect(resolveAssetPath('$PACKAGE_ROOT/foo/bar/../baz')).toBe(joinFilePath(getModuleRoot(), '/foo/baz')); + }); + + it('leaves absolute paths as they are.', async(): Promise => { + expect(resolveAssetPath('/foo/bar/')).toBe('/foo/bar/'); + }); + + it('handles other paths relative to the cwd.', async(): Promise => { + expect(resolveAssetPath('foo/bar/')).toBe(joinFilePath(process.cwd(), 'foo/bar/')); + }); + + it('handles other paths with ../.', async(): Promise => { + expect(resolveAssetPath('foo/bar/../baz')).toBe(joinFilePath(process.cwd(), 'foo/baz')); + }); + }); });