From 4cabba395dfc551ca63ae0fa55e52eb8e1d9b117 Mon Sep 17 00:00:00 2001 From: BOHA Date: Tue, 12 May 2026 20:26:32 +0200 Subject: [PATCH] v1.6.3: fix project file upload, filter responsible user by permission Co-Authored-By: Claude Opus 4.7 --- package.json | 2 +- src/admin/lib/queries/users.ts | 10 ++-- src/admin/pages/ProjectDetail.tsx | 2 +- src/routes/admin/project-files.ts | 17 +++++-- src/routes/admin/users.ts | 8 +++- src/services/nas-file-manager.ts | 76 ++++++++++++------------------- src/services/users.service.ts | 13 +++++- 7 files changed, 68 insertions(+), 60 deletions(-) diff --git a/package.json b/package.json index 01b60c5..96afe92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app-ts", - "version": "1.6.2", + "version": "1.6.3", "description": "", "main": "dist/server.js", "scripts": { diff --git a/src/admin/lib/queries/users.ts b/src/admin/lib/queries/users.ts index 8a24043..32c1eb9 100644 --- a/src/admin/lib/queries/users.ts +++ b/src/admin/lib/queries/users.ts @@ -1,12 +1,14 @@ import { queryOptions } from "@tanstack/react-query"; import { jsonQuery } from "../apiAdapter"; -export const userListOptions = () => +export const userListOptions = (permission?: string) => queryOptions({ - queryKey: ["users"], + queryKey: ["users", { permission }], queryFn: () => { - // The users endpoint returns { success, data: { items, ... } } or { success, data: [...] } - return jsonQuery>("/api/admin/users"); + const url = permission + ? `/api/admin/users?permission=${encodeURIComponent(permission)}&limit=100` + : "/api/admin/users?limit=100"; + return jsonQuery>(url); }, }); diff --git a/src/admin/pages/ProjectDetail.tsx b/src/admin/pages/ProjectDetail.tsx index f02d4d8..f687113 100644 --- a/src/admin/pages/ProjectDetail.tsx +++ b/src/admin/pages/ProjectDetail.tsx @@ -105,7 +105,7 @@ export default function ProjectDetail() { const isPending = projectQuery.isPending; const notes: Note[] = project?.project_notes || []; - const { data: usersData } = useQuery(userListOptions()); + const { data: usersData } = useQuery(userListOptions("projects.view")); const rawUsers = ((usersData as Record)?.items ?? usersData ?? []) as any[]; diff --git a/src/routes/admin/project-files.ts b/src/routes/admin/project-files.ts index 0b5e510..dd6f0cb 100644 --- a/src/routes/admin/project-files.ts +++ b/src/routes/admin/project-files.ts @@ -156,7 +156,6 @@ export default async function projectFilesRoutes( "/upload", { preHandler: requirePermission("projects.files"), - bodyLimit: config.nas.maxUploadSize, }, async (request, reply) => { const parsedQuery = parseProjectFilesQuery(request.query); @@ -183,13 +182,25 @@ export default async function projectFilesRoutes( const rawFileName = file.filename; const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_"); + const buffer = await file.toBuffer(); const err = await fm.uploadFile( project.project_number, subPath, - file.file, + buffer, 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({ request, diff --git a/src/routes/admin/users.ts b/src/routes/admin/users.ts index 4be1686..2f1de4d 100644 --- a/src/routes/admin/users.ts +++ b/src/routes/admin/users.ts @@ -22,8 +22,12 @@ export default async function usersRoutes( "/", { preHandler: requirePermission("users.view") }, async (request, reply) => { - const params = parsePagination(request.query as Record); - const result = await listUsers(params); + const query = request.query as Record; + const params = parsePagination(query); + const permission = query.permission + ? String(query.permission) + : undefined; + const result = await listUsers({ ...params, permission }); return paginated( reply, diff --git a/src/services/nas-file-manager.ts b/src/services/nas-file-manager.ts index 59ec138..b99bbf0 100644 --- a/src/services/nas-file-manager.ts +++ b/src/services/nas-file-manager.ts @@ -1,6 +1,5 @@ import fs from "fs"; import path from "path"; -import { pipeline } from "stream/promises"; import { config } from "../config/env"; import { localDateStr, localTimeStr } from "../utils/date"; @@ -237,7 +236,7 @@ export class NasFileManager { if (isLink) { try { const target = fs.readlinkSync(fullPath); - item.link_target = target.replace(/\//g, "\\"); + item.link_target = target; } catch { // ignore } @@ -288,14 +287,14 @@ export class NasFileManager { path: subPath, items, breadcrumb, - full_path: realDirPath.replace(/\//g, "\\"), + full_path: realDirPath, }; } public async uploadFile( projectNumber: string, subPath: string, - fileStream: NodeJS.ReadableStream, + fileBuffer: Buffer, fileName: string, ): Promise { const dirPath = this.resolveProjectPath(projectNumber, subPath); @@ -304,14 +303,21 @@ export class NasFileManager { } try { - const stat = await fs.promises.stat(dirPath); - if (!stat.isDirectory()) { - return "Cílová složka neexistuje"; - } + await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o775 }); } catch { 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); let safeName = this.sanitizeFilename(originalName); if (safeName === "") { @@ -323,46 +329,27 @@ export class NasFileManager { return "Tento typ souboru není povolen"; } - const tempPath = path.join( - require("os").tmpdir(), - `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - - try { - await pipeline(fileStream, fs.createWriteStream(tempPath)); - } catch { - await fs.promises.unlink(tempPath).catch(() => {}); - 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ě"; - } + // MIME validation + const typeResult = await FileType.fromBuffer(fileBuffer); + if (typeResult) { + if (this.isSuspiciousMime(typeResult.mime)) { + return "Obsah souboru neodpovídá jeho příponě"; + } + const expectedMime = ext ? MIME_MAP[ext] : null; + if (expectedMime && typeResult.mime !== expectedMime) { + return "Obsah souboru neodpovídá jeho příponě"; } - } catch { - // If file-type fails, continue without MIME check } let destPath = dirPath + "/" + safeName; - // Attempt atomic rename; if destination exists, append counter - let renamed = false; + // If destination exists, append counter let attempts = 0; const maxAttempts = 1000; - do { + while (attempts < maxAttempts) { try { - await fs.promises.rename(tempPath, destPath); - renamed = true; - break; + await fs.promises.writeFile(destPath, fileBuffer, { flag: "wx" }); + return null; } catch (err) { const e = err as NodeJS.ErrnoException; if (e.code === "EEXIST") { @@ -371,17 +358,12 @@ export class NasFileManager { safeName = base + "_" + attempts + (ext ? "." + ext : ""); destPath = dirPath + "/" + safeName; } 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( diff --git a/src/services/users.service.ts b/src/services/users.service.ts index 1bfdc17..a1913b0 100644 --- a/src/services/users.service.ts +++ b/src/services/users.service.ts @@ -33,6 +33,7 @@ export interface ListUsersParams { sort: string; order: "asc" | "desc"; search: string; + permission?: string; } export interface CreateUserData { @@ -56,10 +57,10 @@ export interface UpdateUserData { } 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 where = search + let where: any = search ? { OR: [ { 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([ prisma.users.findMany({ where,