Files
konstitisyon.nu/components/versions/export-pdf-button.js
T
cedric dc1f115bd6 security: sanitiser la sortie marked avec DOMPurify (XSS)
export-pdf-button et print-button injectaient marked(content) directement
dans innerHTML / document.write. Un lien Markdown javascript: passait le
filtre hasRestrictedChar et pouvait s'exécuter.

Ajout de DOMPurify.sanitize() via import dynamique (déjà présent en dep
transitive de jspdf) sur les deux composants, avec whitelist de tags
et d'attributs stricte. markdown-renderer n'est pas touché car
react-markdown-preview utilise rehype-sanitize en interne.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:48:26 +04:00

323 lines
10 KiB
JavaScript

'use client'
import {useState} from 'react'
import PropTypes from 'prop-types'
import Button from '@mui/material/Button'
import CircularProgress from '@mui/material/CircularProgress'
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
import {formatDate} from '@/lib/format.js'
// Helper function to render markdown to HTML
const renderMarkdownToHtml = async content => {
if (!content) {
return 'Aucun contenu disponible'
}
// Check if content contains markdown syntax
const hasMarkdown = content.includes('**')
|| content.includes('*')
|| content.includes('#')
|| content.includes('[')
|| content.includes('`')
|| content.includes('> ')
|| content.includes('- ')
|| content.includes('1. ')
if (!hasMarkdown) {
// Simple text with line breaks
return content.replaceAll('\n', '<br>')
}
try {
// Dynamic import of markdown parser and sanitizer
const [{marked}, {default: DOMPurify}] = await Promise.all([
import('marked'),
import('dompurify')
])
// Configure marked for better PDF rendering
marked.setOptions({
breaks: true, // Convert \n to <br>
gfm: true, // GitHub flavored markdown
headerIds: false, // No header IDs needed for PDF
mangle: false // Don't mangle email addresses
})
return DOMPurify.sanitize(marked(content), {
ALLOWED_TAGS: [
'p',
'strong',
'em',
'b',
'i',
'ul',
'ol',
'li',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'code',
'pre',
'br',
'hr',
'a',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
],
ALLOWED_ATTR: [
'href',
'target',
'rel',
],
ALLOW_DATA_ATTR: false,
})
} catch (error) {
console.warn('Failed to parse markdown, falling back to plain text:', error)
return content.replaceAll('\n', '<br>')
}
}
export default function ExportPdfButton({versionData, isOutdated = false, voteCounts = null, size = 'medium', variant = 'outlined'}) {
const [isExporting, setIsExporting] = useState(false)
const handleExportPdf = async () => {
setIsExporting(true)
try {
const {default: jsPDF} = await import('jspdf')
const {default: html2canvas} = await import('html2canvas')
// Créer un élément temporaire avec le contenu formaté
const tempDiv = document.createElement('div')
tempDiv.style.cssText = `
padding: 40px;
font-family: 'Roboto', Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: white;
width: 210mm;
position: fixed;
top: -9999px;
left: -9999px;
z-index: -1;
`
// Add CSS styles for markdown elements
const styleElement = document.createElement('style')
styleElement.textContent = `
.pdf-content h1, .pdf-content h2, .pdf-content h3, .pdf-content h4, .pdf-content h5, .pdf-content h6 {
margin: 20px 0 10px 0;
font-weight: bold;
color: #333;
}
.pdf-content h1 { font-size: 24px; color: #1976d2; }
.pdf-content h2 { font-size: 20px; }
.pdf-content h3 { font-size: 18px; }
.pdf-content h4 { font-size: 16px; }
.pdf-content p { margin: 10px 0; }
.pdf-content strong, .pdf-content b { font-weight: bold; }
.pdf-content em, .pdf-content i { font-style: italic; }
.pdf-content ul, .pdf-content ol {
margin: 10px 0;
padding-left: 25px;
}
.pdf-content li { margin: 5px 0; }
.pdf-content blockquote {
margin: 15px 0;
padding: 10px 20px;
border-left: 4px solid #1976d2;
background-color: #f5f5f5;
font-style: italic;
}
.pdf-content code {
background-color: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.pdf-content pre {
background-color: #f5f5f5;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
margin: 15px 0;
}
.pdf-content pre code {
background: none;
padding: 0;
}
.pdf-content a { color: #1976d2; text-decoration: underline; }
.pdf-content hr {
border: none;
border-top: 1px solid #ccc;
margin: 20px 0;
}
.pdf-content table {
border-collapse: collapse;
margin: 15px 0;
width: 100%;
}
.pdf-content th, .pdf-content td {
border: 1px solid #ccc;
padding: 8px;
text-align: left;
}
.pdf-content th {
background-color: #f5f5f5;
font-weight: bold;
}
`
document.head.append(styleElement)
const authorName = versionData.user_created?.split('-')[0] || 'Système'
const createdAt = new Date(versionData.date_created)
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
const isExpired = createdAt < threeDaysAgo
const voteStatus = (isExpired || isOutdated) ? 'fermé' : 'ouvert'
const voteColor = voteStatus === 'ouvert' ? '#2e7d32' : '#d32f2f'
// Vote counts display
const voteTotal = voteCounts ? voteCounts.total : 0
const voteTotalColor = voteTotal > 0 ? '#2e7d32' : (voteTotal < 0 ? '#d32f2f' : '#666')
const voteTotalSign = voteTotal >= 0 ? '+' : ''
// Render markdown content to HTML
const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu)
tempDiv.innerHTML = `
<div style="margin-bottom: 30px; border-bottom: 2px solid #1976d2; padding-bottom: 20px;">
<h1 style="color: #1976d2; margin: 0 0 10px 0; font-size: 28px; font-weight: bold;">
${versionData.name}
</h1>
<div style="margin-top: 15px;">
<p style="margin: 5px 0; color: #666; font-size: 14px;">
<strong>Auteur :</strong> @${authorName}
</p>
<p style="margin: 5px 0; color: #666; font-size: 14px;">
<strong>Date de création :</strong> ${formatDate(versionData.date_created, 'PPpp', {withTimezone: true})}
</p>
<p style="margin: 5px 0; color: #666; font-size: 14px;">
<strong>Statut du vote :</strong>
<span style="color: ${voteColor}; font-weight: bold;">${voteStatus}</span>
</p>
${voteCounts ? `
<div style="margin-top: 15px; padding: 15px; background-color: #f5f5f5; border-radius: 8px;">
<p style="margin: 0 0 10px 0; font-size: 16px; font-weight: bold; color: #333;">
📊 Résultats des votes
</p>
<p style="margin: 5px 0; font-size: 14px;">
👍 Votes positifs : <strong style="color: #2e7d32;">${voteCounts.positive}</strong>
</p>
<p style="margin: 5px 0; font-size: 14px;">
👎 Votes négatifs : <strong style="color: #d32f2f;">${voteCounts.negative}</strong>
</p>
<p style="margin: 10px 0 0 0; font-size: 16px; font-weight: bold;">
🏆 Total : <span style="color: ${voteTotalColor};">${voteTotalSign}${voteTotal}</span>
</p>
</div>
` : ''}
</div>
</div>
<div style="margin-bottom: 20px;">
<h2 style="color: #333; font-size: 20px; margin-bottom: 15px; font-weight: bold;">
Contenu
</h2>
<div class="pdf-content" style="font-size: 14px; line-height: 1.8; text-align: justify;">
${renderedContent}
</div>
</div>
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #888; text-align: center;">
Exporté depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp', {withTimezone: true})}
</div>
`
document.body.append(tempDiv)
// Générer le canvas avec une meilleure qualité
const canvas = await html2canvas(tempDiv, {
scale: 2,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
width: tempDiv.scrollWidth,
height: tempDiv.scrollHeight
})
tempDiv.remove()
styleElement.remove() // Clean up styles
// Créer le PDF
const imgData = canvas.toDataURL('image/png', 1)
const pdf = new jsPDF('p', 'mm', 'a4') // eslint-disable-line new-cap
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const imgWidth = pageWidth
const imgHeight = (canvas.height * imgWidth) / canvas.width
let heightLeft = imgHeight
let position = 0
// Ajouter la première page
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
heightLeft -= pageHeight
// Ajouter des pages supplémentaires si nécessaire
while (heightLeft >= 0) {
position = heightLeft - imgHeight
pdf.addPage()
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
heightLeft -= pageHeight
}
// Nom du fichier sécurisé
const fileName = `${versionData.name.replaceAll(/[^a-zA-Z\d\s]/g, '').replaceAll(/\s+/g, '_')}_${formatDate(new Date(), 'yyyy-MM-dd')}.pdf`
pdf.save(fileName)
} catch (error) {
console.error('Erreur lors de l\'export PDF:', error)
} finally {
setIsExporting(false)
}
}
return (
<Button
variant={variant}
size={size}
startIcon={isExporting ? <CircularProgress size={16} /> : <PictureAsPdfIcon />}
disabled={isExporting}
sx={{
minWidth: size === 'small' ? 'auto' : undefined,
'&:disabled': {
opacity: 0.6
}
}}
onClick={handleExportPdf}
>
{isExporting ? 'Export...' : (size === 'small' ? 'PDF' : 'Exporter PDF')}
</Button>
)
}
ExportPdfButton.propTypes = {
versionData: PropTypes.object.isRequired,
isOutdated: PropTypes.bool,
voteCounts: PropTypes.shape({
positive: PropTypes.number,
negative: PropTypes.number,
total: PropTypes.number
}),
size: PropTypes.oneOf(['small', 'medium', 'large']),
variant: PropTypes.oneOf(['text', 'outlined', 'contained'])
}