178 lines
7.5 KiB
Markdown
178 lines
7.5 KiB
Markdown
# 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<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_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 `<ProjectFileManager />` 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
|