Files
konstitisyon.nu/components/versions/print-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

462 lines
14 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 Snackbar from '@mui/material/Snackbar'
import Alert from '@mui/material/Alert'
import PrintIcon from '@mui/icons-material/Print'
import {formatDate} from '@/lib/format.js'
// Helper function to render markdown to HTML (reused from PDF export)
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 print rendering
marked.setOptions({
breaks: true, // Convert \n to <br>
gfm: true, // GitHub flavored markdown
headerIds: false, // No header IDs needed for print
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 PrintButton({versionData, isOutdated = false, voteCounts = null, size = 'medium', variant = 'outlined'}) {
const [isPrinting, setIsPrinting] = useState(false)
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'})
const handlePrint = async () => {
setIsPrinting(true)
try {
// Calculate vote status
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)
// Create print window
const printWindow = window.open('', '_blank', 'width=800,height=600')
if (!printWindow) {
throw new Error('Impossible d\'ouvrir la fenêtre d\'impression. Vérifiez que les popups ne sont pas bloqués.')
}
// Build print-optimized HTML
const printHtml = `
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${versionData.name} - Konstitisyon.nu</title>
<style>
/* Print-optimized styles */
@media print {
* {
-webkit-print-color-adjust: exact !important;
color-adjust: exact !important;
}
@page {
margin: 2cm;
size: A4;
}
}
body {
font-family: 'Times New Roman', serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: white;
}
.header {
border-bottom: 3px solid #1976d2;
padding-bottom: 20px;
margin-bottom: 30px;
page-break-inside: avoid;
}
.title {
color: #1976d2;
font-size: 28px;
font-weight: bold;
margin: 0 0 15px 0;
page-break-after: avoid;
}
.metadata {
font-size: 14px;
color: #666;
margin: 5px 0;
}
.vote-status {
font-weight: bold;
color: ${voteColor};
}
.content-section {
margin: 30px 0;
}
.content-title {
color: #333;
font-size: 22px;
font-weight: bold;
margin-bottom: 20px;
page-break-after: avoid;
}
.content {
font-size: 16px;
line-height: 1.8;
text-align: justify;
hyphens: auto;
}
/* Markdown elements styles */
.content h1, .content h2, .content h3, .content h4, .content h5, .content h6 {
margin: 25px 0 15px 0;
font-weight: bold;
color: #333;
page-break-after: avoid;
}
.content h1 { font-size: 24px; color: #1976d2; }
.content h2 { font-size: 20px; }
.content h3 { font-size: 18px; }
.content h4 { font-size: 16px; }
.content p { margin: 15px 0; }
.content strong, .content b { font-weight: bold; }
.content em, .content i { font-style: italic; }
.content ul, .content ol {
margin: 15px 0;
padding-left: 30px;
}
.content li {
margin: 8px 0;
page-break-inside: avoid;
}
.content blockquote {
margin: 20px 0;
padding: 15px 25px;
border-left: 4px solid #1976d2;
background-color: #f8f9fa;
font-style: italic;
page-break-inside: avoid;
}
.content code {
background-color: #f8f9fa;
padding: 3px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
.content pre {
background-color: #f8f9fa;
padding: 20px;
border-radius: 5px;
overflow-x: auto;
margin: 20px 0;
page-break-inside: avoid;
}
.content pre code {
background: none;
padding: 0;
}
.content a {
color: #1976d2;
text-decoration: underline;
}
.content hr {
border: none;
border-top: 1px solid #ccc;
margin: 30px 0;
}
.content table {
border-collapse: collapse;
margin: 20px 0;
width: 100%;
page-break-inside: avoid;
}
.content th, .content td {
border: 1px solid #ccc;
padding: 12px;
text-align: left;
}
.content th {
background-color: #f8f9fa;
font-weight: bold;
}
.footer {
margin-top: 50px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 12px;
color: #888;
text-align: center;
page-break-inside: avoid;
}
/* Screen-only styles */
@media screen {
.print-actions {
position: fixed;
top: 20px;
right: 20px;
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;
}
.print-button {
background: #1976d2;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
margin-right: 10px;
}
.close-button {
background: #666;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
}
.print-button:hover { background: #1565c0; }
.close-button:hover { background: #555; }
}
@media print {
.print-actions { display: none !important; }
}
</style>
</head>
<body>
<div class="print-actions">
<button class="print-button" onclick="window.print()">🖨️ Imprimer</button>
<button class="close-button" onclick="window.close()">✕ Fermer</button>
</div>
<div class="header">
<h1 class="title">${versionData.name}</h1>
<div class="metadata">
<strong>Auteur :</strong> @${authorName}
</div>
<div class="metadata">
<strong>Date de création :</strong> ${formatDate(versionData.date_created, 'PPpp', {withTimezone: true})}
</div>
<div class="metadata">
<strong>Statut du vote :</strong>
<span class="vote-status">${voteStatus}</span>
</div>
${voteCounts ? `
<div style="margin-top: 15px; padding: 15px; background-color: #f8f9fa; border-radius: 8px; border: 1px solid #e0e0e0;">
<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 class="content-section">
<h2 class="content-title">Contenu</h2>
<div class="content">
${renderedContent}
</div>
</div>
<div class="footer">
Imprimé depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp', {withTimezone: true})}
</div>
</body>
</html>
`
printWindow.document.write(printHtml)
printWindow.document.close()
// Auto-focus the print window
printWindow.focus()
setSnackbar({
open: true,
message: 'Fenêtre d\'impression ouverte',
severity: 'success'
})
} catch (error) {
console.error('Erreur lors de l\'impression:', error)
setSnackbar({
open: true,
message: error.message || 'Une erreur est survenue lors de l\'impression',
severity: 'error'
})
} finally {
setIsPrinting(false)
}
}
const handleCloseSnackbar = () => {
setSnackbar(prev => ({...prev, open: false}))
}
return (
<>
<Button
variant={variant}
size={size}
startIcon={isPrinting ? <CircularProgress size={16} /> : <PrintIcon />}
disabled={isPrinting}
sx={{
minWidth: size === 'small' ? 'auto' : undefined,
'&:disabled': {
opacity: 0.6
}
}}
onClick={handlePrint}
>
{isPrinting ? 'Préparation...' : (size === 'small' ? 'Print' : 'Imprimer')}
</Button>
<Snackbar
open={snackbar.open}
autoHideDuration={4000}
anchorOrigin={{vertical: 'bottom', horizontal: 'center'}}
onClose={handleCloseSnackbar}
>
<Alert
variant='filled'
severity={snackbar.severity}
sx={{width: '100%'}}
onClose={handleCloseSnackbar}
>
{snackbar.message}
</Alert>
</Snackbar>
</>
)
}
PrintButton.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'])
}