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"; import { logAudit } from "../../services/audit"; import { success, error } from "../../utils/response"; import { parseBody } from "../../schemas/common"; import { NasFileManager } from "../../services/nas-file-manager"; // Body for POST / (create folder). path defaults to root; the name length cap // counts code-points (not UTF-16 units) to match the previous handler check. // Path-traversal safety is enforced downstream by resolveProjectPath. const CreateFolderSchema = z.object({ folder_name: z .string() .trim() .min(1, "Název složky je povinný") .refine((s) => [...s].length <= 100, { message: "Název složky je příliš dlouhý (max 100 znaků)", }), path: z.string().optional().default(""), }); // Body for PUT / (move/rename). Both endpoints are validated by // resolveProjectPath downstream for traversal safety. const MoveItemSchema = z.object({ from_path: z.string().min(1, "Zdrojová cesta je povinná"), to_path: z.string().min(1, "Cílová cesta je povinná"), }); 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 { await fastify.register(multipart, { limits: { fileSize: config.nas.maxUploadSize }, }); const fm = new NasFileManager(); async function getProjectForFiles(projectId: number) { if (!projectId || isNaN(projectId) || projectId <= 0) return null; return prisma.projects.findUnique({ where: { id: projectId }, select: { id: true, project_number: true, name: true }, }); } // GET / — list files or download fastify.get( "/", { preHandler: requirePermission("projects.view") }, async (request, reply) => { 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 nenalezen", 404); if (!fm.isConfigured()) { return error(reply, "Souborový systém není nakonfigurován", 500); } 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"); const result = fm.downloadFile(project.project_number, subPath); if (!result) return error(reply, "Soubor nenalezen", 404); const stream = fs.createReadStream(result.filePath); const encodedName = encodeURIComponent(result.fileName); return reply .header( "Content-Disposition", `attachment; filename*=UTF-8''${encodedName}`, ) .header("Content-Type", result.mime) .header("X-Content-Type-Options", "nosniff") .send(stream); } if (!project.project_number) return error(reply, "Projekt nemá číslo projektu"); const result = fm.listFiles(project.project_number, subPath); if (result === null) { return error(reply, "Složka nenalezena", 404); } return success(reply, { ...result, project_number: project.project_number, folder_exists: true, }); }, ); // POST / — create folder (JSON body) fastify.post( "/", { preHandler: requirePermission("projects.files") }, async (request, reply) => { 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 nenalezen", 404); if (!project.project_number) return error(reply, "Projekt nemá číslo projektu"); if (!fm.isConfigured()) { return error(reply, "Souborový systém není nakonfigurován", 500); } const parsedBody = parseBody(CreateFolderSchema, request.body); if ("error" in parsedBody) return error(reply, parsedBody.error, 400); const folderName = parsedBody.data.folder_name; const path = parsedBody.data.path; // Auto-create project folder if it doesn't exist if (!fm.projectFolderExists(project.project_number)) { fm.createProjectFolder(project.project_number, project.name || ""); } const err = await fm.createFolder( project.project_number, path, folderName, ); if (err !== null) return error(reply, err); await logAudit({ request, authData: request.authData, action: "create", entityType: "project_file", entityId: project.id, description: `Vytvořena složka '${folderName}' v projektu '${project.project_number}'`, newValues: { folder: folderName, path }, }); return success(reply, null, 200, "Složka byla vytvořena"); }, ); // POST /upload — upload file (multipart) fastify.post( "/upload", { preHandler: requirePermission("projects.files"), }, async (request, reply) => { 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 nenalezen", 404); if (!project.project_number) return error(reply, "Projekt nemá číslo projektu"); if (!fm.isConfigured()) { return error(reply, "Souborový systém není nakonfigurován", 500); } // Auto-create project folder if it doesn't exist if (!fm.projectFolderExists(project.project_number)) { fm.createProjectFolder(project.project_number, project.name || ""); } const file = await request.file(); if (!file) return error(reply, "Nebyl nahrán žádný soubor"); const subPath = parsedQuery.data.path || ""; const rawFileName = file.filename; const fileName = rawFileName.replace(/[/\\:*?"<>|]/g, "_"); const buffer = await file.toBuffer(); const err = await fm.uploadFile( project.project_number, subPath, buffer, fileName, ); if (err !== null) { request.log.error( { err, project_number: project.project_number, subPath, fileName, }, "uploadFile failed", ); return error(reply, err); } await logAudit({ request, authData: request.authData, action: "create", entityType: "project_file", entityId: project.id, description: `Nahrán soubor do projektu '${project.project_number}'`, newValues: { file: fileName, path: subPath }, }); return success(reply, null, 200, "Soubor byl nahrán"); }, ); // PUT / — move/rename fastify.put( "/", { preHandler: requirePermission("projects.files") }, async (request, reply) => { 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 nenalezen", 404); if (!project.project_number) return error(reply, "Projekt nemá číslo projektu"); if (!fm.isConfigured()) { return error(reply, "Souborový systém není nakonfigurován", 500); } const parsedBody = parseBody(MoveItemSchema, request.body); if ("error" in parsedBody) return error(reply, parsedBody.error, 400); const fromPath = parsedBody.data.from_path; const toPath = parsedBody.data.to_path; const err = await fm.moveItem(project.project_number, fromPath, toPath); if (err !== null) return error(reply, err); await logAudit({ request, authData: request.authData, action: "update", entityType: "project_file", entityId: project.id, description: `Přesun/přejmenování v projektu '${project.project_number}'`, oldValues: { path: fromPath }, newValues: { path: toPath }, }); return success(reply, null, 200, "Soubor byl přesunut"); }, ); // DELETE / — delete file/folder fastify.delete( "/", { preHandler: requirePermission("projects.files") }, async (request, reply) => { 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 nenalezen", 404); if (!project.project_number) return error(reply, "Projekt nemá číslo projektu"); if (!fm.isConfigured()) { return error(reply, "Souborový systém není nakonfigurován", 500); } const filePath = parsedQuery.data.path || ""; if (!filePath) return error(reply, "Cesta k souboru je povinná"); const err = await fm.deleteItem(project.project_number, filePath); if (err !== null) return error(reply, err); await logAudit({ request, authData: request.authData, action: "delete", entityType: "project_file", entityId: project.id, description: `Smazán soubor/složka v projektu '${project.project_number}'`, oldValues: { path: filePath }, }); return success(reply, null, 200, "Soubor byl smazán"); }, ); }