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 { NasFileManager } from "../../services/nas-file-manager"; 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 nebyl nalezen", 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 nebyl nalezen", 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 nebyla nalezena", 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 nebyl nalezen", 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 body = request.body as Record; const folderName = String(body.folder_name || "").trim(); const path = String(body.path || ""); if (!folderName) return error(reply, "Název složky je povinný"); if ([...folderName].length > 100) return error(reply, "Název složky je příliš dlouhý (max 100 znaků)"); // 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"), bodyLimit: config.nas.maxUploadSize, }, 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 nebyl nalezen", 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 err = await fm.uploadFile( project.project_number, subPath, file.file, fileName, ); if (err !== null) 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 nebyl nalezen", 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 body = request.body as Record; const fromPath = String(body.from_path || ""); const toPath = String(body.to_path || ""); if (!fromPath || !toPath) return error(reply, "Zdrojová i cílová cesta jsou povinné"); 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 nebyl nalezen", 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"); }, ); }