Feat/s3 storage #1
@@ -24,6 +24,7 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
"handlebars": "^4.7.9",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"jwks-rsa": "^4.1.0",
|
"jwks-rsa": "^4.1.0",
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { renderTemplate, MissingTemplateVariableError } from './template.renderer';
|
||||||
|
|
||||||
|
describe('renderTemplate (pure)', () => {
|
||||||
|
it('substitutes variables into subject / html / text', () => {
|
||||||
|
const out = renderTemplate(
|
||||||
|
{ subject: 'Receipt for {{firstName}}', bodyHtml: '<p>Hi {{firstName}}</p>', bodyText: 'Hi {{firstName}}', variables: ['firstName'] },
|
||||||
|
{ firstName: 'Dana' },
|
||||||
|
);
|
||||||
|
expect(out.subject).toBe('Receipt for Dana');
|
||||||
|
expect(out.html).toBe('<p>Hi Dana</p>');
|
||||||
|
expect(out.text).toBe('Hi Dana');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The load-bearing safety property: a customer-supplied name goes into an HTML email body.
|
||||||
|
it('escapes HTML in the html body, but leaves subject/text verbatim (XSS)', () => {
|
||||||
|
const out = renderTemplate(
|
||||||
|
{ subject: 'Hi {{firstName}}', bodyHtml: '<p>{{firstName}}</p>', bodyText: '{{firstName}}', variables: ['firstName'] },
|
||||||
|
{ firstName: '<script>alert(1)</script>' },
|
||||||
|
);
|
||||||
|
expect(out.html).toBe('<p><script>alert(1)</script></p>');
|
||||||
|
expect(out.html).not.toContain('<script>');
|
||||||
|
expect(out.text).toBe('<script>alert(1)</script>'); // plaintext is not HTML → not escaped
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports {{#if}} and {{#each}}', () => {
|
||||||
|
const out = renderTemplate(
|
||||||
|
{ bodyText: '{{#if companyName}}Company: {{companyName}}\n{{/if}}{{#each items}}- {{this}}\n{{/each}}', variables: [] },
|
||||||
|
{ companyName: 'Acme', items: ['a', 'b'] },
|
||||||
|
);
|
||||||
|
expect(out.text).toBe('Company: Acme\n- a\n- b\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when a declared variable is missing (fail loud)', () => {
|
||||||
|
expect(() => renderTemplate({ bodyText: 'Hi {{firstName}}', variables: ['firstName'] }, {})).toThrow(MissingTemplateVariableError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders only the parts that are present', () => {
|
||||||
|
const out = renderTemplate({ bodyText: 'code {{code}}', variables: ['code'] }, { code: '123' });
|
||||||
|
expect(out.text).toBe('code 123');
|
||||||
|
expect(out.subject).toBeUndefined();
|
||||||
|
expect(out.html).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Handlebars from 'handlebars';
|
||||||
|
|
||||||
|
/** The raw, unrendered strings of a template (from the DB row or an inline source). */
|
||||||
|
export interface RawTemplate {
|
||||||
|
subject?: string | null;
|
||||||
|
bodyHtml?: string | null;
|
||||||
|
bodyText?: string | null;
|
||||||
|
/** Declared variable names. Every one MUST be supplied at render time (fail loud). */
|
||||||
|
variables?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderedContent {
|
||||||
|
subject?: string;
|
||||||
|
html?: string;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A declared variable was not supplied — we refuse to send a half-rendered "Hi ," message. */
|
||||||
|
export class MissingTemplateVariableError extends Error {
|
||||||
|
constructor(public readonly missing: string[]) {
|
||||||
|
super(`missing required template variable(s): ${missing.join(', ')}`);
|
||||||
|
this.name = 'MissingTemplateVariableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure render: interpolate `vars` into a template's strings. HTML output is auto-escaped (values
|
||||||
|
* like a customer name go into an email body — XSS risk); subject and plaintext are NOT escaped.
|
||||||
|
* No I/O — the caller supplies the raw template. Throws if any declared variable is absent.
|
||||||
|
*/
|
||||||
|
export function renderTemplate(tpl: RawTemplate, vars: Record<string, unknown>): RenderedContent {
|
||||||
|
const declared = tpl.variables ?? [];
|
||||||
|
const missing = declared.filter((name) => vars[name] === undefined || vars[name] === null);
|
||||||
|
if (missing.length > 0) throw new MissingTemplateVariableError(missing);
|
||||||
|
|
||||||
|
const out: RenderedContent = {};
|
||||||
|
if (tpl.subject != null) out.subject = compile(tpl.subject, false)(vars);
|
||||||
|
if (tpl.bodyHtml != null) out.html = compile(tpl.bodyHtml, true)(vars);
|
||||||
|
if (tpl.bodyText != null) out.text = compile(tpl.bodyText, false)(vars);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `escape=true` → HTML-escape interpolated values (for the html body); false → verbatim. */
|
||||||
|
function compile(src: string, escape: boolean): Handlebars.TemplateDelegate {
|
||||||
|
return Handlebars.compile(src, { noEscape: !escape, strict: false });
|
||||||
|
}
|
||||||
Generated
+30
@@ -364,6 +364,9 @@ importers:
|
|||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^16.4.7
|
specifier: ^16.4.7
|
||||||
version: 16.6.1
|
version: 16.6.1
|
||||||
|
handlebars:
|
||||||
|
specifier: ^4.7.9
|
||||||
|
version: 4.7.9
|
||||||
ioredis:
|
ioredis:
|
||||||
specifier: ^5.11.1
|
specifier: ^5.11.1
|
||||||
version: 5.11.1
|
version: 5.11.1
|
||||||
@@ -2224,6 +2227,11 @@ packages:
|
|||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
|
handlebars@4.7.9:
|
||||||
|
resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==}
|
||||||
|
engines: {node: '>=0.4.7'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
has-flag@4.0.0:
|
has-flag@4.0.0:
|
||||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -3052,6 +3060,11 @@ packages:
|
|||||||
ufo@1.6.4:
|
ufo@1.6.4:
|
||||||
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
|
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
|
||||||
|
|
||||||
|
uglify-js@3.19.3:
|
||||||
|
resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==}
|
||||||
|
engines: {node: '>=0.8.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
uid2@1.0.0:
|
uid2@1.0.0:
|
||||||
resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==}
|
resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==}
|
||||||
engines: {node: '>= 4.0.0'}
|
engines: {node: '>= 4.0.0'}
|
||||||
@@ -3243,6 +3256,9 @@ packages:
|
|||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
wordwrap@1.0.0:
|
||||||
|
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
|
||||||
|
|
||||||
wrap-ansi@6.2.0:
|
wrap-ansi@6.2.0:
|
||||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -5013,6 +5029,15 @@ snapshots:
|
|||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
|
handlebars@4.7.9:
|
||||||
|
dependencies:
|
||||||
|
minimist: 1.2.8
|
||||||
|
neo-async: 2.6.2
|
||||||
|
source-map: 0.6.1
|
||||||
|
wordwrap: 1.0.0
|
||||||
|
optionalDependencies:
|
||||||
|
uglify-js: 3.19.3
|
||||||
|
|
||||||
has-flag@4.0.0: {}
|
has-flag@4.0.0: {}
|
||||||
|
|
||||||
has-symbols@1.1.0: {}
|
has-symbols@1.1.0: {}
|
||||||
@@ -5821,6 +5846,9 @@ snapshots:
|
|||||||
|
|
||||||
ufo@1.6.4: {}
|
ufo@1.6.4: {}
|
||||||
|
|
||||||
|
uglify-js@3.19.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
uid2@1.0.0: {}
|
uid2@1.0.0: {}
|
||||||
|
|
||||||
uid@2.0.2:
|
uid@2.0.2:
|
||||||
@@ -6008,6 +6036,8 @@ snapshots:
|
|||||||
siginfo: 2.0.0
|
siginfo: 2.0.0
|
||||||
stackback: 0.0.2
|
stackback: 0.0.2
|
||||||
|
|
||||||
|
wordwrap@1.0.0: {}
|
||||||
|
|
||||||
wrap-ansi@6.2.0:
|
wrap-ansi@6.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
|
|||||||
Reference in New Issue
Block a user