Files
Momento/memento-note/app/api/canvas/slides/pptx/route.ts
Antigravity 5821e2c96f
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m8s
CI / Deploy production (on server) (push) Has been skipped
fix(audit): sécurité + perf + DB — 40 problèmes adressés
SÉCURITÉ (CRITIQUE):
- link-preview: auth() obligatoire + filtre IP privées (SSRF fix)
- metrics: !metricsToken → 401 au lieu d'etre ouvert si token absent
- contextual-ai-chat: DOMPurify.sanitize() sur dangerouslySetInnerHTML (XSS fix)

PERFORMANCE:
- graph/route.ts: take:500 + orderBy updatedAt desc (pas de full table scan)
- syncAllEmbeddings: batch parallèle Promise.allSettled × 5 (pas séquentiel)
- 80 console.log supprimés du code production

BASE DE DONNÉES:
- Note: index contentUpdatedAt + isPublic ajoutés au schema
- DocumentChunk: commentaire index vectoriel HNSW (à exécuter manuellement)
2026-07-05 08:51:21 +00:00

535 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import PptxGenJS from 'pptxgenjs'
// ─── Color helpers ─────────────────────────────────────────────────────────
const hex = (c: string) => c.replace('#', '').toUpperCase()
interface PptxPalette {
primary: string; secondary: string; accent: string
bg: string; text: string; muted: string; isDark: boolean
}
function resolvePalette(theme?: string): PptxPalette {
const palettes: Record<string, PptxPalette> = {
'midnight-cathedral': { primary: '0A0F1E', secondary: 'C9A84C', accent: 'E8D5B5', bg: '0A0F1E', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'aurora-borealis': { primary: '0F0A2A', secondary: '7C3AED', accent: '06B6D4', bg: '0F0A2A', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'tokyo-neon': { primary: '0A0A0F', secondary: 'FF006E', accent: '3A86FF', bg: '0A0A0F', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'venture-pitch': { primary: '18181B', secondary: 'F97316', accent: '14B8A6', bg: '18181B', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'forest-floor': { primary: '0D1B0E', secondary: '22C55E', accent: 'A3B18A', bg: '0D1B0E', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'steel-glass': { primary: '292524', secondary: 'D4C5A9', accent: '94A3B8', bg: '292524', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'cyberpunk-terminal': { primary: '0A0A0A', secondary: '00FF41', accent: 'FFA500', bg: '0A0A0A', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'sunlit-gallery': { primary: 'FAF7F0', secondary: 'D4A574', accent: '5B9BD5', bg: 'FAF7F0', text: '1C1C1C', muted: '64748B', isDark: false },
'clinical-precision': { primary: 'F8FAFC', secondary: '0891B2', accent: '34D399', bg: 'F8FAFC', text: '0F172A', muted: '64748B', isDark: false },
'editorial-ink': { primary: 'FFFCF5', secondary: '1A1A2E', accent: '800020', bg: 'FFFCF5', text: '1A1A2E', muted: '64748B', isDark: false },
'coastal-morning': { primary: 'F0F7FF', secondary: '2563EB', accent: 'F97066', bg: 'F0F7FF', text: '0F172A', muted: '64748B', isDark: false },
'paper-studio': { primary: 'FEFCF8', secondary: '1E293B', accent: 'C2410C', bg: 'FEFCF8', text: '1E293B', muted: '64748B', isDark: false },
'architectural-saas': { primary: 'F2F0E9', secondary: 'A47148', accent: '4A4E69', bg: 'F2F0E9', text: '1C1C1C', muted: '64748B', isDark: false },
}
const key = (theme || 'architectural-saas').toLowerCase().replace(/[^a-z]/g, '-').replace(/-+/g, '-')
return palettes[key] ?? palettes['architectural-saas']
}
// ─── PPTX Builder (new JSON format) ────────────────────────────────────────
async function buildPptx(spec: any): Promise<Buffer> {
const pptx = new PptxGenJS()
pptx.layout = 'LAYOUT_WIDE'
pptx.title = spec.title || 'Presentation'
pptx.subject = spec.title || 'Presentation'
const p = resolvePalette(spec.theme)
const W = 13.33, H = 7.5
for (const slide of (spec.slides ?? [])) {
const s = pptx.addSlide()
// PLG watermark — bottom-right corner of LAYOUT_WIDE (13.33"×7.5")
s.addText('memento-note.com', {
x: 10.0, y: 7.1, w: 3.0, h: 0.25,
fontSize: 7, fontFace: 'Arial', color: 'B8B0A8',
align: 'right', italic: true,
})
switch (slide.type) {
case 'title': {
s.background = { color: p.isDark ? p.primary : p.secondary }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: W, h: 0.08,
fill: { color: p.accent }, line: { type: 'none' },
})
s.addText(slide.title || spec.title, {
x: 1, y: 2, w: W - 2, h: 2,
fontSize: 42, color: p.isDark ? 'FFFFFF' : 'FFFFFF', bold: true, align: 'center',
})
if (slide.subtitle) {
s.addText(slide.subtitle, {
x: 1, y: 4.3, w: W - 2, h: 1,
fontSize: 20, color: 'FFFFFF', align: 'center', transparency: 20,
})
}
s.addShape(pptx.ShapeType.rect, {
x: 0, y: H - 0.08, w: W, h: 0.08,
fill: { color: p.accent }, line: { type: 'none' },
})
break
}
case 'bullets': {
s.background = { color: p.bg }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: 0.06, h: H,
fill: { color: p.secondary }, line: { type: 'none' },
})
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
s.addShape(pptx.ShapeType.rect, {
x: 0.5, y: 1.35, w: 0.5, h: 0.06,
fill: { color: p.accent }, line: { type: 'none' },
})
const items = slide.items ?? []
// Use simpler bullet format - each item as separate text call with bullet option
items.forEach((item: string, i: number) => {
s.addText(item, {
x: 0.5, y: 1.6 + i * 0.45, w: W - 1, h: 0.5,
fontSize: 17, color: p.text,
bullet: true,
paraSpaceAfter: 6,
})
})
break
}
case 'chart': {
s.background = { color: p.bg }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: 0.06, h: H,
fill: { color: p.secondary }, line: { type: 'none' },
})
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
if (slide.subtitle) {
s.addText(slide.subtitle, {
x: 0.5, y: 1.2, w: W - 1, h: 0.5,
fontSize: 12, color: p.muted,
})
}
const data = slide.data ?? []
if (data.length > 0) {
const chartType = slide.chartType === 'line' ? pptx.ChartType.line
: slide.chartType === 'donut' ? pptx.ChartType.doughnut
: slide.chartType === 'radar' ? pptx.ChartType.radar
: pptx.ChartType.bar
const chartData = [{
name: slide.title,
labels: data.map((d: any) => d.label),
values: data.map((d: any) => d.value),
}]
s.addChart(chartType, chartData, {
x: 0.8, y: 1.8, w: W - 1.6, h: H - 2.5,
showValue: true,
showTitle: false,
showLegend: false,
chartColors: [p.secondary, p.accent, '10B981', 'F59E0B', 'EF4444', '6366F1'],
valAxisHidden: slide.chartType === 'donut' || slide.chartType === 'radar',
catAxisHidden: slide.chartType === 'donut',
})
}
break
}
case 'stats': {
s.background = { color: p.bg }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: 0.06, h: H,
fill: { color: p.secondary }, line: { type: 'none' },
})
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const stats = (slide.stats ?? []).slice(0, 4)
const statW = (W - 1 - (stats.length - 1) * 0.3) / stats.length
stats.forEach((st: any, i: number) => {
const x = 0.5 + i * (statW + 0.3)
s.addShape(pptx.ShapeType.rect, {
x, y: 2, w: statW, h: 0.06,
fill: { color: p.accent }, line: { type: 'none' },
})
s.addText(st.value, {
x, y: 2.3, w: statW, h: 2,
fontSize: 48, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, align: 'center',
})
s.addText(st.label, {
x, y: 4.4, w: statW, h: 0.7,
fontSize: 13, color: p.muted, align: 'center',
})
})
break
}
case 'table': {
s.background = { color: p.bg }
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const headers = slide.headers ?? []
const rows = slide.rows ?? []
if (headers.length > 0) {
const tableRows = [
headers.map((h: string) => ({ text: h, options: { bold: true, color: 'FFFFFF', fill: { color: p.secondary } } })),
...rows.map((row: string[], ri: number) =>
row.map((cell: string) => ({ text: cell, options: { fill: { color: ri % 2 === 0 ? 'F8F8F8' : 'FFFFFF' } } }))
),
]
s.addTable(tableRows, {
x: 0.5, y: 1.5, w: W - 1, h: H - 2,
fontSize: 13, color: p.text,
border: { type: 'solid', pt: 0.5, color: 'DDDDDD' },
colW: Array(headers.length).fill((W - 1) / headers.length),
})
}
break
}
case 'cards': {
s.background = { color: p.bg }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: 0.06, h: H,
fill: { color: p.secondary }, line: { type: 'none' },
})
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 26, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const cards = (slide.cards ?? []).slice(0, 6)
const cols = cards.length <= 2 ? 2 : cards.length <= 3 ? 3 : cards.length === 4 ? 2 : 3
const cardW = (W - 1 - (cols - 1) * 0.2) / cols
const cardH = cards.length > cols ? 2.3 : 3.5
cards.forEach((card: any, i: number) => {
const col = i % cols
const row = Math.floor(i / cols)
const x = 0.5 + col * (cardW + 0.2)
const y = 1.5 + row * (cardH + 0.15)
s.addShape(pptx.ShapeType.roundRect, {
x, y, w: cardW, h: cardH,
fill: { color: p.isDark ? '1E1E2E' : 'FFFFFF' },
line: { color: p.isDark ? '333333' : 'E5E7EB', pt: 1 },
rectRadius: 0.08,
})
s.addShape(pptx.ShapeType.rect, {
x, y, w: cardW, h: 0.05,
fill: { color: p.accent }, line: { type: 'none' },
})
s.addText(card.title, {
x: x + 0.15, y: y + 0.25, w: cardW - 0.3, h: 0.6,
fontSize: 14, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
s.addText(card.description, {
x: x + 0.15, y: y + 0.8, w: cardW - 0.3, h: cardH - 1,
fontSize: 12, color: p.text,
})
})
break
}
case 'timeline': {
s.background = { color: p.bg }
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const events = (slide.events ?? []).slice(0, 6)
const evH = Math.min(0.9, (H - 2) / events.length)
events.forEach((ev: any, i: number) => {
const y = 1.6 + i * evH
// Dot
s.addShape(pptx.ShapeType.ellipse, {
x: 0.7, y: y + 0.1, w: 0.2, h: 0.2,
fill: { color: p.accent }, line: { type: 'none' },
})
// Line
if (i < events.length - 1) {
s.addShape(pptx.ShapeType.rect, {
x: 0.78, y: y + 0.32, w: 0.04, h: evH - 0.2,
fill: { color: p.accent }, line: { type: 'none' },
})
}
s.addText(ev.date, {
x: 1.1, y, w: 2, h: 0.4,
fontSize: 10, color: p.accent, bold: true,
})
s.addText(ev.title, {
x: 1.1, y: y + 0.3, w: W - 2, h: 0.4,
fontSize: 14, color: p.text, bold: true,
})
if (ev.description) {
s.addText(ev.description, {
x: 1.1, y: y + 0.6, w: W - 2, h: 0.3,
fontSize: 11, color: p.muted,
})
}
})
break
}
case 'quote': {
const qBg = p.isDark ? '0D1117' : '1A1A2E'
s.background = { color: qBg }
s.addText('\u201C', {
x: 0.5, y: 0.2, w: 2, h: 2,
fontSize: 96, color: p.accent, bold: true, transparency: 60,
})
s.addText(slide.quote, {
x: 1, y: 1.5, w: W - 2, h: 3.5,
fontSize: 24, color: 'FFFFFF', italic: true, align: 'left',
})
if (slide.author) {
s.addText(`\u2014 ${slide.author}`, {
x: 1, y: 5.2, w: W - 2, h: 0.8,
fontSize: 14, color: p.accent, align: 'left',
})
}
if (slide.context) {
s.addText(slide.context, {
x: 1, y: 6, w: W - 2, h: 0.8,
fontSize: 12, color: 'AAAAAA',
})
}
break
}
case 'comparison': {
s.background = { color: p.bg }
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const colW = (W - 1.5) / 2
const colY = 1.5, colH = H - 2
// Left
if (slide.left) {
s.addShape(pptx.ShapeType.roundRect, {
x: 0.5, y: colY, w: colW, h: colH,
fill: { color: p.isDark ? '1E1E2E' : 'F8FAFC' },
line: { color: p.secondary, pt: 1 },
rectRadius: 0.08,
})
s.addText(slide.left.title, {
x: 0.7, y: colY + 0.15, w: colW - 0.4, h: 0.5,
fontSize: 14, color: p.secondary, bold: true,
})
const lBullets = (slide.left.points ?? []).map((pt: string) => ({
text: pt, options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 4 },
}))
s.addText(lBullets, {
x: 0.7, y: colY + 0.7, w: colW - 0.4, h: colH - 1.4,
fontSize: 13, color: p.text,
})
if (slide.left.score) {
s.addText(slide.left.score, {
x: 0.7, y: colY + colH - 0.6, w: colW - 0.4, h: 0.5,
fontSize: 18, color: p.secondary, bold: true, align: 'center',
})
}
}
// Right
if (slide.right) {
const rx = 0.5 + colW + 0.5
s.addShape(pptx.ShapeType.roundRect, {
x: rx, y: colY, w: colW, h: colH,
fill: { color: p.isDark ? '1E1E2E' : 'F8FAFC' },
line: { color: p.accent, pt: 1 },
rectRadius: 0.08,
})
s.addText(slide.right.title, {
x: rx + 0.2, y: colY + 0.15, w: colW - 0.4, h: 0.5,
fontSize: 14, color: p.accent, bold: true,
})
const rBullets = (slide.right.points ?? []).map((pt: string) => ({
text: pt, options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 4 },
}))
s.addText(rBullets, {
x: rx + 0.2, y: colY + 0.7, w: colW - 0.4, h: colH - 1.4,
fontSize: 13, color: p.text,
})
if (slide.right.score) {
s.addText(slide.right.score, {
x: rx + 0.2, y: colY + colH - 0.6, w: colW - 0.4, h: 0.5,
fontSize: 18, color: p.accent, bold: true, align: 'center',
})
}
}
break
}
case 'equation': {
s.background = { color: p.bg }
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const eqs = (slide.equations ?? []).slice(0, 4)
const eqH = Math.min(1.5, (H - 2.5) / eqs.length)
eqs.forEach((eq: any, i: number) => {
const y = 1.8 + i * eqH
s.addText(eq.latex, {
x: 1, y, w: W - 2, h: eqH * 0.6,
fontSize: 28, color: p.text, align: 'center', fontFace: 'Cambria Math',
})
if (eq.label) {
s.addText(eq.label, {
x: 1, y: y + eqH * 0.6, w: W - 2, h: eqH * 0.3,
fontSize: 11, color: p.muted, align: 'center',
})
}
})
if (slide.explanation) {
s.addText(slide.explanation, {
x: 1, y: H - 1.2, w: W - 2, h: 0.8,
fontSize: 12, color: p.muted, align: 'center',
})
}
break
}
case 'image': {
s.background = { color: p.bg }
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
if (slide.url) {
try {
s.addImage({
path: slide.url,
x: 2, y: 1.5, w: W - 4, h: H - 2.5,
sizing: { type: 'contain', w: W - 4, h: H - 2.5 },
})
} catch { /* image unavailable */ }
}
if (slide.caption) {
s.addText(slide.caption, {
x: 0.5, y: H - 0.9, w: W - 1, h: 0.7,
fontSize: 12, color: p.muted, align: 'center',
})
}
break
}
case 'summary': {
s.background = { color: p.bg }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: 0.06, h: H,
fill: { color: p.secondary }, line: { type: 'none' },
})
s.addText(slide.title || 'En résumé', {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
s.addShape(pptx.ShapeType.rect, {
x: 0.5, y: 1.35, w: 0.5, h: 0.06,
fill: { color: p.accent }, line: { type: 'none' },
})
const items = (slide.items ?? []).slice(0, 8)
items.forEach((item: string, i: number) => {
const y = 1.6 + i * 0.7
s.addShape(pptx.ShapeType.rect, {
x: 0.5, y, w: W - 1, h: 0.55,
fill: { color: i % 2 === 0 ? (p.isDark ? '1E1E2E' : 'F5F5F5') : (p.isDark ? '151525' : 'EBEBEB') },
line: { type: 'none' },
})
s.addText('\u2713', {
x: 0.7, y, w: 0.5, h: 0.55,
fontSize: 14, color: p.accent, bold: true, valign: 'middle',
})
s.addText(item, {
x: 1.3, y, w: W - 2, h: 0.55,
fontSize: 14, color: p.text, valign: 'middle',
})
})
break
}
// Fallback: treat as bullets
default: {
s.background = { color: p.bg }
s.addText(slide.title || '', {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const content = slide.items || slide.content || []
if (Array.isArray(content) && content.length) {
const defBullets = content.map((item: string) => ({
text: String(item),
options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 6 },
}))
s.addText(defBullets, {
x: 0.5, y: 1.5, w: W - 1, h: H - 2,
fontSize: 17, color: p.text,
})
}
}
}
}
return pptx.write({ outputType: 'nodebuffer' }) as Promise<Buffer>
}
// ─── API Route ─────────────────────────────────────────────────────────────
export async function GET(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const canvasId = req.nextUrl.searchParams.get('id')
if (!canvasId) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 })
}
const canvas = await prisma.canvas.findUnique({
where: { id: canvasId, userId: session.user.id },
})
if (!canvas) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
let parsed: any
try {
parsed = JSON.parse(canvas.data)
} catch {
return NextResponse.json({ error: 'Invalid canvas data' }, { status: 500 })
}
if (parsed.type !== 'slides') {
return NextResponse.json({ error: 'Not a slides canvas' }, { status: 400 })
}
// Support both new format (spec embedded) and legacy (spec at root)
const spec = parsed.spec || parsed
if (!spec || !Array.isArray(spec.slides)) {
return NextResponse.json({ error: 'No slide data found — please regenerate the presentation' }, { status: 400 })
}
const buffer = await buildPptx(spec)
const filename = `${(canvas.name || 'presentation').replace(/[^a-zA-Z0-9-_ ]/g, '_').trim()}.pptx`
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': String(buffer.length),
},
})
} catch (err: any) {
console.error('[PPTX Export]', err)
return NextResponse.json({ error: err.message || 'Export failed' }, { status: 500 })
}
}