# Project File Sharing — boha-app-ts **Date:** 2026-03-23 **Status:** Approved **Scope:** NAS-based file management for projects — port of PHP NasFileManager with security improvements --- ## Context The PHP boha-app has a complete project file sharing system via NAS-mounted storage. The TS version has only a placeholder UI and the `NAS_PATH` env var configured. This spec covers porting the full functionality with security improvements identified during audit. --- ## 1. Backend — NasFileManager Service **File:** `src/services/nas-file-manager.ts` Port of `NasFileManager.php` (622 lines) with security improvements. ### Security Improvements Over PHP - **No symlink/junction following** — use `fs.lstatSync()` everywhere. Walk each path component with `lstat()` and reject if any component is a symlink. Do NOT use `fs.realpathSync()` (it follows symlinks by design). Instead, use `path.resolve()` for prefix validation after confirming no symlinks exist. - **Extended blocked extensions** — add `vbs, vbe, js, ws, wsf, scr, pif, jar, reg` to the existing list (Windows Script Host executables). - **Download headers** — `X-Content-Type-Options: nosniff` is already set globally by security middleware, but also set on download responses explicitly. - **MIME validation** via `file-type` package **version 16.x** (last CJS-compatible version — the project uses CommonJS modules). Install as `file-type@16`. - **Use `fs.promises.rm()` with `recursive: true`** instead of hand-rolled recursive delete. Pre-check for symlinks in the tree before calling `rm` to prevent junction traversal on Windows. ### Path Resolution Algorithm (`resolveProjectPath`) ``` 1. Find project folder by prefix scan 2. If subPath is empty, return project folder 3. Reject if subPath contains null bytes or ".." 4. Normalize separators (backslash → forward slash), trim slashes 5. Build candidate = projectFolder + "/" + subPath 6. Walk each path component with lstatSync(): - If component is a symlink → return null (reject) 7. Verify path.resolve(candidate) starts with projectFolder 8. Return candidate (or null if validation fails) ``` ### Retained Security (same as PHP) - Null byte detection in paths - `..` traversal blocking - Filename sanitization: strip control chars (0x00-0x1f, 0x7f), invalid chars (`<>:"/\|?*`), trim dots/spaces, max 255 chars, `path.basename()` extraction - Blocked extensions: `exe, bat, sh, php, htaccess, env, cmd, com, msi, ps1, vbs, vbe, js, ws, wsf, scr, pif, jar, reg` - Executable MIME type detection (application/x-executable, x-msdos-program, x-dosexec, x-msdownload) - PHP MIME detection (any MIME containing "php" or "x-httpd") - Root folder deletion prevention - Duplicate file naming (append `_1`, `_2`, etc.) — note: TOCTOU race exists on SMB mounts but is acceptable for this use case (low concurrency internal app) - File size limit from `MAX_UPLOAD_SIZE` env var (default 52MB) ### Public Methods ``` isConfigured(): boolean createProjectFolder(projectNumber, projectName): boolean deleteProjectFolder(projectNumber): Promise projectFolderExists(projectNumber): boolean renameProjectFolder(projectNumber, newName): boolean listFiles(projectNumber, subPath): ListResult | null uploadFile(projectNumber, subPath, fileBuffer, fileName): Promise downloadFile(projectNumber, filePath): DownloadResult | null deleteItem(projectNumber, filePath): Promise moveItem(projectNumber, fromPath, toPath): string | null createFolder(projectNumber, subPath, folderName): string | null sanitizeFilename(name): string ``` ### Private Methods ``` findProjectFolder(projectNumber): string | null buildFolderName(projectNumber, projectName): string resolveProjectPath(projectNumber, subPath): string | null walkAndRejectSymlinks(fullPath, basePath): boolean countItems(dirPath): number formatFileSize(bytes): string isSuspiciousMime(mime, ext): boolean ``` --- ## 2. Backend — Project Files Route **File:** `src/routes/admin/project-files.ts` **Modify:** `src/server.ts` — add import and register: `app.register(projectFilesRoutes, { prefix: '/api/admin/project-files' })` ### Endpoints | Method | Query Params | Permission | Action | |--------|-------------|------------|--------| | GET | `project_id, path` | `projects.view` | List files/folders | | GET | `action=download, project_id, path` | `projects.view` | Stream file download | | POST | `action=upload, project_id, path` | `projects.files` | Upload file (multipart) | | POST | `action=create_folder, project_id, path, folder_name` | `projects.files` | Create subfolder | | PUT | `action=move, project_id, from_path, to_path` | `projects.files` | Move/rename | | DELETE | `project_id, path` | `projects.files` | Delete file/folder | ### Behavior - All endpoints validate `project_id` exists in database before file operations - All write operations logged via `logAudit()` - Download sets `Content-Disposition: attachment` and `Content-Type` from detected MIME - Upload route: register `@fastify/multipart` within the plugin scope (same pattern as orders route) with `bodyLimit: config.nas.maxUploadSize` to override the global 1MB limit - Folder name max 100 characters on create - `moveItem`: handle `EXDEV` error (cross-device) gracefully with error message — source and target are expected to be on the same NAS mount --- ## 3. Project CRUD Integration **Modify:** `src/services/projects.service.ts` - `createProject()` — after DB insert, call `nasFileManager.createProjectFolder(projectNumber, name)` - `updateProject()` — if project name changed, call `nasFileManager.renameProjectFolder(projectNumber, newName)` - `deleteProject()` — if `deleteFiles` flag is true, call `nasFileManager.deleteProjectFolder(projectNumber)` - `getProject()` — add `has_nas_folder: nasFileManager.projectFolderExists(projectNumber)` to response --- ## 4. Frontend — ProjectFileManager Component **File:** `src/admin/components/ProjectFileManager.tsx` Port of PHP's `ProjectFileManager.jsx` (657 lines). ### Features - File/folder table with columns: Name, Size, Modified, Actions - File type icons by extension (folder, pdf, image, doc, xls, zip, etc.) - Breadcrumb navigation with clickable path segments - Full path display (real path on disk) - Drag-and-drop upload zone with visual feedback - Click-to-upload fallback via hidden file input - Create folder dialog (input + confirm) - Inline rename (click name to edit) - Delete with ConfirmModal - Download via direct link with auth cookie (NOT blob URL — avoids loading large files into memory) - Permission check: write operations only shown if `hasPermission('projects.files')` - Item count for folders, formatted size for files - Folders sorted first, then files, both alphabetically ### Props ```typescript interface ProjectFileManagerProps { projectId: number projectNumber: string | null hasPermission: (perm: string) => boolean hasNasFolder: boolean } ``` --- ## 5. ProjectDetail Integration **Modify:** `src/admin/pages/ProjectDetail.tsx` - Replace the placeholder "Soubory" section with `` component - Pass `projectId`, `projectNumber`, `hasPermission`, `hasNasFolder` props - Delete dialog: add checkbox "Smazat i soubory na disku" that sends `delete_files: true` --- ## Dependencies - `file-type@16` — MIME detection (version 16 is the last CJS-compatible version) - `@fastify/multipart` — already installed (used by orders route) --- ## Out of Scope - File versioning / history - File sharing links (public URLs) - Thumbnail generation for images - Full-text search within files - Virus scanning