Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
},
"dependencies": {
"@cvo/shared": "workspace:*",
"@prisma/client": "^6.2.1"
"@prisma/client": "^6.2.1",
"playwright-core": "^1.60.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
Expand Down
69 changes: 69 additions & 0 deletions apps/app/server/api/cv/export-pdf.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* POST /api/cv/export-pdf
* Corps : RenderableCv (JSON).
* Réponse : PDF binaire (application/pdf), Content-Disposition: attachment.
*
* Flux : parse Zod → assertValidCv → buildCvHtml → renderHtmlToPdf.
* Sécurité : données non loggées (RGPD) ; HTML échappé par buildCvHtml.
*/

import { z } from 'zod'
import { assertValidCv } from '@cvo/shared'
import type { RenderableCv } from '@cvo/shared'
import { buildCvHtml } from '../../utils/cv-html'
import { renderHtmlToPdf } from '../../utils/pdf'

const Provenance = z.object({ profileItemId: z.string(), reformulated: z.boolean() })
const Contact = z.object({ kind: z.enum(['email', 'phone', 'location', 'link']), label: z.string(), value: z.string() })
const Bullet = z.object({ id: z.string(), text: z.string(), provenance: Provenance })
const BaseEntry = z.object({ id: z.string(), provenance: Provenance })

const Section = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('summary'), title: z.string(), text: z.string(), provenance: Provenance }),
z.object({ kind: z.literal('experience'), title: z.string(), entries: z.array(BaseEntry.extend({ role: z.string(), organization: z.string(), period: z.string(), location: z.string().optional(), bullets: z.array(Bullet) })) }),
z.object({ kind: z.literal('skills'), title: z.string(), entries: z.array(BaseEntry.extend({ label: z.string() })) }),
z.object({ kind: z.literal('education'), title: z.string(), entries: z.array(BaseEntry.extend({ degree: z.string(), institution: z.string(), period: z.string() })) }),
])

const RenderableCvSchema = z.object({
header: z.object({ fullName: z.string().min(1), headline: z.string(), contacts: z.array(Contact), provenance: Provenance }),
sections: z.array(Section),
locale: z.literal('fr'),
})

export default defineEventHandler(async (event) => {
const raw = await readBody(event)
const parsed = RenderableCvSchema.safeParse(raw)
if (!parsed.success) {
throw createError({ statusCode: 400, message: 'Corps invalide : ' + parsed.error.message })
}
const cv = parsed.data as RenderableCv

// Garde-fou provenance. Au MVP : les profileItemIds déclarés dans le CV lui-même
// servent de proxy du profile_snapshot (THI-123 fournira les vrais ids).
try {
assertValidCv(cv, extractDeclaredIds(cv))
} catch (err) {
throw createError({ statusCode: 422, message: (err as Error).message })
}

const pdfBuffer = await renderHtmlToPdf(buildCvHtml(cv))

setHeader(event, 'Content-Type', 'application/pdf')
setHeader(event, 'Content-Disposition', 'attachment; filename="cv.pdf"')
return new Uint8Array(pdfBuffer)
})

/** Extrait tous les profileItemIds déclarés dans le CV (proxy de profile_snapshot au MVP). */
function extractDeclaredIds(cv: RenderableCv): Set<string> {
const ids = new Set<string>()
const add = (p: { profileItemId: string } | undefined) => { if (p?.profileItemId) ids.add(p.profileItemId) }

add(cv.header?.provenance)
for (const s of cv.sections ?? []) {
if (s.kind === 'summary') { add(s.provenance) }
else if (s.kind === 'experience') { for (const e of s.entries) { add(e.provenance); for (const b of e.bullets) add(b.provenance) } }
else if (s.kind === 'skills' || s.kind === 'education') { for (const e of s.entries) add(e.provenance) }
}
return ids
}
128 changes: 128 additions & 0 deletions apps/app/server/utils/cv-html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Rendu côté serveur (Nitro) d'un RenderableCv en HTML complet auto-suffisant.
* Aucune dépendance Vue côté serveur : la structure HTML est générée directement
* à partir des données, pour être passée à Chromium (export PDF).
*
* Les classes Tailwind utilisées ici sont un sous-ensemble des tokens @theme
* définis dans main.css. Elles sont inlinées en CSS natif dans la balise <style>
* pour que le document soit entièrement auto-suffisant (pas de CDN en headless).
*
* Doit rester synchronisé avec CvTemplate.vue. Si le design du composant Vue
* évolue, mettre à jour `TOKENS` et la fonction `html()` en parallèle.
*
* Sécurité : `escape()` appliqué sur toutes les données utilisateur — pas
* d'injection XSS possible dans le HTML rendu.
*/

import type { RenderableCv, CvSection } from '@cvo/shared'

// Tokens synchronisés avec @theme de main.css + styles A4 auto-suffisants pour Chromium.
const CSS = `
:root{--b50:#eff6ff;--b100:#dbeafe;--b500:#3b82f6;--b600:#2563eb;--b700:#1d4ed8;--i5:#6b7280;--i7:#374151;--i9:#111827;--sf:#fff;--r:.75rem;--fn:"Inter",ui-sans-serif,system-ui,sans-serif}
*{box-sizing:border-box;margin:0;padding:0}
@page{size:A4 portrait;margin:12mm 14mm}
body{font-family:var(--fn);color:var(--i9);background:var(--sf);-webkit-print-color-adjust:exact;print-color-adjust:exact}
article{width:100%;max-width:210mm;margin:0 auto;font-size:14px;line-height:1.5}
header{padding:32px 40px;border-bottom:1px solid rgba(107,114,128,.2)}
h1{font-size:1.875rem;font-weight:700}
.hl{margin-top:4px;font-size:1rem;font-weight:500;color:var(--b600)}
.ct{margin-top:12px;display:flex;flex-wrap:wrap;gap:4px 20px;list-style:none}
.ct li{font-size:.875rem;color:var(--i5)}
main{padding:28px 40px;display:flex;flex-direction:column;gap:24px}
section{break-inside:avoid}
h2{font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--b600);border-bottom:1px solid var(--b100);padding-bottom:4px;margin-bottom:12px}
.sm{font-size:.875rem;line-height:1.625;color:var(--i7)}
.xe{display:flex;flex-direction:column;gap:20px}
.xr{break-inside:avoid}
.xh{display:flex;justify-content:space-between;align-items:flex-start;gap:16px}
.xn{font-size:.875rem;font-weight:600}
.xo{font-size:.875rem;color:var(--i7)}
.xp{font-size:.75rem;color:var(--i5);white-space:nowrap}
.bl{margin-top:8px;padding-left:16px;display:flex;flex-direction:column;gap:4px}
.bl li{font-size:.875rem;color:var(--i7)}
.bl li::marker{color:var(--b500)}
.sk{display:flex;flex-wrap:wrap;gap:8px;list-style:none}
.sk li{background:var(--b50);color:var(--b700);font-size:.75rem;font-weight:500;padding:4px 12px;border-radius:var(--r)}
.ee{display:flex;flex-direction:column;gap:12px}
.er{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;break-inside:avoid}
.ed{font-size:.875rem;font-weight:600}
.ei{font-size:.875rem;color:var(--i7)}
.ep{font-size:.75rem;color:var(--i5);white-space:nowrap}
`

/** Échappe les caractères HTML pour prévenir toute injection. */
function esc(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}

function renderContacts(cv: RenderableCv): string {
if (!cv.header.contacts.length) return ''
return `<ul class="ct">${cv.header.contacts.map((c) => `<li>${c.label ? `${esc(c.label)} · ` : ''}${esc(c.value)}</li>`).join('')}</ul>`
}

function renderSection(section: CvSection): string {
switch (section.kind) {
case 'summary':
return `<p class="sm">${esc(section.text)}</p>`

case 'experience': {
const rows = section.entries.map((e) => {
const loc = e.location ? ` · ${esc(e.location)}` : ''
const bl = e.bullets.length ? `<ul class="bl">${e.bullets.map((b) => `<li>${esc(b.text)}</li>`).join('')}</ul>` : ''
return `<div class="xr"><div class="xh"><div><p class="xn">${esc(e.role)}</p><p class="xo">${esc(e.organization)}${loc}</p></div><p class="xp">${esc(e.period)}</p></div>${bl}</div>`
})
return `<div class="xe">${rows.join('')}</div>`
}

case 'skills':
return `<ul class="sk">${section.entries.map((e) => `<li>${esc(e.label)}</li>`).join('')}</ul>`

case 'education': {
const rows = section.entries.map((e) => `<div class="er"><div><p class="ed">${esc(e.degree)}</p><p class="ei">${esc(e.institution)}</p></div><p class="ep">${esc(e.period)}</p></div>`)
return `<div class="ee">${rows.join('')}</div>`
}
}
}

/**
* Construit le document HTML complet auto-suffisant à partir d'un RenderableCv.
* Destiné à être passé à Chromium headless pour l'export PDF.
* Les données utilisateur sont systématiquement échappées.
*/
export function buildCvHtml(cv: RenderableCv): string {
const sectionsHtml = cv.sections
.map(
(s) => ` <section>
<h2>${esc(s.title)}</h2>
${renderSection(s)}
</section>`,
)
.join('\n\n')

return `<!DOCTYPE html>
<html lang="${esc(cv.locale)}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CV</title>
<style>${CSS}</style>
</head>
<body>
<article id="cv-render">
<header>
<h1>${esc(cv.header.fullName)}</h1>
<p class="hl">${esc(cv.header.headline)}</p>
${renderContacts(cv)}
</header>
<main>
${sectionsHtml}
</main>
</article>
</body>
</html>`
}
51 changes: 51 additions & 0 deletions apps/app/server/utils/pdf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Utilitaire serveur (Nitro) : rendu HTML → PDF via Chromium headless.
*
* Approche : on injecte le HTML du CV dans une page Chromium et on utilise
* l'API print-to-PDF native. Le CSS @media print (main.css) gère la mise en
* page A4, les marges et le break-inside-avoid.
*
* Chromium path : CHROMIUM_EXECUTABLE_PATH (env, pour Docker / CI) ou Chrome
* installé localement sur macOS (dev). En production on attend un container
* Chromium ou l'option Gotenberg (alternative évoquée dans THI-120 §4b).
*
* Sécurité : le HTML est généré côté serveur uniquement depuis un RenderableCv
* validé par le garde-fou (assertValidCv). Il n'est jamais stocké en clair ni
* loggé (RGPD).
*/

import { chromium } from 'playwright-core'

const MAC_CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'

function resolveChromiumPath(): string {
const env = process.env.CHROMIUM_EXECUTABLE_PATH
if (env) return env
// Fallback local macOS dev : Chrome installé.
return MAC_CHROME
}

/**
* Convertit un fragment HTML en PDF A4.
* @param html Fragment HTML complet (head + body) rendu par le template Vue.
* @returns ArrayBuffer du PDF.
*/
export async function renderHtmlToPdf(html: string): Promise<ArrayBuffer> {
const executablePath = resolveChromiumPath()
const browser = await chromium.launch({
executablePath,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
})
try {
const page = await browser.newPage()
await page.setContent(html, { waitUntil: 'networkidle' })
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '12mm', right: '14mm', bottom: '12mm', left: '14mm' },
})
return pdfBuffer.buffer as ArrayBuffer
} finally {
await browser.close()
}
}
112 changes: 112 additions & 0 deletions apps/app/test/cv-html.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, it, expect } from 'vitest'
import { buildCvHtml } from '../server/utils/cv-html'
import type { RenderableCv } from '@cvo/shared'

const demoCv: RenderableCv = {
locale: 'fr',
header: {
fullName: 'Camille Martin',
headline: 'Développeuse full-stack TypeScript',
contacts: [{ kind: 'email', label: 'Email', value: 'camille@exemple.fr' }],
provenance: { profileItemId: 'identity-1', reformulated: true },
},
sections: [
{
kind: 'summary',
title: 'Profil',
text: 'Développeuse orientée qualité.',
provenance: { profileItemId: 'summary-1', reformulated: true },
},
{
kind: 'experience',
title: 'Expériences',
entries: [
{
id: 'exp-1',
role: 'Développeuse full-stack',
organization: 'Studio Web SAS',
period: '2021 – 2024',
provenance: { profileItemId: 'exp-1', reformulated: true },
bullets: [
{
id: 'b1',
text: "Conception d'une app Vue 3.",
provenance: { profileItemId: 'exp-1-b1', reformulated: true },
},
],
},
],
},
{
kind: 'skills',
title: 'Compétences',
entries: [{ id: 'sk-1', label: 'TypeScript', provenance: { profileItemId: 'skill-ts', reformulated: false } }],
},
{
kind: 'education',
title: 'Formation',
entries: [
{
id: 'edu-1',
degree: 'Master Informatique',
institution: 'Université de Lyon',
period: '2019',
provenance: { profileItemId: 'edu-1', reformulated: false },
},
],
},
],
}

describe('buildCvHtml', () => {
it('produit un document HTML valide', () => {
const html = buildCvHtml(demoCv)
expect(html).toMatch(/^<!DOCTYPE html>/)
expect(html).toContain('<html lang="fr"')
expect(html).toContain('</html>')
})

it("inclut le nom et l'accroche", () => {
const html = buildCvHtml(demoCv)
expect(html).toContain('Camille Martin')
expect(html).toContain('Développeuse full-stack TypeScript')
})

it('inclut les contacts', () => {
const html = buildCvHtml(demoCv)
expect(html).toContain('camille@exemple.fr')
})

it('inclut toutes les sections', () => {
const html = buildCvHtml(demoCv)
expect(html).toContain('Profil')
expect(html).toContain('Expériences')
expect(html).toContain('Compétences')
expect(html).toContain('Formation')
})

it("inclut les puces d'expérience", () => {
const html = buildCvHtml(demoCv)
expect(html).toContain("Conception d&#39;une app Vue 3.")
})

it('échappe les caractères HTML potentiellement dangereux', () => {
const maliciousCv: RenderableCv = {
...demoCv,
header: {
...demoCv.header,
fullName: '<script>alert("xss")</script>',
},
sections: [],
}
const html = buildCvHtml(maliciousCv)
expect(html).not.toContain('<script>')
expect(html).toContain('&lt;script&gt;')
})

it('inclut les tokens CSS (@page A4, couleurs de marque)', () => {
const html = buildCvHtml(demoCv)
expect(html).toContain('A4 portrait')
expect(html).toContain('--b600')
})
})
Loading