From 4f4b12f03910458129a186b6a0c236e8dff0da3f Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 24 Apr 2026 08:24:14 +0200 Subject: [PATCH] security: fix all Medium findings from FLAWS_REPORT audit - Auth: TOTP replay protection with counter tracking, constant-time backup code comparison, atomic lockout increment, per-token logout - Invoices/PDFs: net-based VAT calculation, dangerous URL scheme stripping in cleanQuillHtml, orders-pdf error handling - Orders: reject item changes on status transition, cascading delete cleanup, take:1 with orderBy - Projects: atomic rename collision handling, MIME/extension validation, empty customer name rejection - Attendance: Czech public holiday awareness in frontend fund calculation, leave_hours 0 handling, invalid date NaN guard, bounded per-month queries in workfund - Users/Admin: profile audit logging + password validation, session revocation guard, session ID validation, dashboard DB aggregation, soft-deleted record protection in scope templates - Frontend: FormField label linkage, Pagination ARIA, error handling in OrderConfirmationModal, 401 propagation, GPS emoji hidden from screen readers, table sort state fix, geolocation race/abort cleanup, Leaflet popup DOM safety, Vehicles toggleActive minimal body, CompanySettings ref mutation fix, OfferDetail unlock abort, AttendanceBalances combined fetches - Utils: env validation, Puppeteer concurrency mutex, invoice alert cron cleanup on shutdown, body limit alignment, TOTP error logging, trustProxy from env, symlink rejection, rate cache Map usage Co-Authored-By: Claude Opus 4.7 --- prisma/schema.prisma | 4 +- src/admin/components/AttendanceShiftTable.tsx | 41 +++++---- src/admin/components/FormField.tsx | 21 ++++- .../components/OrderConfirmationModal.tsx | 8 ++ src/admin/components/Pagination.tsx | 10 ++- src/admin/hooks/useListData.ts | 14 +++- src/admin/hooks/useTableSort.ts | 8 +- src/admin/pages/Attendance.tsx | 8 ++ src/admin/pages/AttendanceBalances.tsx | 7 +- src/admin/pages/AttendanceHistory.tsx | 84 +++++++++++++++---- src/admin/pages/AttendanceLocation.tsx | 12 ++- src/admin/pages/CompanySettings.tsx | 15 +++- src/admin/pages/OfferDetail.tsx | 11 ++- src/admin/pages/Vehicles.tsx | 9 +- src/config/env.ts | 34 ++++++++ src/routes/admin/auth.ts | 25 +++++- src/routes/admin/dashboard.ts | 27 +++--- src/routes/admin/invoices-pdf.ts | 9 +- src/routes/admin/profile.ts | 18 ++-- src/routes/admin/received-invoices.ts | 12 +-- src/routes/admin/scope-templates.ts | 8 +- src/routes/admin/sessions.ts | 7 +- src/routes/admin/totp.ts | 9 +- src/schemas/customers.schema.ts | 2 +- src/schemas/profile.schema.ts | 2 +- src/server.ts | 14 ++-- src/services/attendance.service.ts | 26 +++--- src/services/auth.ts | 53 +++++------- src/services/exchange-rates.ts | 8 +- src/services/nas-file-manager.ts | 61 ++++++++------ src/services/orders.service.ts | 20 ++++- src/utils/html-to-pdf.ts | 50 ++++++----- src/utils/totp.ts | 16 +++- 33 files changed, 442 insertions(+), 211 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2e02053..f7be420 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -46,7 +46,7 @@ model attendance_project_logs { hours Int? @db.UnsignedInt minutes Int? @db.UnsignedInt attendance attendance @relation(fields: [attendance_id], references: [id], onDelete: Cascade, onUpdate: NoAction) - projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction) + projects projects? @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction) @@index([attendance_id], map: "idx_attendance_project_logs_aid") @@index([project_id], map: "idx_project_id") @@ -197,6 +197,7 @@ model invoices { @@index([customer_id], map: "customer_id") @@index([due_date], map: "idx_invoices_due_date") @@index([status, issue_date], map: "idx_invoices_status_issue") + @@index([status, due_date], map: "idx_invoices_status_due") @@index([order_id], map: "order_id") } @@ -582,6 +583,7 @@ model users { totp_secret String? @db.VarChar(255) totp_enabled Boolean @default(false) totp_backup_codes String? @db.Text + totp_last_used_counter Int? attendance attendance[] leave_balances leave_balances[] leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers") diff --git a/src/admin/components/AttendanceShiftTable.tsx b/src/admin/components/AttendanceShiftTable.tsx index 3040a8f..2ecc9ae 100644 --- a/src/admin/components/AttendanceShiftTable.tsx +++ b/src/admin/components/AttendanceShiftTable.tsx @@ -1,4 +1,4 @@ -import { Link } from "react-router-dom"; +import { Link } from "react-router-dom"; import { formatDate, formatDatetime, @@ -64,20 +64,26 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode { let h: number, m: number, isActive = false; + let durationValid = true; if (log.hours !== null && log.hours !== undefined) { h = parseInt(String(log.hours)) || 0; m = parseInt(String(log.minutes)) || 0; } else { isActive = !log.ended_at; const end = log.ended_at ? new Date(log.ended_at) : new Date(); - const mins = Math.max( - 0, - Math.floor( - (end.getTime() - new Date(log.started_at!).getTime()) / 60000, - ), - ); - h = Math.floor(mins / 60); - m = mins % 60; + const start = log.started_at ? new Date(log.started_at) : null; + if (start && !isNaN(start.getTime()) && !isNaN(end.getTime())) { + const mins = Math.max( + 0, + Math.floor((end.getTime() - start.getTime()) / 60000), + ); + h = Math.floor(mins / 60); + m = mins % 60; + } else { + durationValid = false; + h = 0; + m = 0; + } } return ( - {log.project_name || `#${log.project_id}`} ({h}: - {String(m).padStart(2, "0")}h{isActive ? " \u25B8" : ""}) + {log.project_name || `#${log.project_id}`} {durationValid ? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " \u25B8" : ""})` : "—"} ); })} @@ -118,7 +123,7 @@ export default function AttendanceShiftTable({ if (records.length === 0) { return (
-

Za tento měsíc nejsou žádné záznamy.

+

Za tento měsíc nejsou žádné záznamy.

); } @@ -129,15 +134,15 @@ export default function AttendanceShiftTable({ Datum - Zaměstnanec + ZamÄ›stnanec Typ - Příchod + Příchod Pauza Odchod Hodiny Projekt GPS - Poznámka + Poznámka Akce @@ -146,7 +151,8 @@ export default function AttendanceShiftTable({ const leaveType = record.leave_type || "work"; const isLeave = leaveType !== "work"; const workMinutes = isLeave - ? (Number(record.leave_hours) || 8) * 60 + ? (record.leave_hours != null ? Number(record.leave_hours) : 8) * + 60 : calculateWorkMinutes(record); const hasLocation = (record.arrival_lat && record.arrival_lng) || @@ -186,7 +192,7 @@ export default function AttendanceShiftTable({ title="Zobrazit polohu" aria-label="Zobrazit polohu" > - {"\uD83D\uDCCD"} + ) : ( "\u2014" @@ -251,3 +257,4 @@ export default function AttendanceShiftTable({ ); } + diff --git a/src/admin/components/FormField.tsx b/src/admin/components/FormField.tsx index 7cd6e53..8384e41 100644 --- a/src/admin/components/FormField.tsx +++ b/src/admin/components/FormField.tsx @@ -1,4 +1,10 @@ -import type { CSSProperties, ReactNode } from "react"; +import { + type CSSProperties, + type ReactNode, + isValidElement, + cloneElement, + useId, +} from "react"; interface FormFieldProps { label: ReactNode; @@ -15,13 +21,22 @@ export default function FormField({ required, style, }: FormFieldProps) { + const generatedId = useId(); + const childProps = isValidElement(children) + ? (children.props as Record) + : null; + const childId = childProps?.id ? String(childProps.id) : generatedId; + const childWithId = isValidElement(children) + ? cloneElement(children, { id: childId } as React.Attributes) + : children; + return (
-
); diff --git a/src/admin/components/OrderConfirmationModal.tsx b/src/admin/components/OrderConfirmationModal.tsx index ec79864..4577d1c 100644 --- a/src/admin/components/OrderConfirmationModal.tsx +++ b/src/admin/components/OrderConfirmationModal.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from "react"; import { motion, AnimatePresence } from "framer-motion"; +import { useAlert } from "../context/AlertContext"; interface ConfirmationItem { description: string; @@ -33,6 +34,7 @@ export default function OrderConfirmationModal({ defaultVatRate, applyVat, }: OrderConfirmationModalProps) { + const alert = useAlert(); const [step, setStep] = useState<"choose" | "edit">("choose"); const [lang, setLang] = useState("cs"); const [applyVatState, setApplyVatState] = useState(applyVat); @@ -43,6 +45,9 @@ export default function OrderConfirmationModal({ setLoading(true); try { await onGenerate(lang, applyVatState, undefined); + } catch (err) { + console.error("Chyba při generování potvrzení:", err); + alert.error("Nepodařilo se vygenerovat potvrzení"); } finally { setLoading(false); setStep("choose"); @@ -54,6 +59,9 @@ export default function OrderConfirmationModal({ setLoading(true); try { await onGenerate(lang, applyVatState, items); + } catch (err) { + console.error("Chyba při generování potvrzení:", err); + alert.error("Nepodařilo se vygenerovat potvrzení"); } finally { setLoading(false); setStep("choose"); diff --git a/src/admin/components/Pagination.tsx b/src/admin/components/Pagination.tsx index b4a11a8..3f54bab 100644 --- a/src/admin/components/Pagination.tsx +++ b/src/admin/components/Pagination.tsx @@ -36,13 +36,18 @@ export default function Pagination({ }; return ( -
+
{total} záznamů
@@ -74,6 +81,7 @@ export default function Pagination({ disabled={page >= total_pages} onClick={() => onPageChange(page + 1)} className="admin-pagination-page" + aria-label="Další stránka" > ( const abortRef = useRef(null); const debouncedSearch = useDebounce(search, 300); + const extraParamsKey = Object.entries(extraParams) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([k, v]) => `${k}=${v}`) + .join("&"); + const fetchData = useCallback(async () => { if (abortRef.current) abortRef.current.abort(); const controller = new AbortController(); @@ -66,7 +71,10 @@ export default function useListData( ? `${endpoint}?${params}` : `${API_BASE}/${endpoint}?${params}`; const response = await apiFetch(url, { signal: controller.signal }); - if (response.status === 401) return; + if (response.status === 401) { + window.location.href = "/login"; + return; + } const result = await response.json(); if (result.success) { const data = dataKey @@ -105,8 +113,8 @@ export default function useListData( page, perPage, dataKey, - JSON.stringify(extraParams), - ]); // eslint-disable-line react-hooks/exhaustive-deps + extraParamsKey, + ]); useEffect(() => { fetchData(); diff --git a/src/admin/hooks/useTableSort.ts b/src/admin/hooks/useTableSort.ts index ef1813c..564d560 100644 --- a/src/admin/hooks/useTableSort.ts +++ b/src/admin/hooks/useTableSort.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback } from "react"; interface SortState { sort: string; @@ -13,10 +13,10 @@ export default function useTableSort( sort: defaultSort, order: defaultOrder, }); - const userClicked = useRef(false); + const [userClicked, setUserClicked] = useState(false); const handleSort = useCallback((column: string) => { - userClicked.current = true; + setUserClicked(true); setState((prev) => { if (prev.sort === column) { return { sort: column, order: prev.order === "asc" ? "desc" : "asc" }; @@ -25,7 +25,7 @@ export default function useTableSort( }); }, []); - const activeSort = userClicked.current ? state.sort : null; + const activeSort = userClicked ? state.sort : null; return { sort: state.sort, order: state.order, handleSort, activeSort }; } diff --git a/src/admin/pages/Attendance.tsx b/src/admin/pages/Attendance.tsx index 7a8e5db..36dc0e1 100644 --- a/src/admin/pages/Attendance.tsx +++ b/src/admin/pages/Attendance.tsx @@ -126,9 +126,12 @@ export default function Attendance() { action: string | null; }>({ show: false, action: null }); const geoAbortRef = useRef(null); + const mountedRef = useRef(true); + const latestActionRef = useRef(null); useEffect(() => { return () => { + mountedRef.current = false; if (geoAbortRef.current) geoAbortRef.current.abort(); }; }, []); @@ -179,6 +182,7 @@ export default function Attendance() { const handlePunch = (action: string) => { setSubmitting(true); + latestActionRef.current = action; if (!navigator.geolocation) { alert.warning("GPS není dostupná"); @@ -188,6 +192,7 @@ export default function Attendance() { navigator.geolocation.getCurrentPosition( (position) => { + if (!mountedRef.current) return; const { latitude, longitude, accuracy } = position.coords; submitPunch(action, { latitude, longitude, accuracy, address: "" }); @@ -203,6 +208,8 @@ export default function Attendance() { ) .then((r) => r.json()) .then((geoData) => { + if (!mountedRef.current) return; + if (latestActionRef.current !== action) return; if (geoData.display_name) { apiFetch(`${API_BASE}/attendance/update-address`, { method: "POST", @@ -219,6 +226,7 @@ export default function Attendance() { .catch(() => {}); }, (geoError) => { + if (!mountedRef.current) return; let errorMsg = "Nepodařilo se získat polohu"; if (geoError.code === geoError.PERMISSION_DENIED) { errorMsg = "Přístup k poloze byl zamítnut"; diff --git a/src/admin/pages/AttendanceBalances.tsx b/src/admin/pages/AttendanceBalances.tsx index d4303f9..338345d 100644 --- a/src/admin/pages/AttendanceBalances.tsx +++ b/src/admin/pages/AttendanceBalances.tsx @@ -224,9 +224,10 @@ export default function AttendanceBalances() { }, [year]); useEffect(() => { - fetchData(); - fetchFundData(); - fetchProjectData(); + const loadAll = async () => { + await Promise.all([fetchData(), fetchFundData(), fetchProjectData()]); + }; + loadAll(); }, [fetchData, fetchFundData, fetchProjectData]); useModalLock(showEditModal); diff --git a/src/admin/pages/AttendanceHistory.tsx b/src/admin/pages/AttendanceHistory.tsx index bfa3730..f4314b3 100644 --- a/src/admin/pages/AttendanceHistory.tsx +++ b/src/admin/pages/AttendanceHistory.tsx @@ -69,6 +69,73 @@ const formatBreakRange = (record: AttendanceRecord): string => { return "—"; }; +function getEasterSunday(year: number): string { + const a = year % 19; + const b = Math.floor(year / 100); + const c = year % 100; + const d = Math.floor(b / 4); + const e = b % 4; + const f = Math.floor((b + 8) / 25); + const g = Math.floor((b - f + 1) / 3); + const h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4); + const k = c % 4; + const l = (32 + 2 * e + 2 * i - h - k) % 7; + const m = Math.floor((a + 11 * h + 22 * l) / 451); + const month = Math.floor((h + l - 7 * m + 114) / 31); + const day = ((h + l - 7 * m + 114) % 31) + 1; + return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +function getCzechHolidays(year: number): string[] { + const y = String(year); + const holidays = [ + `${y}-01-01`, + `${y}-05-01`, + `${y}-05-08`, + `${y}-07-05`, + `${y}-07-06`, + `${y}-09-28`, + `${y}-10-28`, + `${y}-11-17`, + `${y}-12-24`, + `${y}-12-25`, + `${y}-12-26`, + ]; + const easterSunday = getEasterSunday(year); + const easterDate = new Date(easterSunday); + const goodFriday = new Date(easterDate); + goodFriday.setDate(goodFriday.getDate() - 2); + const easterMonday = new Date(easterDate); + easterMonday.setDate(easterMonday.getDate() + 1); + const pad = (n: number) => String(n).padStart(2, "0"); + holidays.push( + `${goodFriday.getFullYear()}-${pad(goodFriday.getMonth() + 1)}-${pad(goodFriday.getDate())}`, + ); + holidays.push( + `${easterMonday.getFullYear()}-${pad(easterMonday.getMonth() + 1)}-${pad(easterMonday.getDate())}`, + ); + holidays.sort(); + return holidays; +} + +function getBusinessDaysInMonth(year: number, month: number): number { + const holidays = getCzechHolidays(year); + let count = 0; + const daysInMonth = new Date(year, month + 1, 0).getDate(); + for (let day = 1; day <= daysInMonth; day++) { + const date = new Date(year, month, day); + const dow = date.getDay(); + if (dow !== 0 && dow !== 6) { + const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + if (!holidays.includes(dateStr)) { + count++; + } + } + } + return count; +} + const renderProjectCell = (record: AttendanceRecord) => { if (record.project_logs && record.project_logs.length > 0) { return ( @@ -185,7 +252,8 @@ export default function AttendanceHistory() { if (leaveType === "work") { totalMinutes += calculateWorkMinutes(record); } else { - const hours = Number(record.leave_hours) || 8; + const hours = + record.leave_hours != null ? Number(record.leave_hours) : 8; if (leaveType === "vacation") vacationHours += hours; else if (leaveType === "sick") sickHours += hours; else if (leaveType === "holiday") holidayHours += hours; @@ -193,21 +261,9 @@ export default function AttendanceHistory() { } } - // Exclude holidays from business days (matching PHP CzechHolidays logic) const yr = parseInt(yearStr, 10); const mo = parseInt(monthStr, 10) - 1; - const holidayDays = records.filter( - (r) => (r.leave_type || "work") === "holiday", - ).length; - let businessDays = 0; - const cur = new Date(yr, mo, 1); - while (cur.getMonth() === mo) { - const dow = cur.getDay(); - if (dow !== 0 && dow !== 6) businessDays++; - cur.setDate(cur.getDate() + 1); - } - // Subtract holidays from business days (holidays are non-working days, not part of the fund) - businessDays = Math.max(0, businessDays - holidayDays); + const businessDays = getBusinessDaysInMonth(yr, mo); const fund = businessDays * 8; const worked = Math.round((totalMinutes / 60) * 100) / 100; // Covered = worked + vacation + sick (NOT holiday/unpaid — holiday is excluded from fund, unpaid is voluntary) diff --git a/src/admin/pages/AttendanceLocation.tsx b/src/admin/pages/AttendanceLocation.tsx index 21706a9..3210457 100644 --- a/src/admin/pages/AttendanceLocation.tsx +++ b/src/admin/pages/AttendanceLocation.tsx @@ -134,9 +134,17 @@ export default function AttendanceLocation() { fillOpacity: 0.8, }).addTo(map); - marker.bindPopup( - `${loc.label}
${loc.time}
Přesnost: ${Math.round(loc.accuracy)}m`, + const popupEl = document.createElement("div"); + const strong = document.createElement("strong"); + strong.textContent = loc.label; + popupEl.appendChild(strong); + popupEl.appendChild(document.createElement("br")); + popupEl.appendChild(document.createTextNode(loc.time)); + popupEl.appendChild(document.createElement("br")); + popupEl.appendChild( + document.createTextNode(`Přesnost: ${Math.round(loc.accuracy)}m`), ); + marker.bindPopup(popupEl); if (loc.accuracy > 0) { L.circle([loc.lat, loc.lng], { diff --git a/src/admin/pages/CompanySettings.tsx b/src/admin/pages/CompanySettings.tsx index dd9175e..63db0db 100644 --- a/src/admin/pages/CompanySettings.tsx +++ b/src/admin/pages/CompanySettings.tsx @@ -85,7 +85,6 @@ export default function CompanySettings({ vat_id: "", }); const [customFields, setCustomFields] = useState([]); - const customFieldKeyCounter = useRef(0); const [fieldOrder, setFieldOrder] = useState([ ...DEFAULT_FIELD_ORDER, ]); @@ -197,9 +196,17 @@ export default function CompanySettings({ const cf = Array.isArray(d.custom_fields) && d.custom_fields.length > 0 ? d.custom_fields.map( - (f: { name: string; value: string; showLabel?: boolean }) => ({ + ( + f: { + name: string; + value: string; + showLabel?: boolean; + _key?: string; + }, + i: number, + ) => ({ ...f, - _key: `cf-${++customFieldKeyCounter.current}`, + _key: f._key || `cf-${Date.now()}-${i}`, }), ) : []; @@ -716,7 +723,7 @@ export default function CompanySettings({ name: "", value: "", showLabel: true, - _key: `cf-${++customFieldKeyCounter.current}`, + _key: `cf-${Date.now()}`, }, ]) } diff --git a/src/admin/pages/OfferDetail.tsx b/src/admin/pages/OfferDetail.tsx index ad23dde..6a6b5b5 100644 --- a/src/admin/pages/OfferDetail.tsx +++ b/src/admin/pages/OfferDetail.tsx @@ -334,6 +334,7 @@ export default function OfferDetail() { full_name: string; } | null>(null); const heartbeatRef = useRef | null>(null); + const unlockAbortRef = useRef(null); useModalLock(showOrderModal); @@ -451,10 +452,14 @@ export default function OfferDetail() { return () => { if (heartbeatRef.current) clearInterval(heartbeatRef.current); + if (unlockAbortRef.current) unlockAbortRef.current.abort(); // Release lock on unmount - apiFetch(`${API_BASE}/offers/${id}/unlock`, { method: "POST" }).catch( - () => {}, - ); + const controller = new AbortController(); + unlockAbortRef.current = controller; + apiFetch(`${API_BASE}/offers/${id}/unlock`, { + method: "POST", + signal: controller.signal, + }).catch(() => {}); }; }, [isEdit, id, isLockedByOther, isInvalidated]); diff --git a/src/admin/pages/Vehicles.tsx b/src/admin/pages/Vehicles.tsx index bc50fb8..235d3f0 100644 --- a/src/admin/pages/Vehicles.tsx +++ b/src/admin/pages/Vehicles.tsx @@ -173,14 +173,7 @@ export default function Vehicles() { const response = await apiFetch(`${API_BASE}/vehicles/${vehicle.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - spz: vehicle.spz, - name: vehicle.name, - brand: vehicle.brand || "", - model: vehicle.model || "", - initial_km: vehicle.initial_km, - is_active: !vehicle.is_active, - }), + body: JSON.stringify({ is_active: !vehicle.is_active }), }); const result = await response.json(); diff --git a/src/config/env.ts b/src/config/env.ts index d41d157..90ea705 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -81,7 +81,41 @@ export const config = { origins: (process.env.CORS_ORIGINS || "").split(",").filter(Boolean), }, + trustProxy: (process.env.TRUST_PROXY || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + security: { bcryptCost: 12, }, } as const; + +const HEX64_RE = /^[0-9a-fA-F]{64}$/; +if (!HEX64_RE.test(config.jwt.secret)) { + throw new Error("JWT_SECRET must be a 64-character hex string"); +} +if (!HEX64_RE.test(config.totp.encryptionKey)) { + throw new Error("TOTP_ENCRYPTION_KEY must be a 64-character hex string"); +} +if (Number.isNaN(config.port) || config.port < 1 || config.port > 65535) { + throw new Error("PORT must be a valid TCP port (1-65535)"); +} +if ( + Number.isNaN(config.jwt.accessTokenExpiry) || + config.jwt.accessTokenExpiry <= 0 +) { + throw new Error("ACCESS_TOKEN_EXPIRY must be a positive integer"); +} +if ( + Number.isNaN(config.jwt.refreshTokenSessionExpiry) || + config.jwt.refreshTokenSessionExpiry <= 0 +) { + throw new Error("REFRESH_TOKEN_SESSION_EXPIRY must be a positive integer"); +} +if ( + Number.isNaN(config.jwt.refreshTokenRememberExpiry) || + config.jwt.refreshTokenRememberExpiry <= 0 +) { + throw new Error("REFRESH_TOKEN_REMEMBER_EXPIRY must be a positive integer"); +} diff --git a/src/routes/admin/auth.ts b/src/routes/admin/auth.ts index 1c561df..2e2bad3 100644 --- a/src/routes/admin/auth.ts +++ b/src/routes/admin/auth.ts @@ -153,11 +153,32 @@ export default async function authRoutes( return error(reply, "Uživatel nenalezen", 401); } - const isValid = OTPAuth.verify(user.totp_secret, totp_code); - if (!isValid) { + const verifyResult = OTPAuth.verify(user.totp_secret, totp_code); + if (!verifyResult.valid) { return error(reply, "Neplatný TOTP kód", 401); } + // Reject replayed TOTP codes + const replayCheck = await prisma.$transaction(async (tx) => { + const rows = await tx.$queryRaw< + Array<{ totp_last_used_counter: number | null }> + >`SELECT totp_last_used_counter FROM users WHERE id = ${user.id} FOR UPDATE`; + const lastCounter = rows[0]?.totp_last_used_counter ?? null; + if ( + lastCounter !== null && + verifyResult.counter !== null && + verifyResult.counter <= lastCounter + ) { + return { replay: true }; + } + await tx.$executeRaw`UPDATE users SET totp_last_used_counter = ${verifyResult.counter} WHERE id = ${user.id}`; + return { replay: false }; + }); + + if (replayCheck.replay) { + return error(reply, "TOTP kód již byl použit", 401); + } + // Reset failed attempts and update last login (TOTP verified = successful login) await prisma.users.update({ where: { id: user.id }, diff --git a/src/routes/admin/dashboard.ts b/src/routes/admin/dashboard.ts index 6adbbff..edf8cba 100644 --- a/src/routes/admin/dashboard.ts +++ b/src/routes/admin/dashboard.ts @@ -179,24 +179,25 @@ export default async function dashboardRoutes( // Invoices — only for invoices.view if (has("invoices.view")) { - const [unpaidCount, issuedThisMonth] = await Promise.all([ + const [unpaidCount, revenueAgg] = await Promise.all([ prisma.invoices.count({ where: { status: "issued" } }), - prisma.invoices.findMany({ - where: { issue_date: { gte: monthStart, lt: monthEnd } }, - include: { invoice_items: true }, - }), + prisma.$queryRaw< + Array<{ currency: string | null; total: string | number | null }> + >` + SELECT i.currency, SUM(ii.quantity * ii.unit_price) as total + FROM invoices i + JOIN invoice_items ii ON i.id = ii.invoice_id + WHERE i.issue_date >= ${monthStart} AND i.issue_date < ${monthEnd} + GROUP BY i.currency + `, ]); const revenueByCurrency: Record = {}; - for (const inv of issuedThisMonth) { - const currency = inv.currency ?? "CZK"; - let total = 0; - for (const item of inv.invoice_items) { - total += - (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); - } + for (const row of revenueAgg) { + const currency = row.currency || "CZK"; + const amount = Number(row.total) || 0; revenueByCurrency[currency] = - (revenueByCurrency[currency] ?? 0) + total; + (revenueByCurrency[currency] || 0) + amount; } result.invoices = { diff --git a/src/routes/admin/invoices-pdf.ts b/src/routes/admin/invoices-pdf.ts index 7b6eccd..0febf95 100644 --- a/src/routes/admin/invoices-pdf.ts +++ b/src/routes/admin/invoices-pdf.ts @@ -54,7 +54,14 @@ function cleanQuillHtml(html: string | null | undefined): string { ); s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, ""); 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( + /href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi, + 'href="#"', + ); + s = s.replace( + /src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi, + 'src=""', + ); s = s.replace(/( )/g, " "); s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, ""); let prev = ""; diff --git a/src/routes/admin/profile.ts b/src/routes/admin/profile.ts index 4039f7c..c3a104c 100644 --- a/src/routes/admin/profile.ts +++ b/src/routes/admin/profile.ts @@ -63,19 +63,19 @@ export default async function profileRoutes( config.security.bcryptCost, ); data.password_changed_at = new Date(); - - await logAudit({ - request, - authData: request.authData, - action: "password_change", - entityType: "user", - entityId: userId, - description: "Změna hesla", - }); } await prisma.users.update({ where: { id: userId }, data }); + await logAudit({ + request, + authData: request.authData, + action: "update", + entityType: "user", + entityId: userId, + description: data.password_hash ? "Změna hesla" : "Aktualizace profilu", + }); + if (body.current_password && body.new_password) { await prisma.refresh_tokens.updateMany({ where: { user_id: userId, replaced_at: null }, diff --git a/src/routes/admin/received-invoices.ts b/src/routes/admin/received-invoices.ts index d87ad0f..d89c7e8 100644 --- a/src/routes/admin/received-invoices.ts +++ b/src/routes/admin/received-invoices.ts @@ -263,10 +263,10 @@ export default async function receivedInvoicesRoutes( const meta = invoicesMeta[i] || {}; const amount = Number(meta.amount ?? 0); const vatRate = Number(meta.vat_rate ?? 21); - // Amount includes VAT — extract VAT portion: amount - amount/(1 + rate/100) + // Amount is net — VAT = amount * rate / 100 const vatAmount = vatRate > 0 - ? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100 + ? Math.round(((amount * vatRate) / 100) * 100) / 100 : 0; const issueDate = meta.issue_date @@ -434,13 +434,9 @@ export default async function receivedInvoicesRoutes( body.vat_rate !== undefined ? Number(body.vat_rate) : Number(existing.vat_rate); - // Amount includes VAT — extract VAT portion: amount - amount/(1 + rate/100) + // Amount is net — VAT = amount * rate / 100 const computedVat = - finalVatRate > 0 - ? roundMoney( - finalAmount - roundMoney(finalAmount / (1 + finalVatRate / 100)), - ) - : 0; + finalVatRate > 0 ? roundMoney((finalAmount * finalVatRate) / 100) : 0; // Auto-set paid_date when status transitions to paid (matching PHP) const newStatus = diff --git a/src/routes/admin/scope-templates.ts b/src/routes/admin/scope-templates.ts index 4d4e528..cec7143 100644 --- a/src/routes/admin/scope-templates.ts +++ b/src/routes/admin/scope-templates.ts @@ -70,12 +70,12 @@ export default async function scopeTemplatesRoutes( }; if (body.id) { - const existingItem = await prisma.item_templates.findUnique({ - where: { id: Number(body.id) }, + const existingItem = await prisma.item_templates.findFirst({ + where: { id: Number(body.id), is_deleted: false }, }); if (!existingItem) return error(reply, "Šablona nenalezena", 404); - await prisma.item_templates.update({ - where: { id: Number(body.id) }, + await prisma.item_templates.updateMany({ + where: { id: Number(body.id), is_deleted: false }, data: { ...itemData, modified_at: new Date() }, }); return success( diff --git a/src/routes/admin/sessions.ts b/src/routes/admin/sessions.ts index 6286653..c6e6119 100644 --- a/src/routes/admin/sessions.ts +++ b/src/routes/admin/sessions.ts @@ -86,6 +86,7 @@ export default async function sessionsRoutes( { preHandler: requireAuth }, async (request, reply) => { const id = parseInt(request.params.id, 10); + if (Number.isNaN(id)) return error(reply, "Neplatné ID relace", 400); const authData = request.authData!; const session = await prisma.refresh_tokens.findFirst({ @@ -111,11 +112,15 @@ export default async function sessionsRoutes( const currentToken = request.cookies?.refresh_token; const currentHash = currentToken ? hashToken(currentToken) : null; + if (!currentHash) { + return error(reply, "Nelze identifikovat aktuální relaci", 400); + } + await prisma.refresh_tokens.updateMany({ where: { user_id: authData.userId, replaced_at: null, - ...(currentHash ? { token_hash: { not: currentHash } } : {}), + token_hash: { not: currentHash }, }, data: { replaced_at: new Date() }, }); diff --git a/src/routes/admin/totp.ts b/src/routes/admin/totp.ts index 279dd30..6fa04d2 100644 --- a/src/routes/admin/totp.ts +++ b/src/routes/admin/totp.ts @@ -67,11 +67,11 @@ export default async function totpRoutes( 400, ); } - const isValid = OTPAuth.verify( + const verifyResult = OTPAuth.verify( user.totp_secret!, String(body.current_code), ); - if (!isValid) { + if (!verifyResult.valid) { return error(reply, "Neplatný aktuální TOTP kód", 400); } } else { @@ -153,8 +153,8 @@ export default async function totpRoutes( return error(reply, "2FA není aktivní", 400); } - const isValid = OTPAuth.verify(user.totp_secret, String(body.code)); - if (!isValid) { + const verifyResult = OTPAuth.verify(user.totp_secret, String(body.code)); + if (!verifyResult.valid) { return error(reply, "Neplatný TOTP kód", 400); } @@ -308,7 +308,6 @@ export default async function totpRoutes( const isMatch = await bcrypt.compare(String(code), backupCodes[i]); if (isMatch) { matchIndex = i; - break; } } diff --git a/src/schemas/customers.schema.ts b/src/schemas/customers.schema.ts index 5c1b715..74244c0 100644 --- a/src/schemas/customers.schema.ts +++ b/src/schemas/customers.schema.ts @@ -13,7 +13,7 @@ export const CreateCustomerSchema = z.object({ }); export const UpdateCustomerSchema = z.object({ - name: z.string().optional(), + name: z.string().min(1, "Název zákazníka je povinný").optional(), street: z.string().nullish(), city: z.string().nullish(), postal_code: z.string().nullish(), diff --git a/src/schemas/profile.schema.ts b/src/schemas/profile.schema.ts index 772285b..3049959 100644 --- a/src/schemas/profile.schema.ts +++ b/src/schemas/profile.schema.ts @@ -5,7 +5,7 @@ export const UpdateProfileSchema = z.object({ first_name: z.string().optional(), last_name: z.string().optional(), current_password: z.string().optional(), - new_password: z.string().optional(), + new_password: z.string().min(8, "Heslo musí mít alespoň 8 znaků").optional(), }); export type UpdateProfileInput = z.infer; diff --git a/src/server.ts b/src/server.ts index 28cba44..3932a80 100644 --- a/src/server.ts +++ b/src/server.ts @@ -36,13 +36,14 @@ const app = Fastify({ logger: { level: config.isProduction ? "warn" : "info", }, - trustProxy: config.isProduction - ? ["127.0.0.1", "192.168.50.100"] - : ["127.0.0.1", "::1"], - bodyLimit: 1048576, + trustProxy: + config.trustProxy.length > 0 ? config.trustProxy : ["127.0.0.1", "::1"], + bodyLimit: config.nas.maxUploadSize, }); async function start() { + let invoiceAlertCron: any = null; + // --- Plugins --- await app.register(cors, { origin: @@ -184,7 +185,7 @@ async function start() { // --- Invoice alert cron (daily at 8:00 AM) --- if (config.email.invoiceAlert) { const cron = await import("node-cron"); - cron.default.schedule("0 8 * * *", async () => { + invoiceAlertCron = cron.default.schedule("0 8 * * *", async () => { try { const { checkInvoiceAlerts } = await import("./services/invoice-alerts"); @@ -209,6 +210,9 @@ async function start() { const shutdown = async (signal: string) => { app.log.info(`${signal} received, shutting down gracefully...`); try { + if (invoiceAlertCron) { + invoiceAlertCron.stop(); + } await app.close(); const { default: prisma } = await import("./config/database"); await prisma.$disconnect(); diff --git a/src/services/attendance.service.ts b/src/services/attendance.service.ts index bc49e0f..5eaefdc 100644 --- a/src/services/attendance.service.ts +++ b/src/services/attendance.service.ts @@ -517,12 +517,6 @@ export async function getWorkfund(year: number) { }; } - const yearStart = new Date(year, 0, 1); - const yearEnd = new Date(year, maxMonth + 1, 0, 23, 59, 59); - const allRecords = await prisma.attendance.findMany({ - where: { shift_date: { gte: yearStart, lte: yearEnd } }, - }); - const months: Record< string, { @@ -553,6 +547,19 @@ export async function getWorkfund(year: number) { const fundToDate = bizDaysToDate * 8; const monthStart = new Date(year, m, 1); const monthEnd = new Date(year, m + 1, 0, 23, 59, 59); + const monthRecords = await prisma.attendance.findMany({ + where: { shift_date: { gte: monthStart, lte: monthEnd } }, + select: { + user_id: true, + shift_date: true, + leave_type: true, + arrival_time: true, + departure_time: true, + break_start: true, + break_end: true, + leave_hours: true, + }, + }); const monthUsers: Record< string, @@ -566,12 +573,7 @@ export async function getWorkfund(year: number) { > = {}; for (const u of users) { - const recs = allRecords.filter( - (r) => - r.user_id === u.id && - r.shift_date >= monthStart && - r.shift_date <= monthEnd, - ); + const recs = monthRecords.filter((r) => r.user_id === u.id); let worked = 0; let vacationHours = 0; let sickHours = 0; diff --git a/src/services/auth.ts b/src/services/auth.ts index 78fc99f..175c50b 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -53,7 +53,9 @@ async function loadAuthData(userId: number): Promise { const isAdmin = user.roles?.name === "admin"; const permissions = isAdmin - ? (await prisma.permissions.findMany()).map((p: { name: string }) => p.name) + ? (await prisma.permissions.findMany({ select: { name: true } })).map( + (p) => p.name, + ) : (user.roles?.role_permissions ?? []).map( (rp: { permissions: { name: string } }) => rp.permissions.name, ); @@ -129,21 +131,24 @@ export async function login( const passwordValid = await bcrypt.compare(password, user.password_hash); if (!passwordValid) { const settings = await getSystemSettings(); - await prisma.users.update({ - where: { id: user.id }, - data: { failed_login_attempts: { increment: 1 } }, - }); - - if ((user.failed_login_attempts ?? 0) + 1 >= settings.max_login_attempts) { - await prisma.users.update({ + await prisma.$transaction(async (tx) => { + const updated = await tx.users.update({ where: { id: user.id }, - data: { - locked_until: new Date( - Date.now() + settings.lockout_minutes * 60_000, - ), - }, + data: { failed_login_attempts: { increment: 1 } }, + select: { failed_login_attempts: true }, }); - } + + if ((updated.failed_login_attempts ?? 0) >= settings.max_login_attempts) { + await tx.users.update({ + where: { id: user.id }, + data: { + locked_until: new Date( + Date.now() + settings.lockout_minutes * 60_000, + ), + }, + }); + } + }); return { type: "error", @@ -310,26 +315,12 @@ export async function refreshAccessToken( export async function logout(refreshTokenRaw: string): Promise { const tokenHash = hashToken(refreshTokenRaw); - const token = await prisma.refresh_tokens.findFirst({ + + // Delete only the specific token presented, not all sessions + await prisma.refresh_tokens.deleteMany({ where: { token_hash: tokenHash }, }); - if (token) { - // Delete all tokens for this user from the same IP + user agent (same browser session) - await prisma.refresh_tokens.deleteMany({ - where: { - user_id: token.user_id, - ip_address: token.ip_address, - user_agent: token.user_agent, - }, - }); - } else { - // Fallback: just delete by hash - await prisma.refresh_tokens.deleteMany({ - where: { token_hash: tokenHash }, - }); - } - await prisma.refresh_tokens.deleteMany({ where: { expires_at: { lt: new Date() } }, }); diff --git a/src/services/exchange-rates.ts b/src/services/exchange-rates.ts index a98e571..92968d5 100644 --- a/src/services/exchange-rates.ts +++ b/src/services/exchange-rates.ts @@ -10,13 +10,13 @@ interface CnbRate { amount: number; } -const rateCache: Record> = {}; +const rateCache = new Map>(); async function fetchRatesForDate( date?: string, ): Promise> { const key = date || "today"; - if (rateCache[key]) return rateCache[key]; + if (rateCache.has(key)) return rateCache.get(key)!; try { let url = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"; @@ -32,11 +32,11 @@ async function fetchRatesForDate( rates[r.currencyCode] = r.rate / r.amount; } - rateCache[key] = rates; + rateCache.set(key, rates); return rates; } catch (err) { console.error("Failed to fetch CNB exchange rates:", err); - if (rateCache["today"]) return rateCache["today"]; + if (rateCache.has("today")) return rateCache.get("today")!; throw new Error("Nepodařilo se získat aktuální kurzy z ČNB"); } } diff --git a/src/services/nas-file-manager.ts b/src/services/nas-file-manager.ts index b525c00..59ec138 100644 --- a/src/services/nas-file-manager.ts +++ b/src/services/nas-file-manager.ts @@ -337,9 +337,16 @@ export class NasFileManager { try { const typeResult = await FileType.fromFile(tempPath); - if (typeResult && this.isSuspiciousMime(typeResult.mime, ext)) { - await fs.promises.unlink(tempPath).catch(() => {}); - return "Obsah souboru neodpovídá jeho příponě"; + if (typeResult) { + if (this.isSuspiciousMime(typeResult.mime)) { + await fs.promises.unlink(tempPath).catch(() => {}); + return "Obsah souboru neodpovídá jeho příponě"; + } + const expectedMime = ext ? MIME_MAP[ext] : null; + if (expectedMime && typeResult.mime !== expectedMime) { + await fs.promises.unlink(tempPath).catch(() => {}); + return "Obsah souboru neodpovídá jeho příponě"; + } } } catch { // If file-type fails, continue without MIME check @@ -347,27 +354,29 @@ export class NasFileManager { let destPath = dirPath + "/" + safeName; - try { - await fs.promises.stat(destPath); - const base = path.basename(safeName, ext ? "." + ext : ""); - let counter = 1; - do { - safeName = base + "_" + counter + (ext ? "." + ext : ""); - destPath = dirPath + "/" + safeName; - counter++; - } while ( - await fs.promises - .stat(destPath) - .then(() => true) - .catch(() => false) - ); - } catch { - // destPath does not exist, continue - } + // Attempt atomic rename; if destination exists, append counter + let renamed = false; + let attempts = 0; + const maxAttempts = 1000; + do { + try { + await fs.promises.rename(tempPath, destPath); + renamed = true; + break; + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e.code === "EEXIST") { + const base = path.basename(safeName, ext ? "." + ext : ""); + attempts++; + safeName = base + "_" + attempts + (ext ? "." + ext : ""); + destPath = dirPath + "/" + safeName; + } else { + break; + } + } + } while (!renamed && attempts < maxAttempts); - try { - await fs.promises.rename(tempPath, destPath); - } catch { + if (!renamed) { await fs.promises.unlink(tempPath).catch(() => {}); return "Nepodařilo se uložit soubor"; } @@ -514,8 +523,8 @@ export class NasFileManager { } try { - const stat = await fs.promises.stat(dirPath); - if (!stat.isDirectory()) { + const stat = await fs.promises.lstat(dirPath); + if (stat.isSymbolicLink() || !stat.isDirectory()) { return "Nadřazená složka neexistuje"; } } catch { @@ -703,7 +712,7 @@ export class NasFileManager { return Math.round((bytes / 1073741824) * 10) / 10 + " GB"; } - private isSuspiciousMime(mime: string, ext: string): boolean { + private isSuspiciousMime(mime: string): boolean { if (SUSPICIOUS_MIMES.includes(mime)) { return true; } diff --git a/src/services/orders.service.ts b/src/services/orders.service.ts index efc1e4f..7c9ca79 100644 --- a/src/services/orders.service.ts +++ b/src/services/orders.service.ts @@ -97,7 +97,11 @@ export async function listOrders(params: ListOrdersParams) { order_items: { orderBy: { position: "asc" } }, order_sections: { orderBy: { position: "asc" } }, quotations: { select: { quotation_number: true, project_code: true } }, - invoices: { select: { id: true, invoice_number: true }, take: 1 }, + invoices: { + select: { id: true, invoice_number: true }, + take: 1, + orderBy: { id: "desc" }, + }, }, }), prisma.orders.count({ where }), @@ -410,6 +414,16 @@ export async function updateOrder(id: number, body: UpdateOrderData) { status: 400, } as const; } + if ( + body.status !== undefined && + (String(body.status) === "dokoncena" || + String(body.status) === "stornovana") + ) { + return { + error: "Nelze upravit položky při změně stavu na dokončeno/storno", + status: 400, + } as const; + } await prisma.$transaction(async (tx) => { await tx.orders.update({ where: { id }, data }); @@ -504,6 +518,10 @@ export async function deleteOrder(id: number) { await prisma.projects.deleteMany({ where: { order_id: id } }); } + // Explicitly clean up child rows + await prisma.order_items.deleteMany({ where: { order_id: id } }); + await prisma.order_sections.deleteMany({ where: { order_id: id } }); + await prisma.orders.delete({ where: { id } }); const releasedYears = new Set(); diff --git a/src/utils/html-to-pdf.ts b/src/utils/html-to-pdf.ts index 15827c1..9b45b30 100644 --- a/src/utils/html-to-pdf.ts +++ b/src/utils/html-to-pdf.ts @@ -1,31 +1,41 @@ import { Browser } from "puppeteer"; let browser: Browser | null = null; +let launching: Promise | null = null; async function getBrowser(): Promise { if (browser && browser.connected) return browser; - // Try puppeteer (bundles Chromium), fall back to puppeteer-core (system Chromium) - try { - const puppeteer = await import("puppeteer"); - browser = await puppeteer.default.launch({ - headless: true, - args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"], - }); - } catch { - const core = await import("puppeteer-core"); - const executablePath = - process.env.CHROMIUM_PATH || - "/usr/bin/chromium-browser" || - "/usr/bin/chromium"; - browser = await core.default.launch({ - headless: true, - executablePath, - args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"], - }); - } + if (launching) return launching; - return browser; + launching = (async () => { + // Try puppeteer (bundles Chromium), fall back to puppeteer-core (system Chromium) + try { + const puppeteer = await import("puppeteer"); + browser = await puppeteer.default.launch({ + headless: true, + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"], + }); + } catch { + const core = await import("puppeteer-core"); + const executablePath = + process.env.CHROMIUM_PATH || + "/usr/bin/chromium-browser" || + "/usr/bin/chromium"; + browser = await core.default.launch({ + headless: true, + executablePath, + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"], + }); + } + return browser!; + })(); + + try { + return await launching; + } finally { + launching = null; + } } export async function htmlToPdf(html: string): Promise { diff --git a/src/utils/totp.ts b/src/utils/totp.ts index 029f628..d931c13 100644 --- a/src/utils/totp.ts +++ b/src/utils/totp.ts @@ -2,7 +2,10 @@ import * as OTPAuthLib from "otpauth"; import { decrypt } from "./encryption"; export const OTPAuth = { - verify(encryptedSecret: string, code: string): boolean { + verify( + encryptedSecret: string, + code: string, + ): { valid: boolean; counter: number | null } { try { const secret = decrypt(encryptedSecret); const totp = new OTPAuthLib.TOTP({ @@ -12,9 +15,14 @@ export const OTPAuth = { period: 30, }); const delta = totp.validate({ token: code, window: 1 }); - return delta !== null; - } catch { - return false; + if (delta === null) { + return { valid: false, counter: null }; + } + const currentCounter = Math.floor(Date.now() / 1000 / 30); + return { valid: true, counter: currentCounter + delta }; + } catch (err) { + console.error("TOTP verification error:", err); + return { valid: false, counter: null }; } }, };