diff --git a/config/util/representation-conversion/default.json b/config/util/representation-conversion/default.json index 3265be779..1d8cf1ca2 100644 --- a/config/util/representation-conversion/default.json +++ b/config/util/representation-conversion/default.json @@ -28,8 +28,15 @@ { "@type": "ErrorToTemplateConverter", "engine": { "@type": "HandlebarsTemplateEngine" }, - "templatePath": "$PACKAGE_ROOT/templates/error/error.hbs", - "contentType": "text/html" + "templatePath": "$PACKAGE_ROOT/templates/error/main.md", + "descriptions": "$PACKAGE_ROOT/templates/error/descriptions/", + "contentType": "text/markdown", + "extension": ".md" + }, + { + "@type": "MarkdownToHtmlConverter", + "engine": { "@type": "HandlebarsTemplateEngine" }, + "templatePath": "$PACKAGE_ROOT/templates/main.html" } ] } diff --git a/package-lock.json b/package-lock.json index 11dac2a4e..e0c94d927 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4822,6 +4822,11 @@ "resolved": "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz", "integrity": "sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w==" }, + "@types/marked": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-2.0.3.tgz", + "integrity": "sha512-lbhSN1rht/tQ+dSWxawCzGgTfxe9DB31iLgiT1ZVT5lshpam/nyOA1m3tKHRoNPctB2ukSL22JZI5Fr+WI/zYg==" + }, "@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -11427,8 +11432,7 @@ "marked": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/marked/-/marked-2.1.3.tgz", - "integrity": "sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA==", - "dev": true + "integrity": "sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA==" }, "media-typer": { "version": "0.3.0", diff --git a/package.json b/package.json index 4f3e8a0f7..757050336 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@types/bcrypt": "^5.0.0", "@types/cors": "^2.8.10", "@types/end-of-stream": "^1.4.0", + "@types/marked": "^2.0.3", "@types/mime-types": "^2.1.0", "@types/n3": "^1.10.0", "@types/node": "^15.12.5", @@ -112,6 +113,7 @@ "fetch-sparql-endpoint": "^2.0.1", "handlebars": "^4.7.7", "jose": "^3.11.6", + "marked": "^2.1.3", "mime-types": "^2.1.31", "n3": "^1.10.0", "nodemailer": "^6.6.2", diff --git a/src/index.ts b/src/index.ts index c59bc9bec..9e9a1e7fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -229,6 +229,7 @@ export * from './storage/conversion/ConversionUtil'; export * from './storage/conversion/ErrorToTemplateConverter'; export * from './storage/conversion/ErrorToQuadConverter'; export * from './storage/conversion/IfNeededConverter'; +export * from './storage/conversion/MarkdownToHtmlConverter'; export * from './storage/conversion/PassthroughConverter'; export * from './storage/conversion/QuadToRdfConverter'; export * from './storage/conversion/RdfToQuadConverter'; diff --git a/src/storage/conversion/ErrorToTemplateConverter.ts b/src/storage/conversion/ErrorToTemplateConverter.ts index d36e9649d..f296b5afb 100644 --- a/src/storage/conversion/ErrorToTemplateConverter.ts +++ b/src/storage/conversion/ErrorToTemplateConverter.ts @@ -4,25 +4,38 @@ import { BasicRepresentation } from '../../ldp/representation/BasicRepresentatio import type { Representation } from '../../ldp/representation/Representation'; import type { TemplateEngine } from '../../pods/generate/TemplateEngine'; import { INTERNAL_ERROR } from '../../util/ContentTypes'; +import { HttpError } from '../../util/errors/HttpError'; import { InternalServerError } from '../../util/errors/InternalServerError'; -import { resolveAssetPath } from '../../util/PathUtil'; +import { joinFilePath, resolveAssetPath } from '../../util/PathUtil'; import type { RepresentationConverterArgs } from './RepresentationConverter'; import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * Serializes an Error by filling in the provided template. * Content-type is based on the constructor parameter. + * + * In case the input Error has an `options.errorCode` value, + * the converter will look in the `descriptions` for a file + * with the exact same name as that error code + `extension`. + * The templating engine will then be applied to that file. + * That result will be passed as an additional parameter to the main templating call, + * using the variable `codeMessage`. */ export class ErrorToTemplateConverter extends TypedRepresentationConverter { private readonly engine: TemplateEngine; private readonly templatePath: string; + private readonly descriptions: string; private readonly contentType: string; + private readonly extension: string; - public constructor(engine: TemplateEngine, templatePath: string, contentType: string) { + public constructor(engine: TemplateEngine, templatePath: string, descriptions: string, contentType: string, + extension: string) { super(INTERNAL_ERROR, contentType); this.engine = engine; this.templatePath = resolveAssetPath(templatePath); + this.descriptions = resolveAssetPath(descriptions); this.contentType = contentType; + this.extension = extension; } public async handle({ representation }: RepresentationConverterArgs): Promise { @@ -34,10 +47,26 @@ export class ErrorToTemplateConverter extends TypedRepresentationConverter { // Render the template const { name, message, stack } = error; - const variables = { name, message, stack }; + const description = await this.getErrorCodeMessage(error); + const variables = { name, message, stack, description }; const template = await fsPromises.readFile(this.templatePath, 'utf8'); - const html = this.engine.apply(template, variables); + const rendered = this.engine.apply(template, variables); - return new BasicRepresentation(html, representation.metadata, this.contentType); + return new BasicRepresentation(rendered, representation.metadata, this.contentType); + } + + private async getErrorCodeMessage(error: Error): Promise { + if (HttpError.isInstance(error) && error.options.errorCode) { + const filePath = joinFilePath(this.descriptions, `${error.options.errorCode}${this.extension}`); + let template: string; + try { + template = await fsPromises.readFile(filePath, 'utf8'); + } catch { + // In case no template is found we still want to convert + return; + } + + return this.engine.apply(template, (error.options.details ?? {}) as NodeJS.Dict); + } } } diff --git a/src/storage/conversion/MarkdownToHtmlConverter.ts b/src/storage/conversion/MarkdownToHtmlConverter.ts new file mode 100644 index 000000000..e17466fff --- /dev/null +++ b/src/storage/conversion/MarkdownToHtmlConverter.ts @@ -0,0 +1,42 @@ +import { promises as fsPromises } from 'fs'; +import marked from 'marked'; +import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; +import type { Representation } from '../../ldp/representation/Representation'; +import type { TemplateEngine } from '../../pods/generate/TemplateEngine'; +import { TEXT_HTML, TEXT_MARKDOWN } from '../../util/ContentTypes'; +import { resolveAssetPath } from '../../util/PathUtil'; +import { readableToString } from '../../util/StreamUtil'; +import type { RepresentationConverterArgs } from './RepresentationConverter'; +import { TypedRepresentationConverter } from './TypedRepresentationConverter'; + +/** + * Converts markdown data to HTML. + * The generated HTML will be injected into the given template using the parameter `htmlBody`. + * A standard markdown string will be converted to a

tag, so html and body tags should be part of the template. + * In case the markdown body starts with a header (#), that value will also be used as `title` parameter. + */ +export class MarkdownToHtmlConverter extends TypedRepresentationConverter { + private readonly engine: TemplateEngine; + private readonly templatePath: string; + + public constructor(engine: TemplateEngine, templatePath: string) { + super(TEXT_MARKDOWN, TEXT_HTML); + this.engine = engine; + this.templatePath = resolveAssetPath(templatePath); + } + + public async handle({ representation }: RepresentationConverterArgs): Promise { + const markdown = await readableToString(representation.data); + + // See if there is a title we can use + const match = /^\s*#+\s*([^\n]+)\n/u.exec(markdown); + const title = match?.[1]; + + const htmlBody = marked(markdown); + + const template = await fsPromises.readFile(this.templatePath, 'utf8'); + const html = this.engine.apply(template, { htmlBody, title }); + + return new BasicRepresentation(html, representation.metadata, TEXT_HTML); + } +} diff --git a/src/util/ContentTypes.ts b/src/util/ContentTypes.ts index 9163ab17d..1d0bce73d 100644 --- a/src/util/ContentTypes.ts +++ b/src/util/ContentTypes.ts @@ -4,6 +4,7 @@ export const APPLICATION_OCTET_STREAM = 'application/octet-stream'; export const APPLICATION_SPARQL_UPDATE = 'application/sparql-update'; export const APPLICATION_X_WWW_FORM_URLENCODED = 'application/x-www-form-urlencoded'; export const TEXT_HTML = 'text/html'; +export const TEXT_MARKDOWN = 'text/markdown'; export const TEXT_TURTLE = 'text/turtle'; // Internal content types (not exposed over HTTP) diff --git a/src/util/errors/HttpError.ts b/src/util/errors/HttpError.ts index 431513cc6..05dc3c6e6 100644 --- a/src/util/errors/HttpError.ts +++ b/src/util/errors/HttpError.ts @@ -3,6 +3,7 @@ import { isError } from './ErrorUtil'; export interface HttpErrorOptions { cause?: unknown; errorCode?: string; + details?: NodeJS.Dict; } /** diff --git a/src/util/identifiers/BaseIdentifierStrategy.ts b/src/util/identifiers/BaseIdentifierStrategy.ts index 529a7fae4..93bb3b7bb 100644 --- a/src/util/identifiers/BaseIdentifierStrategy.ts +++ b/src/util/identifiers/BaseIdentifierStrategy.ts @@ -13,7 +13,8 @@ export abstract class BaseIdentifierStrategy implements IdentifierStrategy { public getParentContainer(identifier: ResourceIdentifier): ResourceIdentifier { if (!this.supportsIdentifier(identifier)) { - throw new InternalServerError(`The identifier ${identifier.path} is outside the configured identifier space.`); + throw new InternalServerError(`The identifier ${identifier.path} is outside the configured identifier space.`, + { errorCode: 'E0001', details: { path: identifier.path }}); } if (this.isRootContainer(identifier)) { throw new InternalServerError(`Cannot obtain the parent of ${identifier.path} because it is a root container.`); diff --git a/templates/error/descriptions/E0001 b/templates/error/descriptions/E0001 new file mode 100644 index 000000000..6831798bc --- /dev/null +++ b/templates/error/descriptions/E0001 @@ -0,0 +1,5 @@ +### Requests to `{{ path }}` are not supported +The Community Solid Server received a request for `{{ path }}`, which is not configured. +Here are some things you can try to fix this: + - Have you started the server with the right hostname? + - If you are running the server behind a reverse proxy, did you set up the `Forwarded` header correctly? diff --git a/templates/error/error.hbs b/templates/error/error.hbs deleted file mode 100644 index 26dcb9f23..000000000 --- a/templates/error/error.hbs +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - {{ name }} - - -

{{ name }}

-

{{ message }}

- {{#if stack}} -
{{ stack }}
- {{/if}} - - diff --git a/templates/error/main.md b/templates/error/main.md new file mode 100644 index 000000000..3e30f35f3 --- /dev/null +++ b/templates/error/main.md @@ -0,0 +1,14 @@ +# {{ name }} + +{{#if description}} +{{{ description }}} +{{/if}} + +## Technical details +{{ message }} + +{{#if stack}} +``` +{{ stack }} +``` +{{/if}} diff --git a/templates/main.html b/templates/main.html new file mode 100644 index 000000000..f4f52090f --- /dev/null +++ b/templates/main.html @@ -0,0 +1,14 @@ + + + + + + {{#if title}} + {{ title }} + {{/if}} + + +{{! Triple braces to prevent HTML escaping }} +{{{ htmlBody }}} + + diff --git a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts index 4785c52d6..7fc33ca50 100644 --- a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts +++ b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts @@ -4,29 +4,32 @@ import { ErrorToTemplateConverter } from '../../../../src/storage/conversion/Err import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; import { readableToString } from '../../../../src/util/StreamUtil'; +import { mockFs } from '../../../util/Util'; -const mockRead = jest.fn().mockResolvedValue('{{ template }}'); -jest.mock('fs', (): any => ({ - promises: { readFile: (...args: any[]): any => mockRead(...args) }, -})); +jest.mock('fs'); describe('An ErrorToTemplateConverter', (): void => { + let cache: { data: any }; const identifier = { path: 'http://test.com/error' }; + const templatePath = '/templates/error.template'; + const descriptions = '/templates/codes'; + const errorCode = 'E0001'; let engine: TemplateEngine; - const path = '/template/error.template'; let converter: ErrorToTemplateConverter; const preferences = {}; beforeEach(async(): Promise => { - mockRead.mockClear(); + cache = mockFs('/templates'); + cache.data['error.template'] = '{{ template }}'; + cache.data.codes = { [`${errorCode}.html`]: '{{{ errorText }}}' }; engine = { apply: jest.fn().mockReturnValue(''), }; - converter = new ErrorToTemplateConverter(engine, path, 'text/html'); + converter = new ErrorToTemplateConverter(engine, templatePath, descriptions, 'text/html', '.html'); }); - it('supports going from errors to quads.', async(): Promise => { + it('supports going from errors to the given content type.', async(): Promise => { await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 }); await expect(converter.getOutputTypes()).resolves.toEqual({ 'text/html': 1 }); }); @@ -47,8 +50,6 @@ describe('An ErrorToTemplateConverter', (): void => { expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe(''); - expect(mockRead).toHaveBeenCalledTimes(1); - expect(mockRead).toHaveBeenLastCalledWith(path, 'utf8'); expect(engine.apply).toHaveBeenCalledTimes(1); expect(engine.apply).toHaveBeenLastCalledWith( '{{ template }}', { name: 'BadRequestHttpError', message: 'error text', stack: error.stack }, @@ -65,11 +66,63 @@ describe('An ErrorToTemplateConverter', (): void => { expect(result.binary).toBe(true); expect(result.metadata.contentType).toBe('text/html'); await expect(readableToString(result.data)).resolves.toBe(''); - expect(mockRead).toHaveBeenCalledTimes(1); - expect(mockRead).toHaveBeenLastCalledWith(path, 'utf8'); expect(engine.apply).toHaveBeenCalledTimes(1); expect(engine.apply).toHaveBeenLastCalledWith( '{{ template }}', { name: 'BadRequestHttpError', message: 'error text' }, ); }); + + it('adds additional information if an error code is found.', async(): Promise => { + const error = new BadRequestHttpError('error text', { errorCode, details: { key: 'val' }}); + const representation = new BasicRepresentation([ error ], 'internal/error', false); + const prom = converter.handle({ identifier, representation, preferences }); + await expect(prom).resolves.toBeDefined(); + const result = await prom; + expect(result.binary).toBe(true); + expect(result.metadata.contentType).toBe('text/html'); + await expect(readableToString(result.data)).resolves.toBe(''); + expect(engine.apply).toHaveBeenCalledTimes(2); + expect(engine.apply).toHaveBeenCalledWith( + '{{{ errorText }}}', { key: 'val' }, + ); + expect(engine.apply).toHaveBeenLastCalledWith( + '{{ template }}', + { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '' }, + ); + }); + + it('sends an empty object for additional error code parameters if none are defined.', async(): Promise => { + const error = new BadRequestHttpError('error text', { errorCode }); + const representation = new BasicRepresentation([ error ], 'internal/error', false); + const prom = converter.handle({ identifier, representation, preferences }); + await expect(prom).resolves.toBeDefined(); + const result = await prom; + expect(result.binary).toBe(true); + expect(result.metadata.contentType).toBe('text/html'); + await expect(readableToString(result.data)).resolves.toBe(''); + expect(engine.apply).toHaveBeenCalledTimes(2); + expect(engine.apply).toHaveBeenCalledWith( + '{{{ errorText }}}', { }, + ); + expect(engine.apply).toHaveBeenLastCalledWith( + '{{ template }}', + { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '' }, + ); + }); + + it('converts errors with a code as usual if no corresponding template is found.', async(): Promise => { + const error = new BadRequestHttpError('error text', { errorCode: 'invalid' }); + const representation = new BasicRepresentation([ error ], 'internal/error', false); + const prom = converter.handle({ identifier, representation, preferences }); + await expect(prom).resolves.toBeDefined(); + const result = await prom; + expect(result.binary).toBe(true); + expect(result.metadata.contentType).toBe('text/html'); + await expect(readableToString(result.data)).resolves.toBe(''); + expect(engine.apply).toHaveBeenCalledTimes(1); + expect(engine.apply).toHaveBeenLastCalledWith( + '{{ template }}', + { name: 'BadRequestHttpError', message: 'error text', stack: error.stack }, + ); + }); }); diff --git a/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts b/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts new file mode 100644 index 000000000..6eea42c3f --- /dev/null +++ b/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts @@ -0,0 +1,61 @@ +import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; +import type { TemplateEngine } from '../../../../src/pods/generate/TemplateEngine'; +import { MarkdownToHtmlConverter } from '../../../../src/storage/conversion/MarkdownToHtmlConverter'; +import { readableToString } from '../../../../src/util/StreamUtil'; +import { mockFs } from '../../../util/Util'; + +jest.mock('fs'); + +describe('A MarkdownToHtmlConverter', (): void => { + let cache: { data: any }; + const identifier = { path: 'http://test.com/text' }; + const templatePath = '/templates/error.template'; + const preferences = {}; + let engine: TemplateEngine; + let converter: MarkdownToHtmlConverter; + + beforeEach(async(): Promise => { + cache = mockFs('/templates'); + cache.data['error.template'] = '{{ template }}'; + engine = { + apply: jest.fn().mockReturnValue(''), + }; + + converter = new MarkdownToHtmlConverter(engine, templatePath); + }); + + it('supports going from markdown to html.', async(): Promise => { + await expect(converter.getInputTypes()).resolves.toEqual({ 'text/markdown': 1 }); + await expect(converter.getOutputTypes()).resolves.toEqual({ 'text/html': 1 }); + }); + + it('converts markdown and inserts it in the template.', async(): Promise => { + const markdown = 'Text `code` more text.'; + const representation = new BasicRepresentation(markdown, 'text/markdown', true); + const prom = converter.handle({ identifier, representation, preferences }); + await expect(prom).resolves.toBeDefined(); + const result = await prom; + expect(result.binary).toBe(true); + expect(result.metadata.contentType).toBe('text/html'); + await expect(readableToString(result.data)).resolves.toBe(''); + expect(engine.apply).toHaveBeenCalledTimes(1); + expect(engine.apply).toHaveBeenLastCalledWith( + '{{ template }}', { htmlBody: '

Text code more text.

\n' }, + ); + }); + + it('uses the main markdown header as title if there is one.', async(): Promise => { + const markdown = '# title text\nmore text'; + const representation = new BasicRepresentation(markdown, 'text/markdown', true); + const prom = converter.handle({ identifier, representation, preferences }); + await expect(prom).resolves.toBeDefined(); + const result = await prom; + expect(result.binary).toBe(true); + expect(result.metadata.contentType).toBe('text/html'); + await expect(readableToString(result.data)).resolves.toBe(''); + expect(engine.apply).toHaveBeenCalledTimes(1); + expect(engine.apply).toHaveBeenLastCalledWith( + '{{ template }}', { htmlBody: '

title text

\n

more text

\n', title: 'title text' }, + ); + }); +}); diff --git a/test/unit/util/identifiers/BaseIdentifierStrategy.test.ts b/test/unit/util/identifiers/BaseIdentifierStrategy.test.ts index 5686bf39d..a4675f2c7 100644 --- a/test/unit/util/identifiers/BaseIdentifierStrategy.test.ts +++ b/test/unit/util/identifiers/BaseIdentifierStrategy.test.ts @@ -22,6 +22,8 @@ describe('A BaseIdentifierStrategy', (): void => { it('errors when attempting to get the parent of an unsupported identifier.', async(): Promise => { expect((): any => strategy.getParentContainer({ path: '/unsupported' })) .toThrow('The identifier /unsupported is outside the configured identifier space.'); + expect((): any => strategy.getParentContainer({ path: '/unsupported' })) + .toThrow(expect.objectContaining({ options: { errorCode: 'E0001', details: { path: '/unsupported' }}})); }); it('errors when attempting to get the parent of a root container.', async(): Promise => {