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:
BOHA
2026-04-24 00:58:35 +02:00
parent 122eee175e
commit 528e55991b
57 changed files with 2355 additions and 1010 deletions

View File

@@ -100,6 +100,9 @@ export default async function attendanceRoutes(
// --- action=balances: leave balance overview for all users ---
if (action === "balances") {
if (!authData.permissions.includes("attendance.admin")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getBalances(yr);
return reply.send({ success: true, data });
@@ -107,6 +110,9 @@ export default async function attendanceRoutes(
// --- action=workfund: monthly work fund overview ---
if (action === "workfund") {
if (!authData.permissions.includes("attendance.admin")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getWorkfund(yr);
return reply.send({ success: true, data });
@@ -114,6 +120,9 @@ export default async function attendanceRoutes(
// --- action=project_report: monthly project hours ---
if (action === "project_report") {
if (!authData.permissions.includes("attendance.admin")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getProjectReport(yr);
return reply.send({ success: true, data });
@@ -185,6 +194,10 @@ export default async function attendanceRoutes(
if (!id) return error(reply, "Missing id", 400);
const record = await attendanceService.getLocationRecord(id);
if (!record) return error(reply, "Záznam nenalezen", 404);
const isAdmin = authData.permissions.includes("attendance.admin");
if (record.user_id !== authData.userId && !isAdmin) {
return error(reply, "Nedostatečná oprávnění", 403);
}
return reply.send({ success: true, data: record });
}
@@ -294,6 +307,14 @@ export default async function attendanceRoutes(
if ("error" in leaveParsed) return error(reply, leaveParsed.error, 400);
const leaveBody = leaveParsed.data;
if (
leaveBody.user_id != null &&
leaveBody.user_id !== authData.userId &&
!authData.permissions.includes("attendance.admin")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const result = await attendanceService.createLeave(
{
user_id: leaveBody.user_id,
@@ -342,6 +363,14 @@ export default async function attendanceRoutes(
if ("error" in stdParsed) return error(reply, stdParsed.error, 400);
const body = stdParsed.data;
if (
body.user_id != null &&
body.user_id !== authData.userId &&
!authData.permissions.includes("attendance.admin")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const result = await attendanceService.createAttendance(
{
user_id: body.user_id,
@@ -364,6 +393,8 @@ export default async function attendanceRoutes(
},
authData.userId,
);
if ("error" in result)
return error(reply, result.error!, result.status ?? 400);
await logAudit({
request,

View File

@@ -1,6 +1,7 @@
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, paginated, error } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
@@ -53,6 +54,13 @@ export default async function auditLogRoutes(
// days === 0 means "delete all" (from frontend "Vše" option)
if (days === 0 || body.action === "all") {
const result = await prisma.audit_logs.deleteMany({});
await logAudit({
request,
authData: request.authData,
action: "delete",
entityType: "audit_logs",
description: `Uživatel ${request.authData?.username ?? "unknown"} smazal všechny audit logy, počet: ${result.count}`,
});
return success(reply, null, 200, `Smazáno ${result.count} záznamů`);
}
@@ -62,6 +70,13 @@ export default async function auditLogRoutes(
const result = await prisma.audit_logs.deleteMany({
where: { created_at: { lt: cutoff } },
});
await logAudit({
request,
authData: request.authData,
action: "delete",
entityType: "audit_logs",
description: `Uživatel ${request.authData?.username ?? "unknown"} smazal audit logy starší než ${days} dní, počet: ${result.count}`,
});
return success(
reply,
null,

View File

@@ -92,7 +92,15 @@ export default async function authRoutes(
// POST /api/admin/login/totp
fastify.post<{ Body: TotpVerifyRequest }>(
"/login/totp",
{ bodyLimit: 10240 },
{
config: {
rateLimit: {
max: 20,
timeWindow: "1 minute",
},
},
bodyLimit: 10240,
},
async (request, reply) => {
const parsed = parseBody(TotpVerifySchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -106,20 +114,42 @@ export default async function authRoutes(
.update(login_token)
.digest("hex");
const storedToken = await prisma.totp_login_tokens.findFirst({
where: { token_hash: tokenHash },
const totpResult = await prisma.$transaction(async (tx) => {
const tokens = await tx.$queryRaw<
Array<{ id: number; user_id: number; expires_at: Date }>
>`
SELECT id, user_id, expires_at FROM totp_login_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
`;
const storedToken = tokens[0] ?? null;
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
return { error: "Neplatný nebo expirovaný login token", status: 401 };
}
await tx.totp_login_tokens.delete({ where: { id: storedToken.id } });
const user = await tx.users.findUnique({
where: { id: storedToken.user_id },
include: { roles: true },
});
if (!user || !user.totp_secret) {
return { error: "Uživatel nenalezen", status: 401 };
}
if (user.locked_until && new Date(user.locked_until) > new Date()) {
return { error: "Účet je dočasně uzamčen", status: 429 };
}
return { user };
});
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
return error(reply, "Neplatný nebo expirovaný login token", 401);
if ("error" in totpResult) {
return error(reply, totpResult.error!, totpResult.status!);
}
const user = await prisma.users.findUnique({
where: { id: storedToken.user_id },
include: { roles: true },
});
if (!user || !user.totp_secret) {
const user = totpResult.user;
if (!user.totp_secret) {
return error(reply, "Uživatel nenalezen", 401);
}
@@ -128,8 +158,6 @@ export default async function authRoutes(
return error(reply, "Neplatný TOTP kód", 401);
}
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
// Reset failed attempts and update last login (TOTP verified = successful login)
await prisma.users.update({
where: { id: user.id },
@@ -186,31 +214,43 @@ export default async function authRoutes(
);
// POST /api/admin/refresh
fastify.post("/refresh", { bodyLimit: 10240 }, async (request, reply) => {
const refreshTokenRaw = request.cookies.refresh_token;
if (!refreshTokenRaw) {
return error(reply, "Refresh token chybí", 401);
}
fastify.post(
"/refresh",
{
config: {
rateLimit: {
max: 10,
timeWindow: "1 minute",
},
},
bodyLimit: 10240,
},
async (request, reply) => {
const refreshTokenRaw = request.cookies.refresh_token;
if (!refreshTokenRaw) {
return error(reply, "Refresh token chybí", 401);
}
const result = await refreshAccessToken(refreshTokenRaw, request);
const result = await refreshAccessToken(refreshTokenRaw, request);
if (result.type === "error") {
reply.clearCookie("refresh_token", {
path: "/api/admin",
httpOnly: true,
secure: config.isProduction,
sameSite: "strict",
if (result.type === "error") {
reply.clearCookie("refresh_token", {
path: "/api/admin",
httpOnly: true,
secure: config.isProduction,
sameSite: "strict",
});
return error(reply, result.message, result.status);
}
// Preserve the original remember_me flag so long-lived sessions stay long-lived after rotation
setRefreshCookie(reply, result.refreshToken, result.rememberMe);
return success(reply, {
access_token: result.accessToken,
user: result.user,
});
return error(reply, result.message, result.status);
}
// Preserve the original remember_me flag so long-lived sessions stay long-lived after rotation
setRefreshCookie(reply, result.refreshToken, result.rememberMe);
return success(reply, {
access_token: result.accessToken,
user: result.user,
});
});
},
);
// POST /api/admin/logout
fastify.post("/logout", async (request, reply) => {

View File

@@ -74,6 +74,13 @@ export default async function companySettingsRoutes(
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 &&
buf[8] === 0x57 &&
buf[9] === 0x45
)
mime = "image/webp";
return reply
.type(mime)
@@ -324,15 +331,12 @@ export default async function companySettingsRoutes(
nas: {
projects: {
configured: projectNas.isConfigured(),
path: config.nas.path || "—",
},
financials: {
configured: nasFinancialsManager.isConfigured(),
path: config.nas.financialsPath || "—",
},
offers: {
configured: nasOffersManager.isConfigured(),
path: config.nas.offersPath || "—",
},
},
});

View File

@@ -56,54 +56,58 @@ function decodeCustomFields(raw: string | null): {
export default async function customersRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
const { page, limit, skip, sort, order, search } = parsePagination(
request.query as Record<string, unknown>,
);
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "name";
const where = search
? {
OR: [
{ name: { contains: search } },
{ company_id: { contains: search } },
],
}
: {};
const [customers, total] = await Promise.all([
prisma.customers.findMany({
where,
skip,
take: limit,
orderBy: { [sortField]: order },
include: { _count: { select: { quotations: true } } },
}),
prisma.customers.count({ where }),
]);
const enriched = customers.map((c) => {
const { custom_fields, customer_field_order } = decodeCustomFields(
c.custom_fields,
fastify.get(
"/",
{ preHandler: requirePermission("customers.view") },
async (request, reply) => {
const { page, limit, skip, sort, order, search } = parsePagination(
request.query as Record<string, unknown>,
);
return {
...c,
custom_fields,
customer_field_order,
quotation_count: c._count?.quotations ?? 0,
};
});
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "name";
return reply.send({
success: true,
data: enriched,
pagination: buildPaginationMeta(total, page, limit),
});
});
const where = search
? {
OR: [
{ name: { contains: search } },
{ company_id: { contains: search } },
],
}
: {};
const [customers, total] = await Promise.all([
prisma.customers.findMany({
where,
skip,
take: limit,
orderBy: { [sortField]: order },
include: { _count: { select: { quotations: true } } },
}),
prisma.customers.count({ where }),
]);
const enriched = customers.map((c) => {
const { custom_fields, customer_field_order } = decodeCustomFields(
c.custom_fields,
);
return {
...c,
custom_fields,
customer_field_order,
quotation_count: c._count?.quotations ?? 0,
};
});
return reply.send({
success: true,
data: enriched,
pagination: buildPaginationMeta(total, page, limit),
});
},
);
fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requireAuth },
{ preHandler: requirePermission("customers.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -7,6 +7,12 @@ import { nasFinancialsManager } from "../../services/nas-financials-manager";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { getRate } from "../../services/exchange-rates";
import { localDateStr } from "../../utils/date";
import { parseId } from "../../utils/response";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
/* ── Helpers ─────────────────────────────────────────────────────── */
@@ -50,6 +56,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(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
let prev = "";
while (prev !== s) {
prev = s;
@@ -78,7 +85,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 =
@@ -250,185 +262,189 @@ export default async function invoicesPdfRoutes(
"/:id",
{ preHandler: requirePermission("invoices.export") },
async (request, reply) => {
const id = parseInt(request.params.id, 10);
const id = parseId(request.params.id, reply);
if (id === null) return;
const query = request.query as Record<string, string>;
const lang = query.lang === "en" ? "en" : "cs";
const t = translations[lang];
const invoice = await prisma.invoices.findUnique({
where: { id },
});
try {
const lang = query.lang === "en" ? "en" : "cs";
const t = translations[lang];
if (!invoice) {
return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Faktura nenalezena</h1></body></html>");
}
const items = await prisma.invoice_items.findMany({
where: { invoice_id: id },
orderBy: { position: "asc" },
});
let customer: Record<string, unknown> | null = null;
if (invoice.customer_id) {
customer = (await prisma.customers.findUnique({
where: { id: invoice.customer_id },
})) as Record<string, unknown> | null;
}
const settings = (await prisma.company_settings.findFirst()) as Record<
string,
unknown
> | null;
let orderNumber = "";
let orderDate = "";
if (invoice.order_id) {
const orderRow = await prisma.orders.findUnique({
where: { id: invoice.order_id },
select: {
order_number: true,
customer_order_number: true,
created_at: true,
},
const invoice = await prisma.invoices.findUnique({
where: { id },
});
if (orderRow) {
orderNumber = escapeHtml(
String(
orderRow.customer_order_number || orderRow.order_number || "",
),
);
if (orderRow.created_at) {
orderDate = formatDate(orderRow.created_at);
if (!invoice) {
return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Faktura nenalezena</h1></body></html>");
}
const items = await prisma.invoice_items.findMany({
where: { invoice_id: id },
orderBy: { position: "asc" },
});
let customer: Record<string, unknown> | null = null;
if (invoice.customer_id) {
customer = (await prisma.customers.findUnique({
where: { id: invoice.customer_id },
})) as Record<string, unknown> | null;
}
const settings = (await prisma.company_settings.findFirst()) as Record<
string,
unknown
> | null;
let orderNumber = "";
let orderDate = "";
if (invoice.order_id) {
const orderRow = await prisma.orders.findUnique({
where: { id: invoice.order_id },
select: {
order_number: true,
customer_order_number: true,
created_at: true,
},
});
if (orderRow) {
orderNumber = escapeHtml(
String(
orderRow.customer_order_number || orderRow.order_number || "",
),
);
if (orderRow.created_at) {
orderDate = formatDate(orderRow.created_at);
}
}
}
}
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 currency = invoice.currency || "CZK";
const applyVat = !!invoice.apply_vat;
const vatSummary: Record<string, { base: number; vat: number }> = {};
let subtotal = 0;
for (const item of items) {
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
subtotal += lineSubtotal;
const rate = Number(item.vat_rate);
const key = String(rate);
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
vatSummary[key].base += lineSubtotal;
if (applyVat) {
vatSummary[key].vat += (lineSubtotal * rate) / 100;
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" />`;
}
}
let totalVat = 0;
for (const data of Object.values(vatSummary)) {
totalVat += data.vat;
}
const totalToPay = subtotal + totalVat;
const currency = invoice.currency || "CZK";
const applyVat = !!invoice.apply_vat;
// QR code - SPAYD payment format
let qrSvg = "";
try {
const spaydParts = [
"SPD*1.0",
"ACC:" + (invoice.bank_iban || "").replace(/ /g, ""),
"AM:" + totalToPay.toFixed(2),
"CC:" + currency,
"X-VS:" + (invoice.invoice_number || ""),
"X-KS:" + (invoice.constant_symbol || "0308"),
"MSG:" + t.title + " " + (invoice.invoice_number || ""),
];
const spaydString = spaydParts.join("*");
qrSvg = await QRCode.toString(spaydString, {
type: "svg",
errorCorrectionLevel: "M",
margin: 1,
width: 200,
});
} catch {
// QR generation failed — leave empty
}
const vatSummary: Record<string, { base: number; vat: number }> = {};
let subtotal = 0;
// VAT recapitulation (always in CZK — Czech tax requirement)
const isForeign = currency.toUpperCase() !== "CZK";
const issueDateStr = invoice.issue_date
? localDateStr(new Date(invoice.issue_date))
: undefined;
const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0;
const vatRates = [21, 12, 0];
const vatRecap: Array<{
rate: number;
base: number;
vat: number;
total: number;
}> = [];
for (const rate of vatRates) {
const key = String(rate);
const base = vatSummary[key]?.base ?? 0;
const vat = vatSummary[key]?.vat ?? 0;
vatRecap.push({
rate,
base: Math.round(base * cnbRate * 100) / 100,
vat: Math.round(vat * cnbRate * 100) / 100,
total: Math.round((base + vat) * cnbRate * 100) / 100,
});
}
for (const item of items) {
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
subtotal += lineSubtotal;
const rate = Number(item.vat_rate);
const key = String(rate);
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
vatSummary[key].base += lineSubtotal;
if (applyVat) {
vatSummary[key].vat +=
Math.round(((lineSubtotal * rate) / 100) * 100) / 100;
}
}
const supp = buildAddressLines(settings, true, t);
const cust = buildAddressLines(customer, false, t);
let totalVat = 0;
for (const data of Object.values(vatSummary)) {
totalVat += data.vat;
}
const totalToPay = subtotal + totalVat;
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("");
// QR code - SPAYD payment format
let qrSvg = "";
try {
const spaydParts = [
"SPD*1.0",
"ACC:" + (invoice.bank_iban || "").replace(/ /g, ""),
"AM:" + totalToPay.toFixed(2),
"CC:" + currency,
"X-VS:" + (invoice.invoice_number || ""),
"X-KS:" + (invoice.constant_symbol || "0308"),
"MSG:" + t.title + " " + (invoice.invoice_number || ""),
];
const spaydString = spaydParts.join("*");
qrSvg = await QRCode.toString(spaydString, {
type: "svg",
errorCorrectionLevel: "M",
margin: 1,
width: 200,
});
} catch {
// QR generation failed — leave empty
}
// Supplier email/web from custom_fields
let suppEmail = "";
if (settings?.custom_fields) {
const raw = settings.custom_fields;
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
if (parsed && typeof parsed === "object") {
const fields = (parsed as Record<string, unknown>).fields;
if (Array.isArray(fields)) {
for (const f of fields) {
if (f.name && f.name.toLowerCase() === "email" && f.value) {
suppEmail = String(f.value);
// VAT recapitulation (always in CZK — Czech tax requirement)
const isForeign = currency.toUpperCase() !== "CZK";
const issueDateStr = invoice.issue_date
? localDateStr(new Date(invoice.issue_date))
: undefined;
const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0;
const vatRates = [21, 12, 0];
const vatRecap: Array<{
rate: number;
base: number;
vat: number;
total: number;
}> = [];
for (const rate of vatRates) {
const key = String(rate);
const base = vatSummary[key]?.base ?? 0;
const vat = vatSummary[key]?.vat ?? 0;
vatRecap.push({
rate,
base: Math.round(base * cnbRate * 100) / 100,
vat: Math.round(vat * cnbRate * 100) / 100,
total: Math.round((base + vat) * cnbRate * 100) / 100,
});
}
const supp = buildAddressLines(settings, true, t);
const cust = buildAddressLines(customer, 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("");
// Supplier email/web from custom_fields
let suppEmail = "";
if (settings?.custom_fields) {
const raw = settings.custom_fields;
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
if (parsed && typeof parsed === "object") {
const fields = (parsed as Record<string, unknown>).fields;
if (Array.isArray(fields)) {
for (const f of fields) {
if (f.name && f.name.toLowerCase() === "email" && f.value) {
suppEmail = String(f.value);
}
}
}
}
}
}
const invoiceNumber = escapeHtml(invoice.invoice_number);
const invoiceNumber = escapeHtml(invoice.invoice_number);
const itemsHtml = items
.map((item, i) => {
const qty = Number(item.quantity);
const unitPrice = Number(item.unit_price);
const lineSubtotal = qty * unitPrice;
const vatRate = Number(item.vat_rate);
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat;
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
const itemsHtml = items
.map((item, i) => {
const qty = Number(item.quantity);
const unitPrice = Number(item.unit_price);
const lineSubtotal = qty * unitPrice;
const vatRate = Number(item.vat_rate);
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat;
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
return `<tr>
return `<tr>
<td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
@@ -438,55 +454,55 @@ export default async function invoicesPdfRoutes(
<td class="right">${formatNum(lineVat)}</td>
<td class="right total-cell">${formatNum(lineTotal)}</td>
</tr>`;
})
.join("");
})
.join("");
const vatRecapHtml = vatRecap
.map(
(vr) => `<tr>
const vatRecapHtml = vatRecap
.map(
(vr) => `<tr>
<td class="right">${formatNum(vr.base)}</td>
<td class="center">${Math.floor(vr.rate)}%</td>
<td class="right">${formatNum(vr.vat)}</td>
<td class="right">${formatNum(vr.total)}</td>
</tr>`,
)
.join("");
)
.join("");
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 = invoice.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
const notesRaw = invoice.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
<!-- Poznamky -->
<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" />
@@ -1000,33 +1016,36 @@ ${indentCSS}
</body>
</html>`;
// Save PDF to NAS
if (nasFinancialsManager.isConfigured() && invoice.invoice_number) {
const issueDate = invoice.issue_date
? new Date(invoice.issue_date)
: new Date();
const saveMode = query.save === "1";
nasFinancialsManager.cleanIssuedInvoice(invoice.invoice_number!);
const pdfPromise = htmlToPdf(html)
.then((pdfBuffer) => {
nasFinancialsManager.saveIssuedInvoicePdf(
invoice.invoice_number!,
issueDate.getFullYear(),
issueDate.getMonth() + 1,
pdfBuffer,
);
})
.catch((err) => {
request.log.error(err, "Failed to save invoice PDF to NAS");
});
if (saveMode) {
await pdfPromise;
// Save PDF to NAS
if (
saveMode &&
nasFinancialsManager.isConfigured() &&
invoice.invoice_number
) {
const issueDate = invoice.issue_date
? new Date(invoice.issue_date)
: new Date();
nasFinancialsManager.cleanIssuedInvoice(invoice.invoice_number!);
const pdfBuffer = await htmlToPdf(html);
nasFinancialsManager.saveIssuedInvoicePdf(
invoice.invoice_number!,
issueDate.getFullYear(),
issueDate.getMonth() + 1,
pdfBuffer,
);
return reply.send({ success: true, message: "PDF uloženo" });
}
}
return reply.type("text/html").send(html);
return reply.type("text/html").send(html);
} 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>");
}
},
);
}

View File

@@ -191,10 +191,8 @@ export default async function invoicesRoutes(
if (!existing) return error(reply, "Faktura nenalezena", 404);
// Delete PDF from NAS
if (existing.invoice_number && existing.issue_date) {
const d = new Date(existing.issue_date);
const relPath = `Vydané/${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${existing.invoice_number}.pdf`;
nasFinancialsManager.deleteIssuedInvoice(relPath);
if (existing.invoice_number) {
await nasFinancialsManager.cleanIssuedInvoice(existing.invoice_number);
}
await logAudit({

View File

@@ -241,6 +241,19 @@ export default async function leaveRequestsRoutes(
const totalHours = totalBusinessDays * 8;
for (const ac of attendanceCreates) {
const duplicate = await prisma.attendance.findFirst({
where: { user_id: ac.user_id, shift_date: ac.shift_date },
});
if (duplicate) {
return error(
reply,
"Pro zvolené datumy již existují záznamy docházky",
400,
);
}
}
await prisma.$transaction(async (tx) => {
// 1. Create attendance records for each business day
if (attendanceCreates.length > 0) {
@@ -331,6 +344,7 @@ export default async function leaveRequestsRoutes(
"/:id",
{ preHandler: requireAuth },
async (request, reply) => {
const authData = request.authData!;
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.leave_requests.findUnique({
@@ -342,6 +356,10 @@ export default async function leaveRequestsRoutes(
return error(reply, "Lze zrušit pouze čekající žádosti", 400);
}
if (existing.user_id !== authData.userId) {
return error(reply, "Nemáte oprávnění zrušit tuto žádost", 403);
}
await prisma.leave_requests.update({
where: { id },
data: { status: "cancelled" },

View File

@@ -4,6 +4,12 @@ import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date";
import { nasOffersManager } from "../../services/nas-offers-manager";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { parseId } from "../../utils/response";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
function formatDate(date: Date | string | null | undefined): string {
if (!date) return "";
@@ -73,6 +79,7 @@ function cleanQuillHtml(html: string | null | undefined): string {
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
// Replace &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
// Merge adjacent spans with same attributes
let prev = "";
while (prev !== s) {
@@ -102,7 +109,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 =
@@ -201,7 +213,8 @@ export default async function offersPdfRoutes(
"/:id",
{ preHandler: requirePermission("offers.view") },
async (request, reply) => {
const id = parseInt(request.params.id, 10);
const id = parseId(request.params.id, reply);
if (id === null) return;
const query = request.query as Record<string, string>;
try {
@@ -349,7 +362,7 @@ export default async function offersPdfRoutes(
if (title)
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (content)
scopeHtml += `<div class="section-content">${cleanQuillHtml(content)}</div>`;
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
scopeHtml += "</div>";
}
scopeHtml += "</div>";
@@ -761,28 +774,24 @@ ${indentCSS}
</body>
</html>`;
const saveMode = query.save === "1";
// Save PDF to NAS
if (nasOffersManager.isConfigured() && quotation.quotation_number) {
if (
saveMode &&
nasOffersManager.isConfigured() &&
quotation.quotation_number
) {
const created = quotation.created_at
? new Date(quotation.created_at)
: new Date();
const saveMode = query.save === "1";
const pdfPromise = htmlToPdf(html)
.then((pdfBuffer) => {
nasOffersManager.saveOfferPdf(
quotation.quotation_number!,
created.getFullYear(),
pdfBuffer,
);
})
.catch((err) => {
request.log.error(err, "Failed to save offer PDF to NAS");
});
if (saveMode) {
await pdfPromise;
return reply.send({ success: true, message: "PDF uloženo" });
}
const pdfBuffer = await htmlToPdf(html);
nasOffersManager.saveOfferPdf(
quotation.quotation_number!,
created.getFullYear(),
pdfBuffer,
);
return reply.send({ success: true, message: "PDF uloženo" });
}
return reply.type("text/html").send(html);

View File

@@ -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(/(&nbsp;)/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>");
}
},
);
}

View File

@@ -88,12 +88,10 @@ export default async function ordersRoutes(
const attachment = await getOrderAttachment(id);
if (!attachment) return error(reply, "Příloha nenalezena", 404);
const safeFilename = attachment.filename.replace(/[\r\n"\\/]/g, "");
return reply
.type("application/pdf")
.header(
"Content-Disposition",
`inline; filename="${attachment.filename}"`,
)
.header("Content-Disposition", `inline; filename="${safeFilename}"`)
.send(attachment.data);
},
);
@@ -209,6 +207,7 @@ export default async function ordersRoutes(
const body = manualParsed.data;
const result = await createOrder(body);
if ("error" in result) return error(reply, result.error!, result.status!);
await logAudit({
request,

View File

@@ -75,6 +75,14 @@ export default async function profileRoutes(
}
await prisma.users.update({ where: { id: userId }, data });
if (body.current_password && body.new_password) {
await prisma.refresh_tokens.updateMany({
where: { user_id: userId, replaced_at: null },
data: { replaced_at: new Date() },
});
}
return success(reply, null, 200, "Profil aktualizován");
});
}

View File

@@ -1,6 +1,7 @@
import fs from "fs";
import { FastifyInstance } from "fastify";
import multipart from "@fastify/multipart";
import { z } from "zod";
import prisma from "../../config/database";
import { config } from "../../config/env";
import { requirePermission } from "../../middleware/auth";
@@ -8,6 +9,28 @@ import { logAudit } from "../../services/audit";
import { success, error } from "../../utils/response";
import { NasFileManager } from "../../services/nas-file-manager";
const ProjectFilesQuerySchema = z.object({
project_id: z.string().min(1, "project_id je povinný"),
path: z.string().optional(),
action: z.string().optional(),
});
function parseProjectFilesQuery(
query: unknown,
):
| { data: { project_id: string; path?: string; action?: string } }
| { error: string } {
try {
const data = ProjectFilesQuerySchema.parse(query);
return { data };
} catch (e) {
if (e instanceof z.ZodError) {
return { error: e.issues.map((err) => err.message).join(", ") };
}
return { error: "Neplatné parametry dotazu" };
}
}
export default async function projectFilesRoutes(
fastify: FastifyInstance,
): Promise<void> {
@@ -30,8 +53,10 @@ export default async function projectFilesRoutes(
"/",
{ preHandler: requirePermission("projects.view") },
async (request, reply) => {
const query = request.query as Record<string, string>;
const projectId = Number(query.project_id);
const parsedQuery = parseProjectFilesQuery(request.query);
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const { project_id: projectIdStr, path: subPath = "" } = parsedQuery.data;
const projectId = Number(projectIdStr);
const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
@@ -39,9 +64,7 @@ export default async function projectFilesRoutes(
return error(reply, "Souborový systém není nakonfigurován", 500);
}
const subPath = query.path || "";
if (query.action === "download") {
if (parsedQuery.data.action === "download") {
if (!subPath) return error(reply, "Cesta k souboru je povinná");
if (!project.project_number)
return error(reply, "Projekt nemá číslo projektu");
@@ -81,8 +104,9 @@ export default async function projectFilesRoutes(
"/",
{ preHandler: requirePermission("projects.files") },
async (request, reply) => {
const query = request.query as Record<string, string>;
const projectId = Number(query.project_id);
const parsedQuery = parseProjectFilesQuery(request.query);
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const projectId = Number(parsedQuery.data.project_id);
const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number)
@@ -105,7 +129,11 @@ export default async function projectFilesRoutes(
fm.createProjectFolder(project.project_number, project.name || "");
}
const err = fm.createFolder(project.project_number, path, folderName);
const err = await fm.createFolder(
project.project_number,
path,
folderName,
);
if (err !== null) return error(reply, err);
await logAudit({
@@ -130,8 +158,9 @@ export default async function projectFilesRoutes(
bodyLimit: config.nas.maxUploadSize,
},
async (request, reply) => {
const query = request.query as Record<string, string>;
const projectId = Number(query.project_id);
const parsedQuery = parseProjectFilesQuery(request.query);
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const projectId = Number(parsedQuery.data.project_id);
const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number)
@@ -149,14 +178,13 @@ export default async function projectFilesRoutes(
const file = await request.file();
if (!file) return error(reply, "Nebyl nahrán žádný soubor");
const subPath = query.path || "";
const fileBuffer = await file.toBuffer();
const subPath = parsedQuery.data.path || "";
const fileName = file.filename;
const err = await fm.uploadFile(
project.project_number,
subPath,
fileBuffer,
file.file,
fileName,
);
if (err !== null) return error(reply, err);
@@ -180,8 +208,9 @@ export default async function projectFilesRoutes(
"/",
{ preHandler: requirePermission("projects.files") },
async (request, reply) => {
const query = request.query as Record<string, string>;
const projectId = Number(query.project_id);
const parsedQuery = parseProjectFilesQuery(request.query);
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const projectId = Number(parsedQuery.data.project_id);
const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number)
@@ -198,7 +227,7 @@ export default async function projectFilesRoutes(
if (!fromPath || !toPath)
return error(reply, "Zdrojová i cílová cesta jsou povinné");
const err = fm.moveItem(project.project_number, fromPath, toPath);
const err = await fm.moveItem(project.project_number, fromPath, toPath);
if (err !== null) return error(reply, err);
await logAudit({
@@ -221,8 +250,9 @@ export default async function projectFilesRoutes(
"/",
{ preHandler: requirePermission("projects.files") },
async (request, reply) => {
const query = request.query as Record<string, string>;
const projectId = Number(query.project_id);
const parsedQuery = parseProjectFilesQuery(request.query);
if ("error" in parsedQuery) return error(reply, parsedQuery.error, 400);
const projectId = Number(parsedQuery.data.project_id);
const project = await getProjectForFiles(projectId);
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
if (!project.project_number)
@@ -232,7 +262,7 @@ export default async function projectFilesRoutes(
return error(reply, "Souborový systém není nakonfigurován", 500);
}
const filePath = query.path || "";
const filePath = parsedQuery.data.path || "";
if (!filePath) return error(reply, "Cesta k souboru je povinná");
const err = await fm.deleteItem(project.project_number, filePath);

View File

@@ -69,6 +69,9 @@ export default async function projectsRoutes(
if ("error" in parsed) return error(reply, parsed.error, 400);
const project = await createProject(parsed.data);
if ("error" in project) {
return error(reply, project.error, (project as any).status ?? 400);
}
await logAudit({
request,

View File

@@ -245,6 +245,8 @@ export default async function quotationsRoutes(
if ("error" in parsed) return error(reply, parsed.error, 400);
const quotation = await createOffer(parsed.data);
if ("error" in quotation)
return error(reply, quotation.error!, quotation.status!);
await logAudit({
request,
@@ -312,9 +314,13 @@ export default async function quotationsRoutes(
// Delete PDF from NAS
if (existing.quotation_number && existing.created_at) {
const yr = new Date(existing.created_at).getFullYear();
nasOffersManager.deleteOfferPdf(
nasOffersManager.buildRelativePath(existing.quotation_number, yr),
);
try {
await nasOffersManager.deleteOfferPdf(
nasOffersManager.buildRelativePath(existing.quotation_number, yr),
);
} catch {
// Non-fatal: NAS delete may fail if file does not exist
}
}
await logAudit({

View File

@@ -111,13 +111,15 @@ export default async function rolesRoutes(
});
if (Array.isArray(body.permission_ids)) {
await prisma.role_permissions.deleteMany({ where: { role_id: id } });
await prisma.role_permissions.createMany({
data: (body.permission_ids as number[]).map((pid) => ({
role_id: id,
permission_id: pid,
})),
});
await prisma.$transaction([
prisma.role_permissions.deleteMany({ where: { role_id: id } }),
prisma.role_permissions.createMany({
data: (body.permission_ids as number[]).map((pid) => ({
role_id: id,
permission_id: pid,
})),
}),
]);
}
await logAudit({

View File

@@ -54,6 +54,39 @@ export default async function totpRoutes(
return error(reply, "Secret a kód jsou povinné", 400);
}
const user = await prisma.users.findUnique({
where: { id: request.authData!.userId },
});
if (!user) return error(reply, "Uživatel nenalezen", 404);
if (user.totp_enabled) {
if (!body.current_code) {
return error(
reply,
"Aktuální TOTP kód je povinný pro změnu 2FA",
400,
);
}
const isValid = OTPAuth.verify(
user.totp_secret!,
String(body.current_code),
);
if (!isValid) {
return error(reply, "Neplatný aktuální TOTP kód", 400);
}
} else {
if (!body.password) {
return error(reply, "Heslo je povinné pro aktivaci 2FA", 400);
}
const valid = await bcrypt.compare(
String(body.password),
user.password_hash,
);
if (!valid) {
return error(reply, "Nesprávné heslo", 400);
}
}
const totp = new OTPAuthLib.TOTP({
secret: OTPAuthLib.Secret.fromBase32(String(secret)),
algorithm: "SHA1",
@@ -185,10 +218,26 @@ export default async function totpRoutes(
const required =
body.required === true || body.required === 1 || body.required === "1";
const settings = await prisma.company_settings.findFirst({
select: { require_2fa: true },
});
const oldValue = settings?.require_2fa ?? false;
await prisma.company_settings.updateMany({
data: { require_2fa: required },
});
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "company_settings",
description: `Povinné 2FA změněno z ${oldValue ? "zapnuto" : "vypnuto"} na ${required ? "zapnuto" : "vypnuto"}`,
oldValues: { require_2fa: oldValue },
newValues: { require_2fa: required },
});
const message = required
? "2FA je nyní povinné pro všechny uživatele"
: "2FA již není povinné";
@@ -200,7 +249,15 @@ export default async function totpRoutes(
// POST - verify backup code (pre-auth, no requireAuth)
fastify.post(
"/backup-verify",
{ bodyLimit: 10240 },
{
config: {
rateLimit: {
max: 5,
timeWindow: "1 minute",
},
},
bodyLimit: 10240,
},
async (request, reply) => {
const parsed = parseBody(TotpBackupSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -211,52 +268,95 @@ export default async function totpRoutes(
.update(login_token)
.digest("hex");
const storedToken = await prisma.totp_login_tokens.findFirst({
where: { token_hash: tokenHash },
});
const settings = await getSystemSettings();
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
return error(reply, "Neplatný nebo expirovaný login token", 401);
}
const txResult = await prisma.$transaction(async (tx) => {
const tokens = await tx.$queryRaw<
Array<{ id: number; user_id: number; expires_at: Date }>
>`
SELECT id, user_id, expires_at FROM totp_login_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
`;
const storedToken = tokens[0] ?? null;
const user = await prisma.users.findUnique({
where: { id: storedToken.user_id },
include: { roles: true },
});
if (!user || !user.totp_backup_codes) {
return error(reply, "Uživatel nenalezen", 401);
}
const backupCodes: string[] = JSON.parse(
user.totp_backup_codes as string,
);
let matchIndex = -1;
for (let i = 0; i < backupCodes.length; i++) {
const isMatch = await bcrypt.compare(String(code), backupCodes[i]);
if (isMatch) {
matchIndex = i;
break;
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
return { error: "Neplatný nebo expirovaný login token", status: 401 };
}
}
if (matchIndex === -1) {
return error(reply, "Neplatný záložní kód", 401);
}
const user = await tx.users.findUnique({
where: { id: storedToken.user_id },
include: { roles: true },
});
backupCodes.splice(matchIndex, 1);
await prisma.users.update({
where: { id: user.id },
data: {
totp_backup_codes: JSON.stringify(backupCodes),
failed_login_attempts: 0,
locked_until: null,
last_login: new Date(),
},
if (!user || !user.totp_backup_codes) {
return { error: "Uživatel nenalezen", status: 401 };
}
if (!user.is_active) {
return { error: "Účet je deaktivován", status: 401 };
}
if (user.locked_until && new Date(user.locked_until) > new Date()) {
return { error: "Účet je dočasně uzamčen", status: 429 };
}
const backupCodes: string[] = JSON.parse(
user.totp_backup_codes as string,
);
let matchIndex = -1;
for (let i = 0; i < backupCodes.length; i++) {
const isMatch = await bcrypt.compare(String(code), backupCodes[i]);
if (isMatch) {
matchIndex = i;
break;
}
}
if (matchIndex === -1) {
const newFailedAttempts = (user.failed_login_attempts ?? 0) + 1;
if (newFailedAttempts >= settings.max_login_attempts) {
await tx.totp_login_tokens.delete({
where: { id: storedToken.id },
});
await tx.users.update({
where: { id: user.id },
data: {
failed_login_attempts: newFailedAttempts,
locked_until: new Date(
Date.now() + settings.lockout_minutes * 60_000,
),
},
});
return { error: "Účet je dočasně uzamčen", status: 429 };
}
await tx.users.update({
where: { id: user.id },
data: { failed_login_attempts: newFailedAttempts },
});
return { error: "Neplatný záložní kód", status: 401 };
}
await tx.totp_login_tokens.delete({ where: { id: storedToken.id } });
backupCodes.splice(matchIndex, 1);
await tx.users.update({
where: { id: user.id },
data: {
totp_backup_codes: JSON.stringify(backupCodes),
failed_login_attempts: 0,
locked_until: null,
last_login: new Date(),
},
});
return { user };
});
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
if ("error" in txResult) {
return error(reply, txResult.error!, txResult.status!);
}
const user = txResult.user;
// Create tokens (same as /login/totp flow)
const { loadAuthData } = await import("../../services/auth");

View File

@@ -206,6 +206,10 @@ export default async function tripsRoutes(
const body = parsed.data;
const authData = request.authData!;
if (body.end_km < body.start_km) {
return error(reply, "Konečný stav km nesmí být menší než počáteční", 400);
}
const trip = await prisma.trips.create({
data: {
vehicle_id: Number(body.vehicle_id),
@@ -247,6 +251,18 @@ export default async function tripsRoutes(
const body = parsed.data;
const authData = request.authData!;
if (
body.end_km != null &&
body.start_km != null &&
body.end_km < body.start_km
) {
return error(
reply,
"Konečný stav km nesmí být menší než počáteční",
400,
);
}
const existing = await prisma.trips.findUnique({ where: { id } });
if (!existing) return error(reply, "Jízda nenalezena", 404);

View File

@@ -1,4 +1,5 @@
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
@@ -59,15 +60,18 @@ export default async function usersRoutes(
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const result = await createUser({
username: body.username,
email: body.email,
password: body.password,
first_name: body.first_name,
last_name: body.last_name,
role_id: body.role_id,
is_active: body.is_active,
});
const result = await createUser(
{
username: body.username,
email: body.email,
password: body.password,
first_name: body.first_name,
last_name: body.last_name,
role_id: body.role_id,
is_active: body.is_active,
},
request.authData?.roleName ?? undefined,
);
if ("error" in result) return error(reply, result.error!, result.status!);
@@ -106,9 +110,20 @@ export default async function usersRoutes(
? Number(parsed.data.role_id)
: (parsed.data.role_id as number | null | undefined),
};
const result = await updateUser(id, userData);
const result = await updateUser(
id,
userData,
request.authData?.roleName ?? undefined,
);
if ("error" in result) return error(reply, result.error!, result.status!);
if (parsed.data.password) {
await prisma.refresh_tokens.updateMany({
where: { user_id: id, replaced_at: null },
data: { replaced_at: new Date() },
});
}
await logAudit({
request,
authData: request.authData,