feat: Respect root path for static assets and HTML links

* feat: rootpath for static assets and links1

* fix: static asset handler respects root path

* fix: use rootPath for links

* tests: fix the tests after adding consuctor params

* feat: change matchregex instead of stored URLs

* feat: baseUrl for handlebar engine and templates

* feat: full baseUrl passed to templates

* test: fix integration tests + templates

* chore: implement requested changes

* docs: Describe TemplateEngine interface changes

Co-authored-by: Joachim Van Herwegen <joachimvh@gmail.com>
This commit is contained in:
Jasper Vaneessen 2022-05-24 10:20:41 +02:00 committed by GitHub
parent 771d138037
commit 2814e72b34
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 105 additions and 43 deletions

View File

@ -42,6 +42,7 @@ These changes are relevant if you wrote custom modules for the server that depen
- `YargsCliExtractor` was changed to now take as input an array of parameter objects.
- `RedirectAllHttpHandler` was removed and fully replaced by `RedirectingHttpHandler`.
- `SingleThreadedResourceLocker` has been renamed to `MemoryResourceLocker`.
- Both `TemplateEngine` implementations now take a `baseUrl` parameter as input.
A new interface `SingleThreaded` has been added. This empty interface can be implemented to mark a component as not-threadsafe. When the CSS starts in multithreaded mode, it will error and halt if any SingleThreaded components are instantiated.

View File

@ -17,7 +17,10 @@
"@type": "TemplatedResourcesGenerator",
"templateFolder": "@css:templates/root/prefilled",
"factory": { "@type": "ExtensionBasedMapperFactory" },
"templateEngine": { "@type": "HandlebarsTemplateEngine" }
"templateEngine": {
"@type": "HandlebarsTemplateEngine",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}
},
"args_storageKey": "rootInitialized",
"args_storage": { "@id": "urn:solid-server:default:SetupStorage" }

View File

@ -17,7 +17,10 @@
"@type": "TemplatedResourcesGenerator",
"templateFolder": "@css:templates/root/empty",
"factory": { "@type": "ExtensionBasedMapperFactory" },
"templateEngine": { "@type": "HandlebarsTemplateEngine" }
"templateEngine": {
"@type": "HandlebarsTemplateEngine",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}
},
"args_storageKey": "rootInitialized",
"args_storage": { "@id": "urn:solid-server:default:SetupStorage" }

View File

@ -27,12 +27,14 @@
{
"comment": "Renders the main setup template.",
"@type": "EjsTemplateEngine",
"template": "@css:templates/setup/index.html.ejs"
"template": "@css:templates/setup/index.html.ejs",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
},
{
"comment": "Will embed the result of the first engine into the main HTML template.",
"@type": "EjsTemplateEngine",
"template": "@css:templates/main.html.ejs"
"template": "@css:templates/main.html.ejs",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}
]
}
@ -60,7 +62,10 @@
"@type": "TemplatedResourcesGenerator",
"templateFolder": "@css:templates/root/empty",
"factory": { "@type": "ExtensionBasedMapperFactory" },
"templateEngine": { "@type": "HandlebarsTemplateEngine" }
"templateEngine": {
"@type": "HandlebarsTemplateEngine",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}
},
"args_storageKey": "rootInitialized",
"args_storage": { "@id": "urn:solid-server:default:SetupStorage" }

View File

@ -6,6 +6,7 @@
"@id": "urn:solid-server:default:StaticAssetHandler",
"@type": "StaticAssetHandler",
"options_expires": 86400,
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"assets": [
{
"StaticAssetHandler:_assets_key": "/favicon.ico",

View File

@ -17,7 +17,10 @@
"@type": "TemplatedResourcesGenerator",
"templateFolder": "@css:templates/root/empty",
"factory": { "@type": "ExtensionBasedMapperFactory" },
"templateEngine": { "@type": "HandlebarsTemplateEngine" }
"templateEngine": {
"@type": "HandlebarsTemplateEngine",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}
},
"args_storageKey": "idpContainerInitialized",
"args_storage": { "@id": "urn:solid-server:default:SetupStorage" }

View File

@ -17,7 +17,10 @@
"@type": "TemplatedResourcesGenerator",
"templateFolder": "@css:templates/root/empty",
"factory": { "@type": "ExtensionBasedMapperFactory" },
"templateEngine": { "@type": "HandlebarsTemplateEngine" }
"templateEngine": {
"@type": "HandlebarsTemplateEngine",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}
},
"args_storageKey": "wellKnownContainerInitialized",
"args_storage": { "@id": "urn:solid-server:default:SetupStorage" }

View File

@ -17,7 +17,8 @@
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
"args_templateEngine": {
"@type": "EjsTemplateEngine",
"template": "@css:templates/identity/email-password/reset-password-email.html.ejs"
"template": "@css:templates/identity/email-password/reset-password-email.html.ejs",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
},
"args_emailSender": { "@id": "urn:solid-server:default:EmailSender" },
"args_resetRoute": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" }

View File

@ -12,12 +12,14 @@
"engines": [
{
"comment": "Will be called with specific templates to generate HTML snippets.",
"@type": "EjsTemplateEngine"
"@type": "EjsTemplateEngine",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
},
{
"comment": "Will embed the result of the first engine into the main HTML template.",
"@type": "EjsTemplateEngine",
"template": "@css:templates/main.html.ejs"
"template": "@css:templates/main.html.ejs",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}
]
},

View File

@ -16,6 +16,7 @@
}
]
},
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"configStorage": { "@id": "urn:solid-server:default:PodConfigurationStorage" }
}
]

View File

@ -10,7 +10,8 @@
"@type": "ExtensionBasedMapperFactory"
},
"templateEngine": {
"@type": "HandlebarsTemplateEngine"
"@type": "HandlebarsTemplateEngine",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}
}
]

View File

@ -12,12 +12,14 @@
"engines": [
{
"comment": "Will be called with specific templates to generate HTML snippets.",
"@type": "EjsTemplateEngine"
"@type": "EjsTemplateEngine",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
},
{
"comment": "Will embed the result of the first engine into the main HTML template.",
"@type": "EjsTemplateEngine",
"template": "@css:templates/main.html.ejs"
"template": "@css:templates/main.html.ejs",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}
]
}

View File

@ -13,7 +13,10 @@
"comment": "Converts an error into a Markdown description of its details.",
"@id": "urn:solid-server:default:ErrorToTemplateConverter",
"@type": "ErrorToTemplateConverter",
"templateEngine": { "@type": "HandlebarsTemplateEngine" }
"templateEngine": {
"@type": "HandlebarsTemplateEngine",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}
}
]
}

View File

@ -7,7 +7,8 @@
"@type": "MarkdownToHtmlConverter",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "@css:templates/main.html.ejs"
"template": "@css:templates/main.html.ejs",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}
},
{
@ -16,7 +17,8 @@
"@type": "ContainerToTemplateConverter",
"templateEngine": {
"@type": "HandlebarsTemplateEngine",
"template": "@css:templates/container.md.hbs"
"template": "@css:templates/container.md.hbs",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
},
"contentType": "text/markdown",
"identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" }

View File

@ -31,6 +31,7 @@ export class TemplatedPodGenerator implements PodGenerator {
private readonly variableHandler: VariableHandler;
private readonly configStorage: KeyValueStorage<string, unknown>;
private readonly configTemplatePath: string;
private readonly baseUrl: string;
/**
* @param storeFactory - Factory used for Components.js instantiation.
@ -39,10 +40,11 @@ export class TemplatedPodGenerator implements PodGenerator {
* @param configTemplatePath - Where to find the configuration templates.
*/
public constructor(storeFactory: ComponentsJsFactory, variableHandler: VariableHandler,
configStorage: KeyValueStorage<string, unknown>, configTemplatePath?: string) {
configStorage: KeyValueStorage<string, unknown>, baseUrl: string, configTemplatePath?: string) {
this.storeFactory = storeFactory;
this.variableHandler = variableHandler;
this.configStorage = configStorage;
this.baseUrl = baseUrl;
this.configTemplatePath = configTemplatePath ?? DEFAULT_CONFIG_PATH;
}
@ -75,7 +77,11 @@ export class TemplatedPodGenerator implements PodGenerator {
variables[TEMPLATE_VARIABLE.templateConfig] = joinFilePath(this.configTemplatePath, settings.template);
const store: ResourceStore =
await this.storeFactory.generate(variables[TEMPLATE_VARIABLE.templateConfig]!, TEMPLATE.ResourceStore, variables);
await this.storeFactory.generate(
variables[TEMPLATE_VARIABLE.templateConfig]!,
TEMPLATE.ResourceStore,
{ ...variables, 'urn:solid-server:default:variable:baseUrl': this.baseUrl },
);
this.logger.debug(`Generating store ${identifier.path} with variables ${JSON.stringify(variables)}`);
// Store the variables permanently

View File

@ -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, resolveAssetPath } from '../../util/PathUtil';
import { ensureTrailingSlash, joinFilePath, resolveAssetPath, trimLeadingSlashes } from '../../util/PathUtil';
import { pipeSafely } from '../../util/StreamUtil';
import type { HttpHandlerInput } from '../HttpHandler';
import { HttpHandler } from '../HttpHandler';
@ -29,22 +29,24 @@ export class StaticAssetHandler extends HttpHandler {
* where URL paths ending in a slash are interpreted as entire folders.
* @param options - Cache expiration time in seconds.
*/
public constructor(assets: Record<string, string>, options: { expires?: number } = {}) {
public constructor(assets: Record<string, string>, baseUrl: string, options: { expires?: number } = {}) {
super();
this.mappings = {};
const rootPath = ensureTrailingSlash(new URL(baseUrl).pathname);
for (const [ url, path ] of Object.entries(assets)) {
this.mappings[url] = resolveAssetPath(path);
this.mappings[trimLeadingSlashes(url)] = resolveAssetPath(path);
}
this.pathMatcher = this.createPathMatcher(assets);
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(assets: Record<string, string>): RegExp {
private createPathMatcher(rootPath: string): RegExp {
// Sort longest paths first to ensure the longest match has priority
const paths = Object.keys(assets)
const paths = Object.keys(this.mappings)
.sort((pathA, pathB): number => pathB.length - pathA.length);
// Collect regular expressions for files and folders separately
@ -55,7 +57,7 @@ export class StaticAssetHandler extends HttpHandler {
}
// Either match an exact document or a file within a folder (stripping the query string)
return new RegExp(`^(?:(${files.join('|')})|(${folders.join('|')})([^?]+))(?:\\?.*)?$`, 'u');
return new RegExp(`^${rootPath}(?:(${files.join('|')})|(${folders.join('|')})([^?]+))(?:\\?.*)?$`, 'u');
}
/**

View File

@ -97,6 +97,17 @@ export function ensureLeadingSlash(path: string): string {
return path.replace(/^\/*/u, '/');
}
/**
* Makes sure the input path has no slashes at the beginning.
*
* @param path - Path to check.
*
* @returns The potentially changed path.
*/
export function trimLeadingSlashes(path: string): string {
return path.replace(/^\/+/u, '');
}
/**
* Extracts the extension (without dot) from a path.
* Custom function since `path.extname` does not work on all cases (e.g. ".acl")

View File

@ -11,13 +11,17 @@ import Dict = NodeJS.Dict;
*/
export class EjsTemplateEngine<T extends Dict<any> = Dict<any>> implements TemplateEngine<T> {
private readonly applyTemplate: Promise<TemplateFunction>;
private readonly baseUrl: string;
/**
* @param baseUrl - Base URL of the server.
* @param template - The default template @range {json}
*/
public constructor(template?: Template) {
public constructor(baseUrl: string, template?: Template) {
// EJS requires the `filename` parameter to be able to include partial templates
const filename = getTemplateFilePath(template);
this.baseUrl = baseUrl;
this.applyTemplate = readTemplate(template)
.then((templateString: string): TemplateFunction => compile(templateString, { filename }));
}
@ -25,7 +29,7 @@ export class EjsTemplateEngine<T extends Dict<any> = Dict<any>> implements Templ
public async render(contents: T): Promise<string>;
public async render<TCustom = T>(contents: TCustom, template: Template): Promise<string>;
public async render<TCustom = T>(contents: TCustom, template?: Template): Promise<string> {
const options = { ...contents, filename: getTemplateFilePath(template) };
const options = { ...contents, filename: getTemplateFilePath(template), baseUrl: this.baseUrl };
return template ? render(await readTemplate(template), options) : (await this.applyTemplate)(options);
}
}

View File

@ -11,11 +11,14 @@ import Dict = NodeJS.Dict;
*/
export class HandlebarsTemplateEngine<T extends Dict<any> = Dict<any>> implements TemplateEngine<T> {
private readonly applyTemplate: Promise<TemplateDelegate>;
private readonly baseUrl: string;
/**
* @params baseUrl - Base URL of the server.
* @param template - The default template @range {json}
*/
public constructor(template?: Template) {
public constructor(baseUrl: string, template?: Template) {
this.baseUrl = baseUrl;
this.applyTemplate = readTemplate(template)
.then((templateString: string): TemplateDelegate => compile(templateString));
}
@ -24,6 +27,6 @@ export class HandlebarsTemplateEngine<T extends Dict<any> = Dict<any>> implement
public async render<TCustom = T>(contents: TCustom, template: Template): Promise<string>;
public async render<TCustom = T>(contents: TCustom, template?: Template): Promise<string> {
const applyTemplate = template ? compile(await readTemplate(template)) : await this.applyTemplate;
return applyTemplate(contents);
return applyTemplate({ ...contents, baseUrl: this.baseUrl });
}
}

View File

@ -3,7 +3,8 @@
"import": [
"css:config/util/auxiliary/acl.json",
"css:config/util/index/default.json",
"css:config/util/representation-conversion/default.json"
"css:config/util/representation-conversion/default.json",
"css:config/util/variables/default.json"
],
"@graph": [
{

View File

@ -4,12 +4,12 @@
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title><%= extractTitle(htmlBody) %></title>
<link rel="stylesheet" href="/.well-known/css/styles/main.css" type="text/css">
<script type="text/javascript" src="/.well-known/css/scripts/util.js"></script>
<link rel="stylesheet" href="<%= baseUrl -%>.well-known/css/styles/main.css" type="text/css">
<script type="text/javascript" src="<%= baseUrl -%>.well-known/css/scripts/util.js"></script>
</head>
<body>
<header>
<a href="/"><img src="/.well-known/css/images/solid.svg" alt="[Solid logo]" /></a>
<a href="<%= baseUrl %>"><img src="<%= baseUrl -%>.well-known/css/images/solid.svg" alt="[Solid logo]" /></a>
<h1>Community Solid Server</h1>
</header>
<main>

View File

@ -13,6 +13,7 @@ describe('A TemplatedPodGenerator', (): void => {
const template = 'config-template.json';
const templatePath = `${configTemplatePath}${template}`;
const identifier = { path: 'http://test.com/alice/' };
const baseUrl = 'http://test.com';
let settings: PodSettings;
let storeFactory: ComponentsJsFactory;
let variableHandler: VariableHandler;
@ -32,7 +33,7 @@ describe('A TemplatedPodGenerator', (): void => {
configStorage = new Map<string, unknown>() as any;
generator = new TemplatedPodGenerator(storeFactory, variableHandler, configStorage, configTemplatePath);
generator = new TemplatedPodGenerator(storeFactory, variableHandler, configStorage, baseUrl, configTemplatePath);
});
it('only supports settings with a template.', async(): Promise<void> => {
@ -45,9 +46,11 @@ describe('A TemplatedPodGenerator', (): void => {
expect(variableHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(variableHandler.handleSafe).toHaveBeenLastCalledWith({ identifier, settings });
expect(storeFactory.generate).toHaveBeenCalledTimes(1);
expect(storeFactory.generate).toHaveBeenLastCalledWith(
templatePath, TEMPLATE.ResourceStore, { [TEMPLATE_VARIABLE.templateConfig]: templatePath },
);
expect(storeFactory.generate)
.toHaveBeenLastCalledWith(templatePath, TEMPLATE.ResourceStore, {
[TEMPLATE_VARIABLE.templateConfig]: templatePath,
'urn:solid-server:default:variable:baseUrl': baseUrl,
});
expect(configStorage.get(identifier.path)).toEqual({ [TEMPLATE_VARIABLE.templateConfig]: templatePath });
});
@ -72,13 +75,14 @@ describe('A TemplatedPodGenerator', (): void => {
});
it('uses a default template folder if none is provided.', async(): Promise<void> => {
generator = new TemplatedPodGenerator(storeFactory, variableHandler, configStorage);
generator = new TemplatedPodGenerator(storeFactory, variableHandler, configStorage, baseUrl);
const defaultPath = joinFilePath(__dirname, '../../../../templates/config/', template);
await expect(generator.generate(identifier, settings)).resolves.toBe('store');
expect(storeFactory.generate)
.toHaveBeenLastCalledWith(defaultPath, TEMPLATE.ResourceStore, {
[TEMPLATE_VARIABLE.templateConfig]: defaultPath,
'urn:solid-server:default:variable:baseUrl': baseUrl,
});
});
});

View File

@ -48,7 +48,7 @@ async function genToArray<T>(iterable: AsyncIterable<T>): Promise<T[]> {
describe('A TemplatedResourcesGenerator', (): void => {
const rootFilePath = '/templates/pod';
// Using handlebars engine since it's smaller than any possible dummy
const generator = new TemplatedResourcesGenerator(rootFilePath, new DummyFactory(), new HandlebarsTemplateEngine());
const generator = new TemplatedResourcesGenerator(rootFilePath, new DummyFactory(), new HandlebarsTemplateEngine('http://test.com/'));
let cache: { data: any };
const template = '<{{webId}}> a <http://xmlns.com/foaf/0.1/Person>.';
const location = { path: 'http://test.com/alice/' };

View File

@ -20,7 +20,7 @@ describe('A StaticAssetHandler', (): void => {
'/foo/bar/folder1/': '/assets/folders/1/',
'/foo/bar/folder2/': '/assets/folders/2',
'/foo/bar/folder2/subfolder/': '/assets/folders/3',
});
}, 'http://localhost:3000');
afterEach(jest.clearAllMocks);
@ -217,7 +217,7 @@ describe('A StaticAssetHandler', (): void => {
jest.spyOn(Date, 'now').mockReturnValue(0);
const cachedHandler = new StaticAssetHandler({
'/foo/bar/style': '/assets/styles/bar.css',
}, {
}, 'http://localhost:3000', {
expires: 86400,
});
const request = { method: 'GET', url: '/foo/bar/style' };

View File

@ -11,7 +11,7 @@ describe('A EjsTemplateEngine', (): void => {
let templateEngine: EjsTemplateEngine;
beforeEach((): void => {
templateEngine = new EjsTemplateEngine(defaultTemplate);
templateEngine = new EjsTemplateEngine('http://localhost:3000', defaultTemplate);
});
it('uses the default template when no template was passed.', async(): Promise<void> => {

View File

@ -10,7 +10,7 @@ describe('A HandlebarsTemplateEngine', (): void => {
let templateEngine: HandlebarsTemplateEngine;
beforeEach((): void => {
templateEngine = new HandlebarsTemplateEngine(template);
templateEngine = new HandlebarsTemplateEngine('http://localhost:3000/', template);
});
it('uses the default template when no template was passed.', async(): Promise<void> => {