feat: add NasFileManager service with security-hardened file operations

TypeScript port of PHP NasFileManager with symlink rejection,
path traversal protection, MIME validation via file-type, and
blocked extension checking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 10:16:36 +01:00
parent 373ea82279
commit ff26dc497d
3 changed files with 834 additions and 0 deletions

215
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string, string> = {
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<boolean> {
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<string | null> {
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<string | null> {
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;
}
}