fix: security, validation, and data integrity fixes across 53 files

- Auth: HS256 algorithm restriction on JWT verify, timing-safe bcrypt
  for inactive/locked users, locked_until check in loadAuthData, TOTP
  fixes (async bcrypt, BigInt conversion, future-code counter fix)
- Validation: Zod enums for leave_type/status, numeric transforms on
  foreign keys, VAT 0% coercion fix (Number(v)||21 → v!=null checks)
- Permissions: requirePermission on attendance PUT, attendance_users
  and project_logs access checks, trips users filtered by trips.record
- Prisma queries: fixed roles.is:{OR} pattern (doesn't work on to-one
  relations), attendance_users now filters by attendance.record only
- Transactions: wrapped deleteOrder, createOrder, updateUser, deleteUser,
  duplicateOffer, bulkCreateAttendance, createLeave, scope-templates,
  leave-requests, company-settings, profile updates
- Frontend: mountedRef reset in useListData, blob URL cleanup on unmount,
  null checks on date fields, AdminDatePicker min/max for HH:mm
- Security headers: COOP, CORP, CSP frame-ancestors/form-action/base-uri
- Other: exchange-rate cache TTL, invoice-alert midnight comparison fix,
  numbering.service releaseSequence no-op, nas-offers filename sanitize,
  Content-Disposition header injection fix, mojibake Czech strings

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-28 08:40:38 +02:00
parent 7f07032bf2
commit d7c7fbad88
52 changed files with 927 additions and 573 deletions

View File

@@ -13,6 +13,7 @@ import {
} from "../../schemas/received-invoices.schema";
import { nasFinancialsManager } from "../../services/nas-financials-manager";
import { toCzk } from "../../services/exchange-rates";
import path from "path";
const VALID_STATUSES = ["unpaid", "paid"] as const;
@@ -126,9 +127,17 @@ export default async function receivedInvoicesRoutes(
return Math.round(total * 100) / 100;
};
// Also get all-time unpaid
// Also get all-time unpaid — use DB-level aggregation for count/sums
const stats = await prisma.received_invoices.aggregate({
where: { status: { not: "paid" }, is_deleted: false },
_sum: { amount: true, amount_czk: true },
_count: true,
});
// We still need per-currency breakdown for unpaid, so fetch only those
const allUnpaid = await prisma.received_invoices.findMany({
where: { status: { not: "paid" } },
where: { status: { not: "paid" }, is_deleted: false },
select: { amount: true, currency: true },
});
return success(reply, {
@@ -137,8 +146,10 @@ export default async function receivedInvoicesRoutes(
vat_month: aggregateByCurrency(monthInvoices, "vat_amount"),
vat_month_czk: await sumCzk(monthInvoices, "vat_amount"),
unpaid: aggregateByCurrency(allUnpaid, "amount"),
unpaid_czk: await sumCzk(allUnpaid, "amount"),
unpaid_count: allUnpaid.length,
unpaid_czk: stats._sum.amount_czk
? Math.round(Number(stats._sum.amount_czk) * 100) / 100
: await sumCzk(allUnpaid, "amount"),
unpaid_count: stats._count,
month_count: monthInvoices.length,
});
},
@@ -188,12 +199,10 @@ export default async function receivedInvoicesRoutes(
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
const mime = invoice.file_mime || "application/pdf";
const safeFileName = invoice.file_name.replace(/[\r\n"]/g, "");
return reply
.type(mime)
.header(
"Content-Disposition",
`inline; filename="${invoice.file_name}"`,
)
.header("Content-Disposition", `inline; filename="${safeFileName}"`)
.send(nasFile.data);
},
);
@@ -315,7 +324,9 @@ export default async function receivedInvoicesRoutes(
status: "unpaid",
notes: meta.notes ? String(meta.notes) : null,
uploaded_by: request.authData?.userId,
file_name: file.name,
file_name: nasResult.filePath
? path.basename(nasResult.filePath)
: file.name,
file_mime: file.mime,
file_size: file.size,
},
@@ -364,7 +375,7 @@ export default async function receivedInvoicesRoutes(
vat_rate: vatRate,
vat_amount:
vatRate > 0
? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100
? Math.round(((amount * vatRate) / 100) * 100) / 100
: 0,
issue_date: body.issue_date
? new Date(String(body.issue_date))
@@ -544,6 +555,9 @@ export default async function receivedInvoicesRoutes(
});
if (!existing) return error(reply, "Přijatá faktura nenalezena", 404);
// Delete DB record first, then NAS file — avoids orphaned file if DB delete fails
await prisma.received_invoices.delete({ where: { id } });
if (existing.file_name) {
const relPath = nasFinancialsManager.buildReceivedPath(
existing.file_name,
@@ -552,8 +566,6 @@ export default async function receivedInvoicesRoutes(
);
nasFinancialsManager.deleteReceivedInvoice(relPath);
}
await prisma.received_invoices.delete({ where: { id } });
await logAudit({
request,
authData: request.authData,