because I got bored of customising my CV for every job
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(CVG-51): extract @cv/handlebars with safe defaults shared across cv-renderer and mail

Both @cv/cv-renderer and @cv/mail were instantiating Handlebars independently - cv-renderer with safety hardening (safeUrl, DOMPurify-sanitized markdown helper), mail with bare global Handlebars. Hoisted createHandlebars(opts?) into a new @cv/handlebars package. Pre-registers safe defaults (hasItems, join, eq, safeUrl, markdown), accepts an optional helpers and sanitizeOptions override for surface-specific tweaks. cv-renderer's HandlebarsEngine and mail's HandlebarsTemplateService now both call createHandlebars() with no override, so they share the safety hardening. Drops handlebars/marked/dompurify deps from cv-renderer (now via @cv/handlebars), keeps a peer-style handlebars dep for the type. All 91 cv-renderer tests still pass; api+worker boot via docker compose.

+232 -96
+3 -3
.docker/.manifests/packages__cv-renderer__package.json
··· 10 10 "typecheck": "tsc --noEmit" 11 11 }, 12 12 "dependencies": { 13 - "handlebars": "^4.7.9", 14 - "isomorphic-dompurify": "^2.16.0", 15 - "marked": "^15.0.0" 13 + "@cv/handlebars": "workspace:*", 14 + "handlebars": "^4.7.9" 16 15 }, 17 16 "devDependencies": { 18 17 "@cv/tsconfig": "*", 18 + "@types/jsdom": "^21.1.7", 19 19 "@types/node": "^22.7.5", 20 20 "jsdom": "^28.1.0", 21 21 "typescript": "^5.3.3",
+3 -3
packages/cv-renderer/package.json
··· 10 10 "typecheck": "tsc --noEmit" 11 11 }, 12 12 "dependencies": { 13 - "handlebars": "^4.7.9", 14 - "isomorphic-dompurify": "^2.16.0", 15 - "marked": "^15.0.0" 13 + "@cv/handlebars": "workspace:*", 14 + "handlebars": "^4.7.9" 16 15 }, 17 16 "devDependencies": { 18 17 "@cv/tsconfig": "*", 18 + "@types/jsdom": "^21.1.7", 19 19 "@types/node": "^22.7.5", 20 20 "jsdom": "^28.1.0", 21 21 "typescript": "^5.3.3",
+3 -81
packages/cv-renderer/src/engines/handlebars.engine.ts
··· 1 - import Handlebars from "handlebars"; 2 - import DOMPurify from "isomorphic-dompurify"; 3 - import { marked } from "marked"; 1 + import { createHandlebars } from "@cv/handlebars"; 2 + import type Handlebars from "handlebars"; 4 3 import type { TemplateEngine } from "../template-engine.interface"; 5 4 6 - marked.setOptions({ gfm: true, breaks: true }); 7 - 8 - // Restrict the rendered HTML to a small allowlist appropriate for CV body 9 - // content. marked emits these natively from markdown; anything else (script, 10 - // iframe, style, on* handlers, javascript: hrefs, data URIs, etc.) is stripped 11 - // by DOMPurify. Without this, malicious markdown (raw HTML passes through 12 - // marked unchanged) could XSS the iframe preview, exfil via <img src>, or 13 - // execute scripts in the headless Chromium that generates the PDF. 14 - const SANITIZE_OPTIONS = { 15 - ALLOWED_TAGS: [ 16 - "p", 17 - "br", 18 - "strong", 19 - "em", 20 - "u", 21 - "code", 22 - "pre", 23 - "blockquote", 24 - "ul", 25 - "ol", 26 - "li", 27 - "a", 28 - "h1", 29 - "h2", 30 - "h3", 31 - "h4", 32 - "h5", 33 - "h6", 34 - ], 35 - ALLOWED_ATTR: ["href", "title"], 36 - ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[/.#?])/i, 37 - }; 38 - 39 - const createHandlebarsInstance = (): typeof Handlebars => { 40 - const instance = Handlebars.create(); 41 - 42 - instance.registerHelper( 43 - "hasItems", 44 - (arr: unknown) => Array.isArray(arr) && arr.length > 0, 45 - ); 46 - 47 - instance.registerHelper( 48 - "join", 49 - (arr: unknown, separator: string) => 50 - Array.isArray(arr) ? arr.join(separator) : "", 51 - ); 52 - 53 - instance.registerHelper("eq", (a: unknown, b: unknown) => a === b); 54 - 55 - // Returns the input only if it is a string with an allow-listed URL scheme, 56 - // otherwise an empty string. Use anywhere user-provided URLs are 57 - // interpolated into href/src attributes - Handlebars HTML-escapes attribute 58 - // values but does not validate URL schemes, so `javascript:` / `data:` / 59 - // `vbscript:` payloads otherwise pass straight through and execute on click. 60 - instance.registerHelper("safeUrl", (input: unknown) => { 61 - if (typeof input !== "string") { 62 - return ""; 63 - } 64 - return /^(?:https?:|mailto:)/i.test(input.trim()) ? input : ""; 65 - }); 66 - 67 - // Renders a markdown string to sanitized HTML. Use with triple-stash 68 - // {{{markdown x}}} so the SafeString output isn't double-escaped. Templates 69 - // wrap the call in a block element since the helper can emit paragraphs and 70 - // lists. See SANITIZE_OPTIONS above for the allowlist. 71 - instance.registerHelper("markdown", (input: unknown) => { 72 - if (typeof input !== "string" || input.length === 0) { 73 - return new Handlebars.SafeString(""); 74 - } 75 - const rawHtml = marked.parse(input) as string; 76 - const sanitized = DOMPurify.sanitize(rawHtml, SANITIZE_OPTIONS); 77 - return new Handlebars.SafeString(sanitized); 78 - }); 79 - 80 - return instance; 81 - }; 82 - 83 5 export class HandlebarsEngine implements TemplateEngine { 84 6 readonly name = "handlebars"; 85 - private readonly hbs = createHandlebarsInstance(); 7 + private readonly hbs: typeof Handlebars = createHandlebars(); 86 8 87 9 render(template: string, context: object): string { 88 10 const compiled = this.hbs.compile(template);
+33
packages/handlebars/package.json
··· 1 + { 2 + "name": "@cv/handlebars", 3 + "version": "0.0.0", 4 + "private": true, 5 + "main": "./src/index.ts", 6 + "types": "./src/index.ts", 7 + "exports": { 8 + ".": { 9 + "require": "./src/index.ts", 10 + "import": "./src/index.ts", 11 + "types": "./src/index.ts" 12 + } 13 + }, 14 + "files": [ 15 + "src/" 16 + ], 17 + "scripts": { 18 + "lint": "biome check .", 19 + "lint:fix": "biome check --write .", 20 + "typecheck": "tsc -b" 21 + }, 22 + "dependencies": { 23 + "handlebars": "^4.7.9", 24 + "isomorphic-dompurify": "^2.16.0", 25 + "marked": "^15.0.0" 26 + }, 27 + "devDependencies": { 28 + "@biomejs/biome": "^2.2.6", 29 + "@cv/biome-config": "*", 30 + "@cv/tsconfig": "*", 31 + "typescript": "^5.6.3" 32 + } 33 + }
+111
packages/handlebars/src/index.ts
··· 1 + import Handlebars from "handlebars"; 2 + import DOMPurify from "isomorphic-dompurify"; 3 + import { marked } from "marked"; 4 + 5 + marked.setOptions({ gfm: true, breaks: true }); 6 + 7 + /** 8 + * DOMPurify allowlist: only the tags+attrs that the markdown helper emits 9 + * (p, br, lists, links, headings, basic emphasis, code blocks). Anything 10 + * else (script, iframe, style, on* handlers, javascript: hrefs, data URIs) 11 + * is stripped. Marked passes raw HTML through unchanged, so without this 12 + * filter a CV could XSS the preview iframe or exfil via crafted img src. 13 + */ 14 + export interface SanitizeOptions { 15 + ALLOWED_TAGS: string[]; 16 + ALLOWED_ATTR: string[]; 17 + ALLOWED_URI_REGEXP: RegExp; 18 + } 19 + 20 + export const DEFAULT_SANITIZE_OPTIONS: SanitizeOptions = { 21 + ALLOWED_TAGS: [ 22 + "p", "br", "strong", "em", "u", "code", "pre", "blockquote", 23 + "ul", "ol", "li", "a", 24 + "h1", "h2", "h3", "h4", "h5", "h6", 25 + ], 26 + ALLOWED_ATTR: ["href", "title"], 27 + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[/.#?])/i, 28 + }; 29 + 30 + export type Helpers = Record<string, Handlebars.HelperDelegate>; 31 + 32 + export interface CreateInstanceOptions { 33 + /** 34 + * Extra helpers registered after the safe defaults. A name collision with a 35 + * built-in helper overrides it - useful for surface-specific tweaks but be 36 + * intentional (e.g. only override markdown if you want a different 37 + * sanitizer policy). 38 + */ 39 + helpers?: Helpers; 40 + /** 41 + * Override the DOMPurify allowlist for the markdown helper. 42 + * Pass a stricter set for surfaces with lower trust (e.g. plain-text email 43 + * preview) or a more permissive set for trusted server-side templates. 44 + */ 45 + sanitizeOptions?: SanitizeOptions; 46 + } 47 + 48 + const safeDefaults = ( 49 + H: typeof Handlebars, 50 + sanitizeOptions: SanitizeOptions, 51 + ): Helpers => ({ 52 + hasItems: (arr: unknown) => Array.isArray(arr) && arr.length > 0, 53 + 54 + join: (arr: unknown, separator: string) => 55 + Array.isArray(arr) ? arr.join(separator) : "", 56 + 57 + eq: (a: unknown, b: unknown) => a === b, 58 + 59 + // Returns the input only if it is a string with an allow-listed URL scheme, 60 + // otherwise an empty string. Use anywhere user-provided URLs are 61 + // interpolated into href/src attributes - Handlebars HTML-escapes attribute 62 + // values but does not validate URL schemes, so `javascript:` / `data:` / 63 + // `vbscript:` payloads otherwise pass straight through and execute on click. 64 + safeUrl: (input: unknown) => { 65 + if (typeof input !== "string") { 66 + return ""; 67 + } 68 + return /^(?:https?:|mailto:)/i.test(input.trim()) ? input : ""; 69 + }, 70 + 71 + // Renders a markdown string to sanitized HTML. Use with triple-stash 72 + // {{{markdown x}}} so the SafeString output isn't double-escaped. Templates 73 + // wrap the call in a block element since the helper can emit paragraphs and 74 + // lists. 75 + markdown: (input: unknown) => { 76 + if (typeof input !== "string" || input.length === 0) { 77 + return new H.SafeString(""); 78 + } 79 + const rawHtml = marked.parse(input) as string; 80 + const sanitized = DOMPurify.sanitize(rawHtml, sanitizeOptions); 81 + return new H.SafeString(sanitized); 82 + }, 83 + }); 84 + 85 + /** 86 + * Create a Handlebars instance with safe defaults pre-registered. 87 + * 88 + * Defaults: 89 + * - hasItems(arr) — true if non-empty array 90 + * - join(arr, sep) — Array.prototype.join 91 + * - eq(a, b) — strict equality 92 + * - safeUrl(input) — returns input only if it is an http(s)/mailto: URL 93 + * - markdown(input) — sanitized markdown→HTML via marked + DOMPurify 94 + * 95 + * Pass `helpers` to add or override. Pass `sanitizeOptions` to customize 96 + * the DOMPurify allowlist. 97 + */ 98 + export const createHandlebars = ( 99 + options?: CreateInstanceOptions, 100 + ): typeof Handlebars => { 101 + const instance = Handlebars.create(); 102 + const sanitizeOptions = options?.sanitizeOptions ?? DEFAULT_SANITIZE_OPTIONS; 103 + const helpers = { 104 + ...safeDefaults(instance, sanitizeOptions), 105 + ...options?.helpers, 106 + }; 107 + for (const [name, fn] of Object.entries(helpers)) { 108 + instance.registerHelper(name, fn); 109 + } 110 + return instance; 111 + };
+12
packages/handlebars/tsconfig.json
··· 1 + { 2 + "extends": "@cv/tsconfig/tsconfig.library.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src", 6 + "baseUrl": ".", 7 + "composite": true, 8 + "declaration": true, 9 + "emitDeclarationOnly": true 10 + }, 11 + "include": ["src/**/*"] 12 + }
+1
packages/mail/package.json
··· 20 20 "typecheck": "tsc -b" 21 21 }, 22 22 "dependencies": { 23 + "@cv/handlebars": "workspace:*", 23 24 "@nestjs/common": "^11.1.18", 24 25 "@nestjs/config": "^4.0.4", 25 26 "@nestjs/core": "^11.1.18",
+4 -2
packages/mail/src/template/handlebars-template.service.ts
··· 1 1 import { existsSync, readFileSync } from "node:fs"; 2 2 import { join } from "node:path"; 3 3 import { Injectable } from "@nestjs/common"; 4 - import Handlebars from "handlebars"; 4 + import { createHandlebars } from "@cv/handlebars"; 5 + import type Handlebars from "handlebars"; 5 6 import { TemplateRegistryService } from "./template-registry.service"; 6 7 7 8 @Injectable() 8 9 export class HandlebarsTemplateService { 10 + private readonly hbs = createHandlebars(); 9 11 private readonly templateCache = new Map< 10 12 string, 11 13 Handlebars.TemplateDelegate ··· 44 46 } 45 47 46 48 const templateContent = readFileSync(fullPath, "utf-8"); 47 - const compiled = Handlebars.compile(templateContent, { strict: true }); 49 + const compiled = this.hbs.compile(templateContent, { strict: true }); 48 50 this.templateCache.set(templateName, compiled); 49 51 return compiled; 50 52 }
+62 -7
pnpm-lock.yaml
··· 838 838 839 839 packages/cv-renderer: 840 840 dependencies: 841 + '@cv/handlebars': 842 + specifier: workspace:* 843 + version: link:../handlebars 841 844 handlebars: 842 845 specifier: ^4.7.9 843 846 version: 4.7.9 844 - isomorphic-dompurify: 845 - specifier: ^2.16.0 846 - version: 2.36.0(@noble/hashes@1.8.0)(canvas@3.2.1) 847 - marked: 848 - specifier: ^15.0.0 849 - version: 15.0.12 850 847 devDependencies: 851 848 '@cv/tsconfig': 852 849 specifier: '*' 853 850 version: link:../tsconfig 851 + '@types/jsdom': 852 + specifier: ^21.1.7 853 + version: 21.1.7 854 854 '@types/node': 855 855 specifier: ^22.7.5 856 856 version: 22.19.3 ··· 941 941 specifier: ^4.0.0 942 942 version: 4.0.16(@types/node@22.19.3)(@vitest/ui@4.0.16)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0)(canvas@3.2.1))(lightningcss@1.30.2)(yaml@2.8.2) 943 943 944 + packages/handlebars: 945 + dependencies: 946 + handlebars: 947 + specifier: ^4.7.9 948 + version: 4.7.9 949 + isomorphic-dompurify: 950 + specifier: ^2.16.0 951 + version: 2.36.0(canvas@3.2.1) 952 + marked: 953 + specifier: ^15.0.0 954 + version: 15.0.12 955 + devDependencies: 956 + '@biomejs/biome': 957 + specifier: ^2.2.6 958 + version: 2.3.11 959 + '@cv/biome-config': 960 + specifier: '*' 961 + version: link:../biome-config 962 + '@cv/tsconfig': 963 + specifier: '*' 964 + version: link:../tsconfig 965 + typescript: 966 + specifier: ^5.6.3 967 + version: 5.9.3 968 + 944 969 packages/handlers: 945 970 dependencies: 946 971 '@cv/core': ··· 977 1002 978 1003 packages/mail: 979 1004 dependencies: 1005 + '@cv/handlebars': 1006 + specifier: workspace:* 1007 + version: link:../handlebars 980 1008 '@nestjs/common': 981 1009 specifier: ^11.1.18 982 1010 version: 11.1.18(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) ··· 3952 3980 '@types/inquirer@8.2.12': 3953 3981 resolution: {integrity: sha512-YxURZF2ZsSjU5TAe06tW0M3sL4UI9AMPA6dd8I72uOtppzNafcY38xkYgCZ/vsVOAyNdzHmvtTpLWilOrbP0dQ==} 3954 3982 3983 + '@types/jsdom@21.1.7': 3984 + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} 3985 + 3955 3986 '@types/jsonwebtoken@9.0.10': 3956 3987 resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} 3957 3988 ··· 4029 4060 '@types/through@0.0.33': 4030 4061 resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} 4031 4062 4063 + '@types/tough-cookie@4.0.5': 4064 + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} 4065 + 4032 4066 '@types/trusted-types@2.0.7': 4033 4067 resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} 4034 4068 ··· 5010 5044 enquirer@2.3.6: 5011 5045 resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} 5012 5046 engines: {node: '>=8.6'} 5047 + 5048 + entities@6.0.1: 5049 + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} 5050 + engines: {node: '>=0.12'} 5013 5051 5014 5052 entities@8.0.0: 5015 5053 resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} ··· 6969 7007 parse-url@8.1.0: 6970 7008 resolution: {integrity: sha512-xDvOoLU5XRrcOZvnI6b8zA6n9O9ejNk/GExuz1yBuWUGn9KA97GI6HTs6u02wKara1CeVmZhH+0TZFdWScR89w==} 6971 7009 7010 + parse5@7.3.0: 7011 + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} 7012 + 6972 7013 parse5@8.0.1: 6973 7014 resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} 6974 7015 ··· 12213 12254 '@types/through': 0.0.33 12214 12255 rxjs: 7.8.2 12215 12256 12257 + '@types/jsdom@21.1.7': 12258 + dependencies: 12259 + '@types/node': 22.19.3 12260 + '@types/tough-cookie': 4.0.5 12261 + parse5: 7.3.0 12262 + 12216 12263 '@types/jsonwebtoken@9.0.10': 12217 12264 dependencies: 12218 12265 '@types/ms': 2.1.0 ··· 12307 12354 '@types/through@0.0.33': 12308 12355 dependencies: 12309 12356 '@types/node': 22.19.3 12357 + 12358 + '@types/tough-cookie@4.0.5': {} 12310 12359 12311 12360 '@types/trusted-types@2.0.7': 12312 12361 optional: true ··· 13316 13365 dependencies: 13317 13366 ansi-colors: 4.1.3 13318 13367 13368 + entities@6.0.1: {} 13369 + 13319 13370 entities@8.0.0: {} 13320 13371 13321 13372 env-paths@2.2.1: {} ··· 14297 14348 14298 14349 isobject@3.0.1: {} 14299 14350 14300 - isomorphic-dompurify@2.36.0(@noble/hashes@1.8.0)(canvas@3.2.1): 14351 + isomorphic-dompurify@2.36.0(canvas@3.2.1): 14301 14352 dependencies: 14302 14353 dompurify: 3.4.2 14303 14354 jsdom: 28.1.0(@noble/hashes@1.8.0)(canvas@3.2.1) ··· 15846 15897 parse-url@8.1.0: 15847 15898 dependencies: 15848 15899 parse-path: 7.1.0 15900 + 15901 + parse5@7.3.0: 15902 + dependencies: 15903 + entities: 6.0.1 15849 15904 15850 15905 parse5@8.0.1: 15851 15906 dependencies: