Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08af4cd0ae | ||
|
|
eb279a87a9 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.25",
|
"version": "2.4.26",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.25",
|
"version": "2.4.26",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.25",
|
"version": "2.4.26",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
44
src/__tests__/pdf-shared.test.ts
Normal file
44
src/__tests__/pdf-shared.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { cleanQuillHtml } from "../utils/pdf-shared";
|
||||||
|
|
||||||
|
describe("cleanQuillHtml — inline-style whitelist", () => {
|
||||||
|
it("keeps Quill text and background colors", () => {
|
||||||
|
const html =
|
||||||
|
'<p><span style="color: rgb(230, 0, 0);">červeně</span> ' +
|
||||||
|
'<span style="background-color: #ffff00;">zvýrazněně</span> ' +
|
||||||
|
'<span style="color: #06c;">modře</span></p>';
|
||||||
|
const out = cleanQuillHtml(html);
|
||||||
|
expect(out).toContain('style="color: rgb(230, 0, 0)"');
|
||||||
|
expect(out).toContain('style="background-color: #ffff00"');
|
||||||
|
expect(out).toContain('style="color: #06c"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops every non-color style declaration", () => {
|
||||||
|
const out = cleanQuillHtml(
|
||||||
|
'<span style="position: fixed; color: red; font-size: 99px;">x</span>',
|
||||||
|
);
|
||||||
|
expect(out).toBe('<span style="color: red">x</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops unsafe color values (url, expression, quotes)", () => {
|
||||||
|
expect(
|
||||||
|
cleanQuillHtml('<span style="color: url(javascript:alert(1))">x</span>'),
|
||||||
|
).toBe("<span>x</span>");
|
||||||
|
expect(
|
||||||
|
cleanQuillHtml('<span style="color: expression(alert(1))">x</span>'),
|
||||||
|
).toBe("<span>x</span>");
|
||||||
|
expect(cleanQuillHtml("<span style='color: \"red\"'>x</span>")).toBe(
|
||||||
|
"<span>x</span>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps stripping scripts, event handlers and js: URLs", () => {
|
||||||
|
const out = cleanQuillHtml(
|
||||||
|
'<p onclick="alert(1)"><script>alert(1)</script>' +
|
||||||
|
'<a href="javascript:alert(1)">x</a></p>',
|
||||||
|
);
|
||||||
|
expect(out).not.toContain("script");
|
||||||
|
expect(out).not.toContain("onclick");
|
||||||
|
expect(out).not.toContain("javascript:");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -253,7 +253,7 @@ export function buildInvoicePdfFooter(
|
|||||||
const t = translations[lang] || translations.cs;
|
const t = translations[lang] || translations.cs;
|
||||||
const pageWord = lang === "cs" ? "Strana" : "Page";
|
const pageWord = lang === "cs" ? "Strana" : "Page";
|
||||||
const ofWord = lang === "cs" ? "z" : "of";
|
const ofWord = lang === "cs" ? "z" : "of";
|
||||||
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
return `<div style="width:100%; font-size:8pt; font-family:'Segoe UI', Tahoma, Arial, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||||
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerLine)}</div>
|
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerLine)}</div>
|
||||||
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||||||
<div style="flex:1;"></div>
|
<div style="flex:1;"></div>
|
||||||
@@ -898,7 +898,7 @@ export default async function invoicesPdfRoutes(
|
|||||||
}
|
}
|
||||||
.section-content,
|
.section-content,
|
||||||
.section-content * {
|
.section-content * {
|
||||||
font-family: Tahoma, sans-serif !important;
|
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||||
}
|
}
|
||||||
.section-content { font-size: 14px; }
|
.section-content { font-size: 14px; }
|
||||||
.section-content h1 { font-size: 20px; }
|
.section-content h1 { font-size: 20px; }
|
||||||
@@ -910,7 +910,7 @@ export default async function invoicesPdfRoutes(
|
|||||||
.section-content li { margin-bottom: 0.2em; }
|
.section-content li { margin-bottom: 0.2em; }
|
||||||
|
|
||||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||||
.ql-align-center { text-align: center; }
|
.ql-align-center { text-align: center; }
|
||||||
.ql-align-right { text-align: right; }
|
.ql-align-right { text-align: right; }
|
||||||
.ql-align-justify { text-align: justify; }
|
.ql-align-justify { text-align: justify; }
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ export function buildIssuedOrderPdfFooter(
|
|||||||
const t = translations[lang];
|
const t = translations[lang];
|
||||||
const pageWord = lang === "cs" ? "Strana" : "Page";
|
const pageWord = lang === "cs" ? "Strana" : "Page";
|
||||||
const ofWord = lang === "cs" ? "z" : "of";
|
const ofWord = lang === "cs" ? "z" : "of";
|
||||||
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
return `<div style="width:100%; font-size:8pt; font-family:'Segoe UI', Tahoma, Arial, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||||
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerName)}</div>
|
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerName)}</div>
|
||||||
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||||||
<div style="flex:1;"></div>
|
<div style="flex:1;"></div>
|
||||||
@@ -655,7 +655,7 @@ export function renderIssuedOrderHtml(
|
|||||||
}
|
}
|
||||||
.section-content,
|
.section-content,
|
||||||
.section-content * {
|
.section-content * {
|
||||||
font-family: Tahoma, sans-serif !important;
|
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||||
}
|
}
|
||||||
.section-content { font-size: 14px; }
|
.section-content { font-size: 14px; }
|
||||||
.section-content h1 { font-size: 20px; }
|
.section-content h1 { font-size: 20px; }
|
||||||
@@ -667,7 +667,7 @@ export function renderIssuedOrderHtml(
|
|||||||
.section-content li { margin-bottom: 0.2em; }
|
.section-content li { margin-bottom: 0.2em; }
|
||||||
|
|
||||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||||
.ql-align-center { text-align: center; }
|
.ql-align-center { text-align: center; }
|
||||||
.ql-align-right { text-align: right; }
|
.ql-align-right { text-align: right; }
|
||||||
.ql-align-justify { text-align: justify; }
|
.ql-align-justify { text-align: justify; }
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ export function renderOfferHtml(
|
|||||||
img, table, pre, code { max-width: 100%; }
|
img, table, pre, code { max-width: 100%; }
|
||||||
|
|
||||||
/* ---- Quill font classes – v PDF vynuceno Tahoma ---- */
|
/* ---- Quill font classes – v PDF vynuceno Tahoma ---- */
|
||||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||||
|
|
||||||
/* ---- Quill alignment ---- */
|
/* ---- Quill alignment ---- */
|
||||||
.ql-align-center { text-align: center; }
|
.ql-align-center { text-align: center; }
|
||||||
@@ -502,7 +502,7 @@ ${indentCSS}
|
|||||||
}
|
}
|
||||||
.section-content,
|
.section-content,
|
||||||
.section-content * {
|
.section-content * {
|
||||||
font-family: Tahoma, sans-serif !important;
|
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||||
}
|
}
|
||||||
.section-content { font-size: 14px; }
|
.section-content { font-size: 14px; }
|
||||||
.section-content h1 { font-size: 20px; }
|
.section-content h1 { font-size: 20px; }
|
||||||
|
|||||||
@@ -570,7 +570,7 @@ export function renderOrderConfirmationHtml(
|
|||||||
.invoice-notes-content li { margin-bottom: 0.2em; }
|
.invoice-notes-content li { margin-bottom: 0.2em; }
|
||||||
.invoice-notes-content,
|
.invoice-notes-content,
|
||||||
.invoice-notes-content * {
|
.invoice-notes-content * {
|
||||||
font-family: Tahoma, sans-serif !important;
|
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||||
}
|
}
|
||||||
.invoice-notes-content { font-size: 14px; }
|
.invoice-notes-content { font-size: 14px; }
|
||||||
.invoice-notes-content h1 { font-size: 20px; }
|
.invoice-notes-content h1 { font-size: 20px; }
|
||||||
@@ -579,7 +579,7 @@ export function renderOrderConfirmationHtml(
|
|||||||
.invoice-notes-content h4 { font-size: 15px; }
|
.invoice-notes-content h4 { font-size: 15px; }
|
||||||
|
|
||||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||||
.ql-align-center { text-align: center; }
|
.ql-align-center { text-align: center; }
|
||||||
.ql-align-right { text-align: right; }
|
.ql-align-right { text-align: right; }
|
||||||
.ql-align-justify { text-align: justify; }
|
.ql-align-justify { text-align: justify; }
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function buildPdfHeaderTemplate(
|
|||||||
logoDataUri: string,
|
logoDataUri: string,
|
||||||
heading: string,
|
heading: string,
|
||||||
): string {
|
): string {
|
||||||
return `<div style="width:100%; font-family:Tahoma, sans-serif; padding:0 12mm; box-sizing:border-box;">
|
return `<div style="width:100%; font-family:'Segoe UI', Tahoma, Arial, sans-serif; padding:0 12mm; box-sizing:border-box;">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; padding-bottom:1mm; border-bottom:2pt solid #de3a3a;">
|
<div style="display:flex; justify-content:space-between; align-items:center; padding-bottom:1mm; border-bottom:2pt solid #de3a3a;">
|
||||||
<div style="flex:0 0 auto;">${logoDataUri ? `<img src="${logoDataUri}" style="max-width:42mm; max-height:22mm; object-fit:contain;" />` : ""}</div>
|
<div style="flex:0 0 auto;">${logoDataUri ? `<img src="${logoDataUri}" style="max-width:42mm; max-height:22mm; object-fit:contain;" />` : ""}</div>
|
||||||
<div style="font-size:13pt; font-weight:700; color:#de3a3a; text-align:right; letter-spacing:0.03em;">${escapeHtml(heading)}</div>
|
<div style="font-size:13pt; font-weight:700; color:#de3a3a; text-align:right; letter-spacing:0.03em;">${escapeHtml(heading)}</div>
|
||||||
@@ -108,6 +108,13 @@ export function buildPdfHeaderTemplate(
|
|||||||
* and merge adjacent identical spans. Runs AFTER DOMPurify at every call site
|
* and merge adjacent identical spans. Runs AFTER DOMPurify at every call site
|
||||||
* (regex pass = defense-in-depth, not the primary sanitizer).
|
* (regex pass = defense-in-depth, not the primary sanitizer).
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Literal CSS color values only: #hex, rgb()/rgba() with numeric components,
|
||||||
|
* or a bare keyword. No quotes, parentheses beyond rgb(a), url(), variables.
|
||||||
|
*/
|
||||||
|
const SAFE_CSS_COLOR =
|
||||||
|
/^(#[0-9a-f]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+)\s*)?\)|[a-z]{3,20})$/i;
|
||||||
|
|
||||||
export function cleanQuillHtml(html: string | null | undefined): string {
|
export function cleanQuillHtml(html: string | null | undefined): string {
|
||||||
if (!html) return "";
|
if (!html) return "";
|
||||||
let s = html;
|
let s = html;
|
||||||
@@ -134,7 +141,28 @@ export function cleanQuillHtml(html: string | null | undefined): string {
|
|||||||
);
|
);
|
||||||
// Replace with regular space (outside of tags)
|
// Replace with regular space (outside of tags)
|
||||||
s = s.replace(/( )/g, " ");
|
s = s.replace(/( )/g, " ");
|
||||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
// Inline styles: Quill writes text/background colors as style attributes.
|
||||||
|
// Keep ONLY color/background-color with literal color values — everything
|
||||||
|
// else (url(), expression(), positioning…) must never reach Puppeteer.
|
||||||
|
s = s.replace(
|
||||||
|
/\s+style\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi,
|
||||||
|
(_m, dq, sq, bare) => {
|
||||||
|
const kept = String(dq ?? sq ?? bare ?? "")
|
||||||
|
.split(";")
|
||||||
|
.map((decl) => {
|
||||||
|
const i = decl.indexOf(":");
|
||||||
|
if (i < 0) return null;
|
||||||
|
const prop = decl.slice(0, i).trim().toLowerCase();
|
||||||
|
const val = decl.slice(i + 1).trim();
|
||||||
|
return (prop === "color" || prop === "background-color") &&
|
||||||
|
SAFE_CSS_COLOR.test(val)
|
||||||
|
? `${prop}: ${val}`
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
.filter((d): d is string => d !== null);
|
||||||
|
return kept.length ? ` style="${kept.join("; ")}"` : "";
|
||||||
|
},
|
||||||
|
);
|
||||||
// Merge adjacent spans with same attributes
|
// Merge adjacent spans with same attributes
|
||||||
let prev = "";
|
let prev = "";
|
||||||
while (prev !== s) {
|
while (prev !== s) {
|
||||||
|
|||||||
Reference in New Issue
Block a user