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:
618
src/services/nas-file-manager.ts
Normal file
618
src/services/nas-file-manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user