security: fix all Critical and High findings from FLAWS_REPORT audit
- Auth: pessimistic locking on login tokens and refresh token rotation, backup code attempt counter, rate limiting verification - Schema: unique constraints on business numbers, FK relations, unsigned/signed alignment, attendance duplicate prevention - Invoices/PDFs: DOMPurify sanitization, bounded queries in stats and alerts, VAT rounding, Puppeteer error handling - Orders/Offers: transactional parent+child creation, Zod NaN refinement, status enums, uniqueness checks - Projects/Files: path traversal protection, streamed uploads, permission guards, query param validation - Attendance/HR: duplicate checks, ownership validation, GPS restrictions, trip distance validation - Frontend: modal lock reference counting, XSS escaping in print HTML, ref mutation fixes, accessibility attributes Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,31 @@ import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { localDateCzStr } from "../../utils/date";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { parseId, error } from "../../utils/response";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { z } from "zod";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const window = new JSDOM("").window;
|
||||
const DOMPurify = createDOMPurify(window);
|
||||
|
||||
const OrderPdfBodySchema = z
|
||||
.object({
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
description: z.string(),
|
||||
quantity: z.number().min(0).finite(),
|
||||
unit: z.string(),
|
||||
unit_price: z.number().min(0).finite(),
|
||||
is_included_in_total: z.boolean().optional(),
|
||||
vat_rate: z.number().finite(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -46,6 +71,7 @@ function cleanQuillHtml(html: string | null | undefined): string {
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
@@ -74,7 +100,12 @@ function buildAddressLines(
|
||||
let fieldOrder: string[] | null = null;
|
||||
const raw = entity.custom_fields;
|
||||
if (raw) {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if ((parsed as Record<string, unknown>).fields) {
|
||||
cfData =
|
||||
@@ -213,129 +244,133 @@ export default async function ordersPdfRoutes(
|
||||
"/:id/confirmation",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
const body = request.body || {};
|
||||
const lang = body.lang === "en" ? "en" : "cs";
|
||||
const t = translations[lang];
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const lang = body.lang === "en" ? "en" : "cs";
|
||||
const t = translations[lang];
|
||||
|
||||
if (!order) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
||||
}
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
if (!order) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
|
||||
}
|
||||
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
const currency = order.currency || "CZK";
|
||||
const applyVat =
|
||||
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
|
||||
const orderVatRate = Number(order.vat_rate) || 21;
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
|
||||
// Use custom items from body if provided, otherwise order items
|
||||
const customItemsRaw = body.items;
|
||||
let items: Array<{
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
vat_rate: number;
|
||||
}> = [];
|
||||
const currency = order.currency || "CZK";
|
||||
const applyVat =
|
||||
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
|
||||
const orderVatRate = Number(order.vat_rate) || 21;
|
||||
|
||||
if (Array.isArray(customItemsRaw) && customItemsRaw.length > 0) {
|
||||
items = customItemsRaw.map((it: Record<string, unknown>) => ({
|
||||
description: String(it.description || ""),
|
||||
quantity: Number(it.quantity) || 0,
|
||||
unit: String(it.unit || ""),
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total:
|
||||
it.is_included_in_total !== false && it.is_included_in_total !== 0,
|
||||
vat_rate: Number(it.vat_rate) || orderVatRate,
|
||||
}));
|
||||
} else {
|
||||
items = order.order_items.map((it) => ({
|
||||
description: it.description || "",
|
||||
quantity: Number(it.quantity) || 0,
|
||||
unit: it.unit || "",
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total: !!it.is_included_in_total,
|
||||
vat_rate: orderVatRate,
|
||||
}));
|
||||
}
|
||||
// Use custom items from body if provided, otherwise order items
|
||||
const customItemsRaw = body.items;
|
||||
let items: Array<{
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
is_included_in_total: boolean;
|
||||
vat_rate: number;
|
||||
}> = [];
|
||||
|
||||
let subtotal = 0;
|
||||
let totalVat = 0;
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total) {
|
||||
const lineTotal = item.quantity * item.unit_price;
|
||||
subtotal += lineTotal;
|
||||
const rate = item.vat_rate;
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineTotal;
|
||||
if (applyVat) {
|
||||
const lineVat = (lineTotal * rate) / 100;
|
||||
vatSummary[key].vat += lineVat;
|
||||
totalVat += lineVat;
|
||||
if (Array.isArray(customItemsRaw) && customItemsRaw.length > 0) {
|
||||
items = customItemsRaw.map((it) => ({
|
||||
description: it.description,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit,
|
||||
unit_price: it.unit_price,
|
||||
is_included_in_total: it.is_included_in_total !== false,
|
||||
vat_rate: it.vat_rate,
|
||||
}));
|
||||
} else {
|
||||
items = order.order_items.map((it) => ({
|
||||
description: it.description || "",
|
||||
quantity: Number(it.quantity) || 0,
|
||||
unit: it.unit || "",
|
||||
unit_price: Number(it.unit_price) || 0,
|
||||
is_included_in_total: !!it.is_included_in_total,
|
||||
vat_rate: orderVatRate,
|
||||
}));
|
||||
}
|
||||
|
||||
let subtotal = 0;
|
||||
let totalVat = 0;
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total) {
|
||||
const lineTotal = item.quantity * item.unit_price;
|
||||
subtotal += lineTotal;
|
||||
const rate = item.vat_rate;
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineTotal;
|
||||
if (applyVat) {
|
||||
const lineVat = (lineTotal * rate) / 100;
|
||||
vatSummary[key].vat += lineVat;
|
||||
totalVat += lineVat;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const totalToPay = subtotal + totalVat;
|
||||
const totalToPay = subtotal + totalVat;
|
||||
|
||||
const userName = request.authData
|
||||
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
|
||||
: "";
|
||||
const userName = request.authData
|
||||
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
|
||||
: "";
|
||||
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(
|
||||
(order.customers as Record<string, unknown>) || null,
|
||||
false,
|
||||
t,
|
||||
);
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(
|
||||
(order.customers as Record<string, unknown>) || null,
|
||||
false,
|
||||
t,
|
||||
);
|
||||
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
|
||||
const orderNumber = escapeHtml(order.order_number || "");
|
||||
const poNumber = escapeHtml(order.customer_order_number || "");
|
||||
const orderDateStr = formatDate(order.created_at);
|
||||
const orderNumber = escapeHtml(order.order_number || "");
|
||||
const poNumber = escapeHtml(order.customer_order_number || "");
|
||||
const orderDateStr = formatDate(order.created_at);
|
||||
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const lineSubtotal = item.quantity * item.unit_price;
|
||||
const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals =
|
||||
Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
||||
return `<tr>
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const lineSubtotal = item.quantity * item.unit_price;
|
||||
const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals =
|
||||
Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
||||
return `<tr>
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${escapeHtml(item.description)}</td>
|
||||
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||||
@@ -345,45 +380,45 @@ export default async function ordersPdfRoutes(
|
||||
<td class="right">${formatNum(lineVat)}</td>
|
||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
})
|
||||
.join("");
|
||||
|
||||
const paymentMethod = lang === "cs" ? "převodem" : "Bank transfer";
|
||||
const paymentMethod = lang === "cs" ? "převodem" : "Bank transfer";
|
||||
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
||||
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notesRaw = order.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
const notesRaw = order.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
<div class="invoice-notes">
|
||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(notesRaw)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
: "";
|
||||
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(lang)}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -846,13 +881,20 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`;
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`;
|
||||
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header("Content-Disposition", `attachment; filename="${filename}"`)
|
||||
.send(pdfBuffer);
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header("Content-Disposition", `attachment; filename="${filename}"`)
|
||||
.send(pdfBuffer);
|
||||
} catch (err) {
|
||||
request.log.error(err, "PDF generation failed");
|
||||
return reply
|
||||
.status(500)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Chyba při generování PDF</h1></body></html>");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user