# 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
