7.5 KiB
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 withlstat()and reject if any component is a symlink. Do NOT usefs.realpathSync()(it follows symlinks by design). Instead, usepath.resolve()for prefix validation after confirming no symlinks exist. - Extended blocked extensions — add
vbs, vbe, js, ws, wsf, scr, pif, jar, regto the existing list (Windows Script Host executables). - Download headers —
X-Content-Type-Options: nosniffis already set globally by security middleware, but also set on download responses explicitly. - MIME validation via
file-typepackage version 16.x (last CJS-compatible version — the project uses CommonJS modules). Install asfile-type@16. - Use
fs.promises.rm()withrecursive: trueinstead of hand-rolled recursive delete. Pre-check for symlinks in the tree before callingrmto 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_SIZEenv var (default 52MB)
Public Methods
isConfigured(): boolean
createProjectFolder(projectNumber, projectName): boolean
deleteProjectFolder(projectNumber): Promise<boolean>
projectFolderExists(projectNumber): boolean
renameProjectFolder(projectNumber, newName): boolean
listFiles(projectNumber, subPath): ListResult | null
uploadFile(projectNumber, subPath, fileBuffer, fileName): Promise<string | null>
downloadFile(projectNumber, filePath): DownloadResult | null
deleteItem(projectNumber, filePath): Promise<string | null>
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_idexists in database before file operations - All write operations logged via
logAudit() - Download sets
Content-Disposition: attachmentandContent-Typefrom detected MIME - Upload route: register
@fastify/multipartwithin the plugin scope (same pattern as orders route) withbodyLimit: config.nas.maxUploadSizeto override the global 1MB limit - Folder name max 100 characters on create
moveItem: handleEXDEVerror (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, callnasFileManager.createProjectFolder(projectNumber, name)updateProject()— if project name changed, callnasFileManager.renameProjectFolder(projectNumber, newName)deleteProject()— ifdeleteFilesflag is true, callnasFileManager.deleteProjectFolder(projectNumber)getProject()— addhas_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
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
<ProjectFileManager />component - Pass
projectId,projectNumber,hasPermission,hasNasFolderprops - 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