Compare commits

..

7 Commits

Author SHA1 Message Date
BOHA
1db5060c42 v1.6.4: fix RichEditor auto-scroll when opening offers with multiple sections
Replace Quill editor.format() with direct DOM style manipulation to
avoid getSelection(true) calls that steal focus and scroll the page
to the sections area on mount.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 08:07:18 +02:00
BOHA
4cabba395d v1.6.3: fix project file upload, filter responsible user by permission
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 20:26:32 +02:00
BOHA
59b478f262 v1.6.2: fix RichEditor auto-scroll and PDF offers multi-page header
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:23:36 +02:00
BOHA
e4f14a24b7 fix: handle plain month number in attendance route, not just YYYY-MM
AttendanceAdmin sends ?year=YYYY&month=M (separate params), but the
route handler assumed month was always in "YYYY-MM" format. When
query.month was just "4", the split produced NaN for month and 4 for
year, causing the service to skip the month filter entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:50:20 +02:00
BOHA
3bd0d055d9 v1.6.0: fix offer items mobile layout and localStorage draft save/restore
- Fix items table description column width on mobile (was ~82px, now ~260px)
- Activate unused offers-items-table CSS class in OfferDetail form
- Save all form fields to localStorage draft (language, VAT, exchange rate were missing)
- Use DRAFT_KEY constant in loadOfferDraft, add error logging

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 15:23:42 +02:00
BOHA
746d17e182 fix: parse YYYY-MM month filter correctly in attendance history
The frontend sends month as "YYYY-MM" but the route handler was passing
it through Number() which parsed only the year portion, causing the
service to ignore the month filter entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:29:47 +02:00
BOHA
e96e51598a v1.5.8: fix audit log table layout (Skeleton outside tbody)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:08:15 +02:00
13 changed files with 313 additions and 197 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.5.7", "version": "1.6.4",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef, useCallback, useEffect } from "react"; import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
import ReactQuill from "react-quill-new"; import ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css"; import "react-quill-new/dist/quill.snow.css";
@@ -96,11 +96,11 @@ export default function RichEditor({
[onChange], [onChange],
); );
useEffect(() => { useLayoutEffect(() => {
if (!quillRef.current) return; if (!quillRef.current) return;
const editor = quillRef.current.getEditor(); const editor = quillRef.current.getEditor();
editor.format("font", "tahoma"); editor.root.style.fontFamily = "Tahoma, sans-serif";
editor.format("size", "14px"); editor.root.style.fontSize = "14px";
}, []); }, []);
return ( return (

View File

@@ -1,12 +1,14 @@
import { queryOptions } from "@tanstack/react-query"; import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter"; import { jsonQuery } from "../apiAdapter";
export const userListOptions = () => export const userListOptions = (permission?: string) =>
queryOptions({ queryOptions({
queryKey: ["users"], queryKey: ["users", { permission }],
queryFn: () => { queryFn: () => {
// The users endpoint returns { success, data: { items, ... } } or { success, data: [...] } const url = permission
return jsonQuery<Record<string, unknown>>("/api/admin/users"); ? `/api/admin/users?permission=${encodeURIComponent(permission)}&limit=100`
: "/api/admin/users?limit=100";
return jsonQuery<Record<string, unknown>>(url);
}, },
}); });

View File

@@ -491,10 +491,69 @@
display: none; display: none;
} }
@media (max-width: 768px) {
.offers-items-table .admin-table td .admin-form-input {
font-size: 16px;
min-height: 44px;
padding: 9px 10px;
}
/* Give the description column more room by shrinking numeric columns */
.offers-col-qty {
width: 4rem !important;
}
.offers-col-unit {
width: 4rem !important;
}
.offers-col-price {
width: 5.5rem !important;
}
.offers-col-included {
width: 3.5rem !important;
}
.offers-col-total {
width: 5.5rem !important;
}
.offers-col-del {
width: 2.5rem !important;
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.offers-items-table { .offers-items-table {
margin: 0 -1rem; margin: 0 -1rem;
width: calc(100% + 2rem); width: calc(100% + 2rem);
border-radius: 0;
border-left: none;
border-right: none;
}
.offers-items-table .admin-table {
min-width: 700px;
}
.offers-items-table .admin-table td {
padding: 6px;
font-size: 12px;
}
.offers-items-table .admin-table th {
font-size: 10px;
padding: 6px;
}
/* Further reduce numeric columns on very small screens */
.offers-col-qty {
width: 3.5rem !important;
}
.offers-col-unit {
width: 3.5rem !important;
}
.offers-col-price {
width: 5rem !important;
}
.offers-col-total {
width: 5rem !important;
} }
} }

View File

@@ -391,32 +391,25 @@ export default function AuditLog() {
> >
<div className="admin-card-body"> <div className="admin-card-body">
<div className="admin-table-responsive"> <div className="admin-table-responsive">
<table className="admin-table"> <Skeleton
<thead> name="audit-log-rows"
<tr> loading={isPending}
<th>Čas</th> fixture={
<th>Uživatel</th> <table className="admin-table">
<th>Akce</th> <thead>
<th>Typ entity</th> <tr>
<th>Popis</th> <th>Čas</th>
<th>IP</th> <th>Uživatel</th>
</tr> <th>Akce</th>
</thead> <th>Typ entity</th>
<tbody> <th>Popis</th>
<Skeleton <th>IP</th>
name="audit-log-rows" </tr>
loading={isPending} </thead>
fixture={ <tbody>
<div style={{ padding: "1rem" }}> {Array.from({ length: 10 }, (_, i) => (
{Array.from({ length: 10 }, (_, i) => ( <tr key={i}>
<div <td>
key={i}
style={{
display: "flex",
gap: "1rem",
marginBottom: "0.75rem",
}}
>
<div <div
style={{ style={{
width: 110, width: 110,
@@ -425,6 +418,8 @@ export default function AuditLog() {
borderRadius: 4, borderRadius: 4,
}} }}
/> />
</td>
<td>
<div <div
style={{ style={{
width: 80, width: 80,
@@ -433,6 +428,8 @@ export default function AuditLog() {
borderRadius: 4, borderRadius: 4,
}} }}
/> />
</td>
<td>
<div <div
style={{ style={{
width: 70, width: 70,
@@ -441,6 +438,8 @@ export default function AuditLog() {
borderRadius: 10, borderRadius: 10,
}} }}
/> />
</td>
<td>
<div <div
style={{ style={{
width: 80, width: 80,
@@ -449,14 +448,18 @@ export default function AuditLog() {
borderRadius: 4, borderRadius: 4,
}} }}
/> />
</td>
<td>
<div <div
style={{ style={{
flex: 1, width: "100%",
height: 14, height: 14,
background: "var(--bg-tertiary)", background: "var(--bg-tertiary)",
borderRadius: 4, borderRadius: 4,
}} }}
/> />
</td>
<td>
<div <div
style={{ style={{
width: 90, width: 90,
@@ -465,63 +468,75 @@ export default function AuditLog() {
borderRadius: 4, borderRadius: 4,
}} }}
/> />
</div>
))}
</div>
}
>
<>
{logs.length === 0 && (
<tr>
<td colSpan={6}>
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<p>Žádné záznamy k zobrazení</p>
</div>
</td> </td>
</tr> </tr>
)} ))}
{logs.length > 0 && </tbody>
logs.map((log) => ( </table>
<tr key={log.id}> }
<td className="admin-mono"> >
{formatDatetime(log.created_at)} <table className="admin-table">
</td> <thead>
<td className="fw-500">{log.username || "-"}</td> <tr>
<td> <th>Čas</th>
<span <th>Uživatel</th>
className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || "admin-badge-secondary"}`} <th>Akce</th>
<th>Typ entity</th>
<th>Popis</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{logs.length === 0 && (
<tr>
<td colSpan={6}>
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
> >
{ACTION_LABELS[log.action] || log.action} <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
</span> <polyline points="14 2 14 8 20 8" />
</td> <line x1="16" y1="13" x2="8" y2="13" />
<td> <line x1="16" y1="17" x2="8" y2="17" />
{ENTITY_TYPE_LABELS[log.entity_type || ""] || </svg>
log.entity_type || </div>
"-"} <p>Žádné záznamy k zobrazení</p>
</td> </div>
<td>{log.description || "-"}</td> </td>
<td className="admin-mono">{log.user_ip || "-"}</td> </tr>
</tr> )}
))} {logs.length > 0 &&
</> logs.map((log) => (
</Skeleton> <tr key={log.id}>
</tbody> <td className="admin-mono">
</table> {formatDatetime(log.created_at)}
</td>
<td className="fw-500">{log.username || "-"}</td>
<td>
<span
className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || "admin-badge-secondary"}`}
>
{ACTION_LABELS[log.action] || log.action}
</span>
</td>
<td>
{ENTITY_TYPE_LABELS[log.entity_type || ""] ||
log.entity_type ||
"-"}
</td>
<td>{log.description || "-"}</td>
<td className="admin-mono">{log.user_ip || "-"}</td>
</tr>
))}
</tbody>
</table>
</Skeleton>
</div> </div>
<Pagination <Pagination

View File

@@ -274,9 +274,10 @@ function loadOfferDraft(): {
sections?: unknown[]; sections?: unknown[];
} | null { } | null {
try { try {
const raw = localStorage.getItem("boha_offer_draft"); const raw = localStorage.getItem(DRAFT_KEY);
return raw ? JSON.parse(raw) : null; return raw ? JSON.parse(raw) : null;
} catch { } catch (e) {
console.error("Failed to load offer draft:", e);
return null; return null;
} }
} }
@@ -353,17 +354,28 @@ export default function OfferDetail() {
const draft = loadOfferDraft(); const draft = loadOfferDraft();
if (draft?.form) { if (draft?.form) {
return { return {
...emptyForm, quotation_number:
(draft.form.quotation_number as string) || emptyForm.quotation_number,
project_code: project_code:
(draft.form.project_code as string) || emptyForm.project_code, (draft.form.project_code as string) || emptyForm.project_code,
customer_id:
(draft.form.customer_id as number | null) ?? emptyForm.customer_id,
customer_name: customer_name:
(draft.form.customer_name as string) || emptyForm.customer_name, (draft.form.customer_name as string) || emptyForm.customer_name,
created_at: (draft.form.created_at as string) || emptyForm.created_at, created_at: (draft.form.created_at as string) || emptyForm.created_at,
valid_until: valid_until:
(draft.form.valid_until as string) || emptyForm.valid_until, (draft.form.valid_until as string) || emptyForm.valid_until,
currency: (draft.form.currency as string) || emptyForm.currency, currency: (draft.form.currency as string) || emptyForm.currency,
customer_id: language: (draft.form.language as string) || emptyForm.language,
(draft.form.customer_id as number | null) ?? emptyForm.customer_id, vat_rate: (draft.form.vat_rate as number) ?? emptyForm.vat_rate,
apply_vat: (draft.form.apply_vat as boolean) ?? emptyForm.apply_vat,
exchange_rate:
(draft.form.exchange_rate as string) || emptyForm.exchange_rate,
scope_title:
(draft.form.scope_title as string) || emptyForm.scope_title,
scope_description:
(draft.form.scope_description as string) ||
emptyForm.scope_description,
}; };
} }
return emptyForm; return emptyForm;
@@ -590,22 +602,27 @@ export default function OfferDetail() {
useEffect(() => { useEffect(() => {
if (isEdit) return; if (isEdit) return;
try { try {
const data = JSON.parse(debouncedDraft);
const draft = { const draft = {
form: { form: {
project_code: form.project_code, project_code: data.form.project_code ?? "",
customer_id: form.customer_id, customer_id: data.form.customer_id ?? null,
customer_name: form.customer_name, customer_name: data.form.customer_name ?? "",
created_at: form.created_at, created_at: data.form.created_at ?? "",
valid_until: form.valid_until, valid_until: data.form.valid_until ?? "",
currency: form.currency, currency: data.form.currency ?? "CZK",
language: data.form.language ?? "EN",
vat_rate: data.form.vat_rate ?? 21,
apply_vat: data.form.apply_vat ?? false,
exchange_rate: data.form.exchange_rate ?? "",
}, },
items, items: data.items,
sections, sections: data.sections,
savedAt: new Date().toISOString(), savedAt: new Date().toISOString(),
}; };
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
} catch { } catch (e) {
/* localStorage full or unavailable */ console.error("Failed to save offer draft:", e);
} }
}, [debouncedDraft]); // eslint-disable-line react-hooks/exhaustive-deps }, [debouncedDraft]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -697,8 +714,8 @@ export default function OfferDetail() {
if (!isEdit) { if (!isEdit) {
try { try {
localStorage.removeItem(DRAFT_KEY); localStorage.removeItem(DRAFT_KEY);
} catch { } catch (e) {
/* ignore */ console.error("Failed to remove offer draft:", e);
} }
} }
if (!isEdit && result.data?.id) { if (!isEdit && result.data?.id) {
@@ -1283,7 +1300,7 @@ export default function OfferDetail() {
</p> </p>
)} )}
<div className="admin-table-responsive"> <div className="offers-items-table">
<DndContext <DndContext
sensors={dndSensors} sensors={dndSensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
@@ -1316,18 +1333,42 @@ export default function OfferDetail() {
<th style={{ width: "2.5rem", textAlign: "center" }}> <th style={{ width: "2.5rem", textAlign: "center" }}>
# #
</th> </th>
<th>Popis</th> <th className="offers-col-desc">Popis</th>
<th style={{ width: "5rem" }}>Množství</th> <th
<th style={{ width: "5rem" }}>Jednotka</th> className="offers-col-qty"
<th style={{ width: "7rem" }}>Cena/ks</th> style={{ width: "5rem" }}
<th style={{ width: "4rem", textAlign: "center" }}> >
Množství
</th>
<th
className="offers-col-unit"
style={{ width: "5rem" }}
>
Jednotka
</th>
<th
className="offers-col-price"
style={{ width: "7rem" }}
>
Cena/ks
</th>
<th
className="offers-col-included"
style={{ width: "4rem", textAlign: "center" }}
>
V ceně V ceně
</th> </th>
<th style={{ width: "7rem", textAlign: "right" }}> <th
className="offers-col-total"
style={{ width: "7rem", textAlign: "right" }}
>
Celkem Celkem
</th> </th>
{!isInvalidated && !isLockedByOther && ( {!isInvalidated && !isLockedByOther && (
<th style={{ width: "3rem" }} /> <th
className="offers-col-del"
style={{ width: "3rem" }}
/>
)} )}
</tr> </tr>
</thead> </thead>

View File

@@ -105,7 +105,7 @@ export default function ProjectDetail() {
const isPending = projectQuery.isPending; const isPending = projectQuery.isPending;
const notes: Note[] = project?.project_notes || []; const notes: Note[] = project?.project_notes || [];
const { data: usersData } = useQuery(userListOptions()); const { data: usersData } = useQuery(userListOptions("projects.view"));
const rawUsers = ((usersData as Record<string, unknown>)?.items ?? const rawUsers = ((usersData as Record<string, unknown>)?.items ??
usersData ?? usersData ??
[]) as any[]; []) as any[];

View File

@@ -220,8 +220,20 @@ export default async function attendanceRoutes(
userId, userId,
isAdmin, isAdmin,
authUserId: authData.userId, authUserId: authData.userId,
month: query.month ? Number(query.month) : undefined, month: query.month
year: query.year ? Number(query.year) : undefined, ? String(query.month).includes("-")
? Number(String(query.month).split("-")[1])
: Number(query.month)
: undefined,
year: query.month
? String(query.month).includes("-")
? Number(String(query.month).split("-")[0])
: query.year
? Number(query.year)
: undefined
: query.year
? Number(query.year)
: undefined,
}); });
return reply.send({ return reply.send({

View File

@@ -632,14 +632,6 @@ ${indentCSS}
border: none; border: none;
vertical-align: top; vertical-align: top;
} }
.logo-header {
text-align: right;
padding-bottom: 4mm;
}
.first-content {
margin-top: -26mm;
}
/* ---- Page break helpers ---- */ /* ---- Page break helpers ---- */
table.page-layout thead { display: table-header-group; } table.page-layout thead { display: table-header-group; }
table.items tbody tr { break-inside: avoid; } table.items tbody tr { break-inside: avoid; }
@@ -696,30 +688,16 @@ ${indentCSS}
display: block; display: block;
width: 100%; width: 100%;
} }
.first-content {
margin-top: 0 !important;
}
.logo-header {
text-align: right;
padding-bottom: 0;
margin-bottom: -18mm;
}
} }
</style> </style>
</head> </head>
<body> <body>
<!-- ============ QUOTATION (logo repeats via thead, full header only on first page) ============ --> <!-- ============ QUOTATION (full header in thead repeats on every page) ============ -->
<div class="quotation-page"> <div class="quotation-page">
<table class="page-layout"> <table class="page-layout">
<thead> <thead>
<tr><td> <tr><td>
<div class="logo-header">${logoImg}</div>
</td></tr>
</thead>
<tbody>
<tr><td>
<div class="first-content">
<div class="page-header"> <div class="page-header">
<div class="left"> <div class="left">
<div class="page-title">${escapeHtml(t("title"))}</div> <div class="page-title">${escapeHtml(t("title"))}</div>
@@ -727,9 +705,13 @@ ${indentCSS}
${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ""} ${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ""}
<div class="valid-until">${escapeHtml(t("valid_until"))}: ${escapeHtml(formatDate(quotation.valid_until))}</div> <div class="valid-until">${escapeHtml(t("valid_until"))}: ${escapeHtml(formatDate(quotation.valid_until))}</div>
</div> </div>
${logoImg ? `<div class="right">${logoImg}</div>` : ""}
</div> </div>
<hr class="separator" /> <hr class="separator" />
</td></tr>
</thead>
<tbody>
<tr><td>
<div class="addresses"> <div class="addresses">
<div class="address-block left"> <div class="address-block left">
<div class="address-label">${escapeHtml(t("customer"))}</div> <div class="address-label">${escapeHtml(t("customer"))}</div>
@@ -763,7 +745,6 @@ ${indentCSS}
${totalsHtml} ${totalsHtml}
</div> </div>
</div> </div>
</div>
</td></tr> </td></tr>
</tbody> </tbody>
</table> </table>

View File

@@ -156,7 +156,6 @@ export default async function projectFilesRoutes(
"/upload", "/upload",
{ {
preHandler: requirePermission("projects.files"), preHandler: requirePermission("projects.files"),
bodyLimit: config.nas.maxUploadSize,
}, },
async (request, reply) => { async (request, reply) => {
const parsedQuery = parseProjectFilesQuery(request.query); const parsedQuery = parseProjectFilesQuery(request.query);
@@ -183,13 +182,25 @@ export default async function projectFilesRoutes(
const rawFileName = file.filename; const rawFileName = file.filename;
const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_"); const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_");
const buffer = await file.toBuffer();
const err = await fm.uploadFile( const err = await fm.uploadFile(
project.project_number, project.project_number,
subPath, subPath,
file.file, buffer,
fileName, fileName,
); );
if (err !== null) return error(reply, err); if (err !== null) {
request.log.error(
{
err,
project_number: project.project_number,
subPath,
fileName,
},
"uploadFile failed",
);
return error(reply, err);
}
await logAudit({ await logAudit({
request, request,

View File

@@ -22,8 +22,12 @@ export default async function usersRoutes(
"/", "/",
{ preHandler: requirePermission("users.view") }, { preHandler: requirePermission("users.view") },
async (request, reply) => { async (request, reply) => {
const params = parsePagination(request.query as Record<string, unknown>); const query = request.query as Record<string, unknown>;
const result = await listUsers(params); const params = parsePagination(query);
const permission = query.permission
? String(query.permission)
: undefined;
const result = await listUsers({ ...params, permission });
return paginated( return paginated(
reply, reply,

View File

@@ -1,6 +1,5 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { pipeline } from "stream/promises";
import { config } from "../config/env"; import { config } from "../config/env";
import { localDateStr, localTimeStr } from "../utils/date"; import { localDateStr, localTimeStr } from "../utils/date";
@@ -237,7 +236,7 @@ export class NasFileManager {
if (isLink) { if (isLink) {
try { try {
const target = fs.readlinkSync(fullPath); const target = fs.readlinkSync(fullPath);
item.link_target = target.replace(/\//g, "\\"); item.link_target = target;
} catch { } catch {
// ignore // ignore
} }
@@ -288,14 +287,14 @@ export class NasFileManager {
path: subPath, path: subPath,
items, items,
breadcrumb, breadcrumb,
full_path: realDirPath.replace(/\//g, "\\"), full_path: realDirPath,
}; };
} }
public async uploadFile( public async uploadFile(
projectNumber: string, projectNumber: string,
subPath: string, subPath: string,
fileStream: NodeJS.ReadableStream, fileBuffer: Buffer,
fileName: string, fileName: string,
): Promise<string | null> { ): Promise<string | null> {
const dirPath = this.resolveProjectPath(projectNumber, subPath); const dirPath = this.resolveProjectPath(projectNumber, subPath);
@@ -304,14 +303,21 @@ export class NasFileManager {
} }
try { try {
const stat = await fs.promises.stat(dirPath); await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o775 });
if (!stat.isDirectory()) {
return "Cílová složka neexistuje";
}
} catch { } catch {
return "Cílová složka neexistuje"; return "Cílová složka neexistuje";
} }
let stat: fs.Stats;
try {
stat = await fs.promises.stat(dirPath);
} catch {
return "Cílová složka neexistuje";
}
if (!stat.isDirectory()) {
return "Cílová složka neexistuje";
}
const originalName = path.basename(fileName); const originalName = path.basename(fileName);
let safeName = this.sanitizeFilename(originalName); let safeName = this.sanitizeFilename(originalName);
if (safeName === "") { if (safeName === "") {
@@ -323,46 +329,27 @@ export class NasFileManager {
return "Tento typ souboru není povolen"; return "Tento typ souboru není povolen";
} }
const tempPath = path.join( // MIME validation
require("os").tmpdir(), const typeResult = await FileType.fromBuffer(fileBuffer);
`upload-${Date.now()}-${Math.random().toString(36).slice(2)}`, if (typeResult) {
); if (this.isSuspiciousMime(typeResult.mime)) {
return "Obsah souboru neodpovídá jeho příponě";
try { }
await pipeline(fileStream, fs.createWriteStream(tempPath)); const expectedMime = ext ? MIME_MAP[ext] : null;
} catch { if (expectedMime && typeResult.mime !== expectedMime) {
await fs.promises.unlink(tempPath).catch(() => {}); return "Obsah souboru neodpovídá jeho příponě";
return "Nepodařilo se uložit soubor";
}
try {
const typeResult = await FileType.fromFile(tempPath);
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
} }
let destPath = dirPath + "/" + safeName; let destPath = dirPath + "/" + safeName;
// Attempt atomic rename; if destination exists, append counter // If destination exists, append counter
let renamed = false;
let attempts = 0; let attempts = 0;
const maxAttempts = 1000; const maxAttempts = 1000;
do { while (attempts < maxAttempts) {
try { try {
await fs.promises.rename(tempPath, destPath); await fs.promises.writeFile(destPath, fileBuffer, { flag: "wx" });
renamed = true; return null;
break;
} catch (err) { } catch (err) {
const e = err as NodeJS.ErrnoException; const e = err as NodeJS.ErrnoException;
if (e.code === "EEXIST") { if (e.code === "EEXIST") {
@@ -371,17 +358,12 @@ export class NasFileManager {
safeName = base + "_" + attempts + (ext ? "." + ext : ""); safeName = base + "_" + attempts + (ext ? "." + ext : "");
destPath = dirPath + "/" + safeName; destPath = dirPath + "/" + safeName;
} else { } else {
break; return `Nepodařilo se uložit soubor (${e?.message || String(e)})`;
} }
} }
} while (!renamed && attempts < maxAttempts);
if (!renamed) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Nepodařilo se uložit soubor";
} }
return null; return "Nepodařilo se uložit soubor";
} }
public downloadFile( public downloadFile(

View File

@@ -33,6 +33,7 @@ export interface ListUsersParams {
sort: string; sort: string;
order: "asc" | "desc"; order: "asc" | "desc";
search: string; search: string;
permission?: string;
} }
export interface CreateUserData { export interface CreateUserData {
@@ -56,10 +57,10 @@ export interface UpdateUserData {
} }
export async function listUsers(params: ListUsersParams) { export async function listUsers(params: ListUsersParams) {
const { page, limit, skip, sort, order, search } = params; const { page, limit, skip, sort, order, search, permission } = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
const where = search let where: any = search
? { ? {
OR: [ OR: [
{ username: { contains: search } }, { username: { contains: search } },
@@ -70,6 +71,14 @@ export async function listUsers(params: ListUsersParams) {
} }
: {}; : {};
if (permission) {
where.roles = {
role_permissions: {
some: { permissions: { name: permission } },
},
};
}
const [users, total] = await Promise.all([ const [users, total] = await Promise.all([
prisma.users.findMany({ prisma.users.findMany({
where, where,