import fs from "fs"; import path from "path"; import { pipeline } from "stream/promises"; import { config } from "../config/env"; import { localDateStr, localTimeStr } from "../utils/date"; const FileType = require("file-type") as typeof import("file-type"); const BLOCKED_EXTENSIONS = new Set([ "exe", "bat", "sh", "php", "htaccess", "env", "cmd", "com", "msi", "ps1", "vbs", "vbe", "js", "ws", "wsf", "scr", "pif", "jar", "reg", ]); const SUSPICIOUS_MIMES = [ "application/x-executable", "application/x-msdos-program", "application/x-dosexec", "application/x-msdownload", ]; const MIME_MAP: Record = { pdf: "application/pdf", doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", zip: "application/zip", rar: "application/x-rar-compressed", "7z": "application/x-7z-compressed", tar: "application/x-tar", gz: "application/gzip", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", bmp: "image/bmp", svg: "image/svg+xml", webp: "image/webp", ico: "image/x-icon", tif: "image/tiff", tiff: "image/tiff", mp3: "audio/mpeg", wav: "audio/wav", mp4: "video/mp4", avi: "video/x-msvideo", mkv: "video/x-matroska", mov: "video/quicktime", txt: "text/plain", csv: "text/csv", html: "text/html", htm: "text/html", xml: "application/xml", json: "application/json", dwg: "application/acad", dxf: "application/dxf", step: "application/step", stp: "application/step", iges: "application/iges", igs: "application/iges", }; interface FileItem { name: string; type: "file" | "folder"; modified: string; is_symlink: boolean; link_target?: string; size?: number; size_formatted?: string; extension?: string; item_count?: number; } interface ListFilesResult { path: string; items: FileItem[]; breadcrumb: string[]; full_path: string; } interface DownloadResult { filePath: string; fileName: string; mime: string; } export class NasFileManager { private readonly basePath: string; constructor() { this.basePath = path.resolve(config.nas.path).replace(/\\/g, "/"); } public isConfigured(): boolean { if (!this.basePath) return false; try { return ( fs.existsSync(this.basePath) && fs.statSync(this.basePath).isDirectory() ); } catch { return false; } } public createProjectFolder( projectNumber: string, projectName: string, ): boolean { if (!this.isConfigured()) return false; const folderName = this.buildFolderName(projectNumber, projectName); const fullPath = this.basePath + "/" + folderName; if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) { return true; } try { fs.mkdirSync(fullPath, { recursive: true, mode: 0o775 }); return true; } catch { return false; } } public async deleteProjectFolder(projectNumber: string): Promise { if (!this.isConfigured()) return false; const folderPath = this.findProjectFolder(projectNumber); if (folderPath === null) return true; try { await fs.promises.rm(folderPath, { recursive: true, force: true }); return true; } catch { return false; } } public projectFolderExists(projectNumber: string): boolean { return this.findProjectFolder(projectNumber) !== null; } public renameProjectFolder(projectNumber: string, newName: string): boolean { if (!this.isConfigured()) return false; const currentPath = this.findProjectFolder(projectNumber); if (currentPath === null) return false; const newFolderName = this.buildFolderName(projectNumber, newName); const newPath = this.basePath + "/" + newFolderName; if (currentPath === newPath) return true; try { fs.renameSync(currentPath, newPath); return true; } catch { return false; } } public listFiles( projectNumber: string, subPath: string = "", ): ListFilesResult | null { const dirPath = this.resolveProjectPath(projectNumber, subPath); if (dirPath === null) return null; try { const stat = fs.lstatSync(dirPath); if (!stat.isDirectory()) return null; } catch { return null; } let entries: string[]; try { entries = fs.readdirSync(dirPath); } catch { return null; } const items: FileItem[] = []; for (const entry of entries) { const fullPath = dirPath + "/" + entry; let lstat: fs.Stats; try { lstat = fs.lstatSync(fullPath); } catch { continue; } const isLink = lstat.isSymbolicLink(); // For symlinks, we need to check if target is dir let isDir: boolean; if (isLink) { try { isDir = fs.statSync(fullPath).isDirectory(); } catch { isDir = false; } } else { isDir = lstat.isDirectory(); } const modified = lstat.mtime; const modifiedStr = `${localDateStr(modified)} ${localTimeStr(modified)}`; const item: FileItem = { name: entry, type: isDir ? "folder" : "file", modified: modifiedStr, is_symlink: isLink, }; if (isLink) { try { const target = fs.readlinkSync(fullPath); item.link_target = target.replace(/\//g, "\\"); } catch { // ignore } } if (isDir) { item.item_count = this.countItems(fullPath); } else { const size = lstat.size; item.size = size; item.size_formatted = this.formatFileSize(size); item.extension = path.extname(entry).slice(1).toLowerCase(); } items.push(item); } // Sort: folders first, then files, both alphabetically (natural sort) items.sort((a, b) => { if (a.type !== b.type) { return a.type === "folder" ? -1 : 1; } return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base", }); }); const breadcrumb: string[] = [""]; if (subPath !== "") { const parts = subPath .replace(/\\/g, "/") .replace(/^\/+|\/+$/g, "") .split("/"); for (const part of parts) { breadcrumb.push(part); } } let realDirPath: string; try { realDirPath = fs.realpathSync(dirPath); } catch { realDirPath = dirPath; } return { path: subPath, items, breadcrumb, full_path: realDirPath.replace(/\//g, "\\"), }; } public async uploadFile( projectNumber: string, subPath: string, fileStream: NodeJS.ReadableStream, fileName: string, ): Promise { const dirPath = this.resolveProjectPath(projectNumber, subPath); if (dirPath === null) { return "Cílová složka neexistuje"; } try { const stat = await fs.promises.stat(dirPath); if (!stat.isDirectory()) { return "Cílová složka neexistuje"; } } catch { return "Cílová složka neexistuje"; } const originalName = path.basename(fileName); let safeName = this.sanitizeFilename(originalName); if (safeName === "") { return "Neplatný název souboru"; } const ext = path.extname(safeName).slice(1).toLowerCase(); if (BLOCKED_EXTENSIONS.has(ext)) { 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ě"; } } } catch { // If file-type fails, continue without MIME check } let destPath = dirPath + "/" + safeName; // Attempt atomic rename; if destination exists, append counter let renamed = false; let attempts = 0; const maxAttempts = 1000; do { try { await fs.promises.rename(tempPath, destPath); renamed = true; break; } catch (err) { const e = err as NodeJS.ErrnoException; if (e.code === "EEXIST") { const base = path.basename(safeName, ext ? "." + ext : ""); attempts++; safeName = base + "_" + attempts + (ext ? "." + ext : ""); destPath = dirPath + "/" + safeName; } else { break; } } } while (!renamed && attempts < maxAttempts); if (!renamed) { await fs.promises.unlink(tempPath).catch(() => {}); return "Nepodařilo se uložit soubor"; } return null; } public downloadFile( projectNumber: string, filePath: string, ): DownloadResult | null { const fullPath = this.resolveProjectPath(projectNumber, filePath); if (fullPath === null) return null; try { const stat = fs.lstatSync(fullPath); if (!stat.isFile()) return null; } catch { return null; } const fileName = path.basename(fullPath); const ext = path.extname(fileName).slice(1).toLowerCase(); const mime = MIME_MAP[ext] || "application/octet-stream"; return { filePath: fullPath, fileName, mime, }; } public async deleteItem( projectNumber: string, filePath: string, ): Promise { if ( filePath === "" || filePath === "/" || filePath === "." || filePath === "./" ) { return "Nelze smazat kořenovou složku projektu"; } const fullPath = this.resolveProjectPath(projectNumber, filePath); if (fullPath === null) { return "Neplatná cesta"; } let isDir: boolean; try { const stat = await fs.promises.lstat(fullPath); isDir = stat.isDirectory(); } catch { return "Soubor nebo složka neexistuje"; } try { if (isDir) { await fs.promises.rm(fullPath, { recursive: true, force: true }); } else { await fs.promises.unlink(fullPath); } } catch { return isDir ? "Nepodařilo se smazat složku" : "Nepodařilo se smazat soubor"; } return null; } public async moveItem( projectNumber: string, fromPath: string, toPath: string, ): Promise { if ( fromPath === "" || fromPath === "/" || fromPath === "." || fromPath === "./" ) { return "Nelze přesunout kořenovou složku"; } const fullFrom = this.resolveProjectPath(projectNumber, fromPath); const fullTo = this.resolveProjectPath(projectNumber, toPath); if (fullFrom === null || fullTo === null) { return "Neplatná cesta"; } try { await fs.promises.stat(fullFrom); } catch { return "Zdrojový soubor neexistuje"; } // Case-insensitive FS (Windows) — allow case-only rename const sameFile = fullFrom.replace(/\\/g, "/").toLowerCase() === fullTo.replace(/\\/g, "/").toLowerCase(); if (!sameFile) { try { await fs.promises.stat(fullTo); return "Cílový soubor již existuje"; } catch { // target does not exist, continue } } const targetName = path.basename(toPath); if (this.sanitizeFilename(targetName) !== targetName) { return "Neplatný cílový název"; } try { await fs.promises.rename(fullFrom, fullTo); } catch (err: unknown) { if ( err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "EXDEV" ) { return "Přesun mezi různými disky není podporován"; } return "Nepodařilo se přesunout soubor"; } return null; } public async createFolder( projectNumber: string, subPath: string, folderName: string, ): Promise { const dirPath = this.resolveProjectPath(projectNumber, subPath); if (dirPath === null) { return "Nadřazená složka neexistuje"; } try { const stat = await fs.promises.lstat(dirPath); if (stat.isSymbolicLink() || !stat.isDirectory()) { return "Nadřazená složka neexistuje"; } } catch { return "Nadřazená složka neexistuje"; } const safeName = this.sanitizeFilename(folderName); if (safeName === "") { return "Neplatný název složky"; } const newPath = dirPath + "/" + safeName; try { await fs.promises.stat(newPath); return "Složka s tímto názvem již existuje"; } catch { // does not exist, continue } try { await fs.promises.mkdir(newPath, { mode: 0o775 }); } catch { return "Nepodařilo se vytvořit složku"; } return null; } public sanitizeFilename(name: string): string { let safe = path.basename(name); safe = safe.replace(/[\x00-\x1f\x7f<>:"/\\|?*]/g, ""); safe = safe.replace(/^[. ]+|[. ]+$/g, ""); if ([...safe].length > 255) { const ext = path.extname(safe).slice(1); const base = path.basename(safe, ext ? "." + ext : ""); const maxBase = 250 - [...ext].length; const trimmedBase = [...base].slice(0, maxBase).join(""); safe = ext ? trimmedBase + "." + ext : trimmedBase; } return safe; } // --- Private helpers --- private findProjectFolder(projectNumber: string): string | null { if (!this.isConfigured()) return null; let entries: string[]; try { entries = fs.readdirSync(this.basePath); } catch { return null; } const prefix = projectNumber + "_"; for (const entry of entries) { if (entry.startsWith(prefix)) { const fullPath = this.basePath + "/" + entry; try { if (fs.statSync(fullPath).isDirectory()) { return fullPath; } } catch { continue; } } } return null; } private buildFolderName(projectNumber: string, projectName: string): string { let safeNum = projectNumber.replace(/[^\p{L}\p{N}_\-.]/gu, ""); safeNum = safeNum.replace(/^\.+|\.+$/g, "").trim(); let safe = projectName.replace(/[^\p{L}\p{N}_\-. ]/gu, ""); safe = safe.trim().replace(/ /g, "_").replace(/_+/g, "_"); if ([...safe].length > 200) { safe = [...safe].slice(0, 200).join(""); } return safeNum + "_" + safe; } private resolveProjectPath( projectNumber: string, subPath: string, ): string | null { const folderPath = this.findProjectFolder(projectNumber); if (folderPath === null) return null; if (subPath === "" || subPath === "/") { return folderPath; } // Basic path traversal protection if (subPath.includes("\0") || subPath.includes("..")) { return null; } const normalized = subPath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); // Reject explicit current-directory references (defense-in-depth for destructive ops) if (normalized === "." || normalized === "./") { return null; } const candidate = path.resolve(folderPath, normalized).replace(/\\/g, "/"); // Verify candidate is within project folder const normalFolder = folderPath.replace(/\\/g, "/"); if ( !candidate.startsWith(normalFolder + "/") && candidate !== normalFolder ) { return null; } // Check for symlinks in path components if (fs.existsSync(candidate)) { if (!this.walkAndRejectSymlinks(candidate, normalFolder)) { return null; } } else { // For new files/folders — check parent const parentDir = path.dirname(candidate); if (fs.existsSync(parentDir)) { if (!this.walkAndRejectSymlinks(parentDir, normalFolder)) { return null; } } } return candidate; } private walkAndRejectSymlinks(fullPath: string, basePath: string): boolean { const normalFull = fullPath.replace(/\\/g, "/"); const normalBase = basePath.replace(/\\/g, "/"); if (!normalFull.startsWith(normalBase)) { return false; } const relative = normalFull.slice(normalBase.length); if (!relative) return true; // same as base const parts = relative.split("/").filter(Boolean); let current = normalBase; for (const part of parts) { current = current + "/" + part; try { const lstat = fs.lstatSync(current); if (lstat.isSymbolicLink()) { return false; } } catch { // Path component doesn't exist yet, that's OK break; } } return true; } private countItems(dirPath: string): number { try { const entries = fs.readdirSync(dirPath); return entries.length; } catch { return 0; } } private formatFileSize(bytes: number): string { if (bytes < 1024) { return bytes + " B"; } if (bytes < 1048576) { return Math.round((bytes / 1024) * 10) / 10 + " KB"; } if (bytes < 1073741824) { return Math.round((bytes / 1048576) * 10) / 10 + " MB"; } return Math.round((bytes / 1073741824) * 10) / 10 + " GB"; } private isSuspiciousMime(mime: string): boolean { if (SUSPICIOUS_MIMES.includes(mime)) { return true; } // PHP files if (mime.includes("php") || mime.includes("x-httpd")) { return true; } return false; } }