diff --git a/package-lock.json b/package-lock.json index 77763e8..7b2d92c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "date-fns": "^4.1.0", "dotenv": "^17.3.1", "fastify": "^5.8.2", + "file-type": "^16.5.4", "framer-motion": "^12.38.0", "hi-base32": "^0.5.1", "jsonwebtoken": "^9.0.3", @@ -1309,6 +1310,12 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1597,6 +1604,18 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -1724,6 +1743,26 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcryptjs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -1745,6 +1784,30 @@ "node": "18 || 20 || >=22" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -2334,12 +2397,30 @@ "@types/estree": "^1.0.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2528,6 +2609,23 @@ } } }, + "node_modules/file-type": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", + "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/find-my-way": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", @@ -2816,6 +2914,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -3560,6 +3678,19 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/peek-readable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", + "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -3688,6 +3819,15 @@ } } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -3874,6 +4014,38 @@ "react-dom": ">=16.8" } }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", + "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -4248,6 +4420,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4276,6 +4457,23 @@ "node": ">=8" } }, + "node_modules/strtok3": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", + "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/superagent": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", @@ -4420,6 +4618,23 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", + "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", diff --git a/package.json b/package.json index 38863a6..319e0a9 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "date-fns": "^4.1.0", "dotenv": "^17.3.1", "fastify": "^5.8.2", + "file-type": "^16.5.4", "framer-motion": "^12.38.0", "hi-base32": "^0.5.1", "jsonwebtoken": "^9.0.3", diff --git a/src/services/nas-file-manager.ts b/src/services/nas-file-manager.ts new file mode 100644 index 0000000..af59fdf --- /dev/null +++ b/src/services/nas-file-manager.ts @@ -0,0 +1,618 @@ +import fs from 'fs'; +import path from 'path'; +import { config } from '../config/env'; + +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 = + modified.getFullYear() + + '-' + String(modified.getMonth() + 1).padStart(2, '0') + + '-' + String(modified.getDate()).padStart(2, '0') + + ' ' + String(modified.getHours()).padStart(2, '0') + + ':' + String(modified.getMinutes()).padStart(2, '0'); + + 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); + } + } + + // Real path on disk for UI display + 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, + fileBuffer: Buffer, + fileName: string, + ): Promise { + const dirPath = this.resolveProjectPath(projectNumber, subPath); + if (dirPath === null || !fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) { + return 'Cílová složka neexistuje'; + } + + if (fileBuffer.length > config.nas.maxUploadSize) { + const maxMb = Math.round(config.nas.maxUploadSize / 1048576); + return `Soubor je příliš velký (max ${maxMb} MB)`; + } + + 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'; + } + + // MIME validation via file-type + try { + const typeResult = await FileType.fromBuffer(fileBuffer); + if (typeResult && this.isSuspiciousMime(typeResult.mime, ext)) { + return 'Obsah souboru neodpovídá jeho příponě'; + } + } catch { + // If file-type fails, continue without MIME check + } + + let destPath = dirPath + '/' + safeName; + + // If file exists, append counter + if (fs.existsSync(destPath)) { + const base = path.basename(safeName, ext ? '.' + ext : ''); + let counter = 1; + do { + safeName = base + '_' + counter + (ext ? '.' + ext : ''); + destPath = dirPath + '/' + safeName; + counter++; + } while (fs.existsSync(destPath)); + } + + try { + fs.writeFileSync(destPath, fileBuffer); + } 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 === '/') { + return 'Nelze smazat kořenovou složku projektu'; + } + + const fullPath = this.resolveProjectPath(projectNumber, filePath); + if (fullPath === null) { + return 'Neplatná cesta'; + } + + if (!fs.existsSync(fullPath)) { + return 'Soubor nebo složka neexistuje'; + } + + let isDir: boolean; + try { + isDir = fs.lstatSync(fullPath).isDirectory(); + } catch { + return 'Neplatná cesta'; + } + + try { + if (isDir) { + await fs.promises.rm(fullPath, { recursive: true, force: true }); + } else { + fs.unlinkSync(fullPath); + } + } catch { + return isDir + ? 'Nepodařilo se smazat složku' + : 'Nepodařilo se smazat soubor'; + } + + return null; + } + + public moveItem(projectNumber: string, fromPath: string, toPath: string): string | null { + if (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'; + } + + if (!fs.existsSync(fullFrom)) { + return 'Zdrojový soubor neexistuje'; + } + + // Case-insensitive FS (Windows) — allow case-only rename + const sameFile = + fullFrom.replace(/\\/g, '/').toLowerCase() === + fullTo.replace(/\\/g, '/').toLowerCase(); + + if (fs.existsSync(fullTo) && !sameFile) { + return 'Cílový soubor již existuje'; + } + + // Validate target name + const targetName = path.basename(toPath); + if (this.sanitizeFilename(targetName) !== targetName) { + return 'Neplatný cílový název'; + } + + try { + fs.renameSync(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 createFolder(projectNumber: string, subPath: string, folderName: string): string | null { + const dirPath = this.resolveProjectPath(projectNumber, subPath); + if (dirPath === null || !fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) { + return 'Nadřazená složka neexistuje'; + } + + const safeName = this.sanitizeFilename(folderName); + if (safeName === '') { + return 'Neplatný název složky'; + } + + const newPath = dirPath + '/' + safeName; + if (fs.existsSync(newPath)) { + return 'Složka s tímto názvem již existuje'; + } + + try { + fs.mkdirSync(newPath, { mode: 0o775 }); + } catch { + return 'Nepodařilo se vytvořit složku'; + } + + return null; + } + + public sanitizeFilename(name: string): string { + let safe = path.basename(name); + // Strip control chars and special chars + 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 safe = projectName.replace(/[^\p{L}\p{N}_\-. ]/gu, ''); + safe = safe.trim().replace(/ /g, '_'); + safe = safe.replace(/_+/g, '_'); + if ([...safe].length > 200) { + safe = [...safe].slice(0, 200).join(''); + } + return projectNumber + '_' + 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; + } + + // Normalize separators and trim + const normalized = subPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + 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, '/'); + + // Get the relative portion after basePath + 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, ext: string): boolean { + if (SUSPICIOUS_MIMES.includes(mime)) { + return true; + } + + // PHP files + if (mime.includes('php') || mime.includes('x-httpd')) { + return true; + } + + return false; + } +}