518 lines
20 KiB
Markdown
518 lines
20 KiB
Markdown
# Project File Sharing Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add NAS-based file management to projects — list, upload, download, delete, rename, create folders — with security hardening over the PHP original.
|
|
|
|
**Architecture:** `NasFileManager` service handles all filesystem operations with path traversal prevention and symlink rejection. `project-files` route exposes REST endpoints with permission checks and audit logging. `ProjectFileManager` React component provides the UI with drag-drop upload, breadcrumbs, and inline rename.
|
|
|
|
**Tech Stack:** Node.js `fs` module, `file-type@16` (CJS), `@fastify/multipart`, Fastify 5, React
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-23-project-files-design.md`
|
|
|
|
---
|
|
|
|
## Task 1: Install Dependencies & Create NasFileManager Service
|
|
|
|
**Files:**
|
|
- Create: `src/services/nas-file-manager.ts`
|
|
|
|
- [ ] **Step 1: Install file-type v16**
|
|
|
|
```bash
|
|
npm install file-type@16
|
|
```
|
|
|
|
- [ ] **Step 2: Create NasFileManager service**
|
|
|
|
Create `src/services/nas-file-manager.ts`. This is a port of the PHP `NasFileManager.php` with security improvements (no symlink following, stricter path resolution).
|
|
|
|
The service must implement:
|
|
|
|
**Constants:**
|
|
```typescript
|
|
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',
|
|
];
|
|
```
|
|
|
|
**Core methods to port from PHP (read `D:\cortex\boha-app\api\includes\NasFileManager.php` for exact logic):**
|
|
|
|
- `constructor()` — read `config.nas.path`, normalize separators
|
|
- `isConfigured()` — check basePath exists and is a directory
|
|
- `createProjectFolder(projectNumber, projectName)` — build folder name, `fs.mkdirSync(path, { recursive: true })`
|
|
- `deleteProjectFolder(projectNumber)` — find folder, `fs.promises.rm(path, { recursive: true, force: true })`
|
|
- `projectFolderExists(projectNumber)` — call `findProjectFolder()`, return boolean
|
|
- `renameProjectFolder(projectNumber, newName)` — find folder, `fs.renameSync()`
|
|
- `listFiles(projectNumber, subPath)` — resolve path, `fs.readdirSync()`, build items array with type/size/modified/extension, sort folders first then alpha, build breadcrumb
|
|
- `uploadFile(projectNumber, subPath, fileBuffer, fileName)` — validate extension, validate MIME via `file-type`, sanitize filename, handle duplicates with `_1`, `_2` suffix, `fs.writeFileSync()`
|
|
- `downloadFile(projectNumber, filePath)` — resolve path, return `{ filePath, fileName, mime }` for the route to stream
|
|
- `deleteItem(projectNumber, filePath)` — prevent root deletion, `fs.promises.rm()` for dirs, `fs.unlinkSync()` for files
|
|
- `moveItem(projectNumber, fromPath, toPath)` — validate both paths, check target doesn't exist (case-insensitive rename allowed), validate target filename, `fs.renameSync()`. Wrap in try-catch: if error code is `EXDEV` (cross-device), return `'Přesun mezi různými disky není podporován'`
|
|
- `createFolder(projectNumber, subPath, folderName)` — sanitize name, max 100 chars, `fs.mkdirSync()`
|
|
|
|
**Security-critical method — `resolveProjectPath(projectNumber, subPath)`:**
|
|
```typescript
|
|
private resolveProjectPath(projectNumber: string, subPath: string): string | null {
|
|
const folderPath = this.findProjectFolder(projectNumber);
|
|
if (!folderPath) return null;
|
|
|
|
if (!subPath || subPath === '/') return folderPath;
|
|
|
|
// Block null bytes and parent traversal
|
|
if (subPath.includes('\0') || subPath.includes('..')) return null;
|
|
|
|
// Normalize separators
|
|
subPath = subPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
|
|
const candidate = path.resolve(folderPath, subPath);
|
|
|
|
// Verify resolved path starts with project folder (prevents traversal)
|
|
if (!candidate.startsWith(folderPath)) return null;
|
|
|
|
// Walk each component — reject symlinks
|
|
if (!this.walkAndRejectSymlinks(candidate, folderPath)) return null;
|
|
|
|
return candidate;
|
|
}
|
|
```
|
|
|
|
**Symlink rejection — `walkAndRejectSymlinks(fullPath, basePath)`:**
|
|
```typescript
|
|
private walkAndRejectSymlinks(fullPath: string, basePath: string): boolean {
|
|
// Walk from basePath down to fullPath, checking each existing segment
|
|
const relative = path.relative(basePath, fullPath);
|
|
const parts = relative.split(path.sep);
|
|
let current = basePath;
|
|
|
|
for (const part of parts) {
|
|
current = path.join(current, part);
|
|
try {
|
|
const stat = fs.lstatSync(current);
|
|
if (stat.isSymbolicLink()) return false;
|
|
} catch {
|
|
// Path doesn't exist yet (for new files) — that's OK
|
|
break;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
```
|
|
|
|
**Helper methods:**
|
|
- `findProjectFolder(projectNumber)` — scan basePath for folders starting with `{number}_`
|
|
- `buildFolderName(projectNumber, name)` — `{number}_{sanitized_name}` (strip invalid chars, replace spaces with `_`, max 200 chars)
|
|
- `sanitizeFilename(name)` — `path.basename()`, strip control chars and `<>:"/\|?*`, trim dots/spaces, max 255 chars
|
|
- `formatFileSize(bytes)` — human-readable (B, KB, MB, GB)
|
|
- `isSuspiciousMime(mime, ext)` — check against SUSPICIOUS_MIMES and PHP-related MIME types
|
|
- `countItems(dirPath)` — count directory entries minus `.` and `..`
|
|
|
|
**MIME detection for upload:**
|
|
```typescript
|
|
import FileType from 'file-type';
|
|
|
|
// In uploadFile():
|
|
const typeResult = await FileType.fromBuffer(fileBuffer);
|
|
const detectedMime = typeResult?.mime || 'application/octet-stream';
|
|
if (this.isSuspiciousMime(detectedMime, ext)) {
|
|
return 'Obsah souboru neodpovídá jeho příponě';
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify TypeScript compiles**
|
|
|
|
```bash
|
|
npx tsc -p tsconfig.server.json --noEmit
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/services/nas-file-manager.ts package.json package-lock.json
|
|
git commit -m "feat: add NasFileManager service with security-hardened file operations"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Create Project Files Route
|
|
|
|
**Files:**
|
|
- Create: `src/routes/admin/project-files.ts`
|
|
- Modify: `src/server.ts`
|
|
|
|
- [ ] **Step 1: Create the route file**
|
|
|
|
Create `src/routes/admin/project-files.ts`. Port from `D:\cortex\boha-app\api\admin\handlers\project-files-handlers.php`.
|
|
|
|
The route registers `@fastify/multipart` within its own plugin scope (same pattern as orders route) with `limits: { fileSize: config.nas.maxUploadSize }`.
|
|
|
|
Set `bodyLimit: config.nas.maxUploadSize` on the upload POST route to override the global 1MB limit.
|
|
|
|
**Endpoints:**
|
|
|
|
First, add `'project_file'` to the `EntityType` union in `src/types/index.ts` (find the `EntityType` type and add it to the list).
|
|
|
|
```typescript
|
|
import fs from 'fs';
|
|
import { FastifyInstance } from 'fastify';
|
|
import multipart from '@fastify/multipart';
|
|
import prisma from '../../config/database';
|
|
import { config } from '../../config/env';
|
|
import { requirePermission } from '../../middleware/auth';
|
|
import { logAudit } from '../../services/audit';
|
|
import { success, error } from '../../utils/response';
|
|
import { NasFileManager } from '../../services/nas-file-manager';
|
|
|
|
export default async function projectFilesRoutes(fastify: FastifyInstance): Promise<void> {
|
|
await fastify.register(multipart, { limits: { fileSize: config.nas.maxUploadSize } });
|
|
|
|
const fm = new NasFileManager();
|
|
|
|
// Helper: get project from DB
|
|
async function getProject(projectId: number) {
|
|
return prisma.projects.findUnique({
|
|
where: { id: projectId },
|
|
select: { id: true, project_number: true, name: true },
|
|
});
|
|
}
|
|
|
|
// GET — list files or download
|
|
fastify.get('/', { preHandler: requirePermission('projects.view') }, async (request, reply) => {
|
|
const query = request.query as Record<string, string>;
|
|
const projectId = Number(query.project_id);
|
|
if (!projectId) return error(reply, 'ID projektu je povinné', 400);
|
|
|
|
const project = await getProject(projectId);
|
|
if (!project) return error(reply, 'Projekt nebyl nalezen', 404);
|
|
if (!project.project_number) return error(reply, 'Projekt nemá číslo', 400);
|
|
|
|
// Download action
|
|
if (query.action === 'download') {
|
|
const filePath = query.path || '';
|
|
if (!filePath) return error(reply, 'Cesta k souboru je povinná', 400);
|
|
|
|
if (!fm.isConfigured()) return error(reply, 'Souborový systém není nakonfigurován', 500);
|
|
|
|
const result = fm.downloadFile(project.project_number, filePath);
|
|
if (!result) return error(reply, 'Soubor nebyl nalezen', 404);
|
|
|
|
reply.header('Content-Disposition', `attachment; filename="${fm.sanitizeFilename(result.fileName)}"`);
|
|
reply.header('Content-Type', result.mime);
|
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
return reply.send(fs.createReadStream(result.filePath));
|
|
}
|
|
|
|
// List files
|
|
if (!fm.isConfigured()) return error(reply, 'Souborový systém není nakonfigurován', 500);
|
|
|
|
const result = fm.listFiles(project.project_number, query.path || '');
|
|
if (!result) return error(reply, 'Složka nebyla nalezena', 404);
|
|
|
|
return success(reply, { ...result, project_number: project.project_number, folder_exists: true });
|
|
});
|
|
|
|
// POST — create folder (JSON body, no multipart)
|
|
fastify.post('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => {
|
|
const query = request.query as Record<string, string>;
|
|
const projectId = Number(query.project_id);
|
|
if (!projectId) return error(reply, 'ID projektu je povinné', 400);
|
|
|
|
const project = await getProject(projectId);
|
|
if (!project || !project.project_number) return error(reply, 'Projekt nebyl nalezen', 404);
|
|
if (!fm.isConfigured()) return error(reply, 'Souborový systém není nakonfigurován', 500);
|
|
|
|
if (!fm.projectFolderExists(project.project_number)) {
|
|
fm.createProjectFolder(project.project_number, project.name || '');
|
|
}
|
|
|
|
const body = request.body as Record<string, string>;
|
|
const folderName = (body.folder_name || '').trim();
|
|
if (!folderName) return error(reply, 'Název složky je povinný', 400);
|
|
if (folderName.length > 100) return error(reply, 'Název složky je příliš dlouhý (max 100 znaků)', 400);
|
|
|
|
const folderError = fm.createFolder(project.project_number, body.path || '', folderName);
|
|
if (folderError) return error(reply, folderError, 400);
|
|
|
|
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'project_file', entityId: projectId, description: `Vytvořena složka '${folderName}' v projektu '${project.project_number}'` });
|
|
return success(reply, null, 200, 'Složka byla vytvořena');
|
|
});
|
|
|
|
// POST upload — separate route with multipart parsing
|
|
fastify.post('/upload', {
|
|
preHandler: requirePermission('projects.files'),
|
|
bodyLimit: config.nas.maxUploadSize,
|
|
}, async (request, reply) => {
|
|
// Register multipart for this request
|
|
const data = await request.file();
|
|
if (!data) return error(reply, 'Nebyl nahrán žádný soubor', 400);
|
|
|
|
const query = request.query as Record<string, string>;
|
|
const projectId = Number(query.project_id);
|
|
if (!projectId) return error(reply, 'ID projektu je povinné', 400);
|
|
|
|
const project = await getProject(projectId);
|
|
if (!project || !project.project_number) return error(reply, 'Projekt nebyl nalezen', 404);
|
|
if (!fm.isConfigured()) return error(reply, 'Souborový systém není nakonfigurován', 500);
|
|
|
|
if (!fm.projectFolderExists(project.project_number)) {
|
|
fm.createProjectFolder(project.project_number, project.name || '');
|
|
}
|
|
|
|
const buffer = await data.toBuffer();
|
|
const uploadError = await fm.uploadFile(project.project_number, query.path || '', buffer, data.filename);
|
|
if (uploadError) return error(reply, uploadError, 400);
|
|
|
|
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'project_file', entityId: projectId, description: `Nahrán soubor do projektu '${project.project_number}'` });
|
|
return success(reply, null, 200, 'Soubor byl nahrán');
|
|
});
|
|
|
|
// PUT — move/rename
|
|
fastify.put('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => {
|
|
const query = request.query as Record<string, string>;
|
|
const projectId = Number(query.project_id);
|
|
if (!projectId) return error(reply, 'ID projektu je povinné', 400);
|
|
|
|
const project = await getProject(projectId);
|
|
if (!project || !project.project_number) return error(reply, 'Projekt nebyl nalezen', 404);
|
|
|
|
if (!fm.isConfigured()) return error(reply, 'Souborový systém není nakonfigurován', 500);
|
|
|
|
const body = request.body as Record<string, string>;
|
|
if (!body.from_path || !body.to_path) return error(reply, 'Zdrojová i cílová cesta jsou povinné', 400);
|
|
|
|
const moveError = fm.moveItem(project.project_number, body.from_path, body.to_path);
|
|
if (moveError) return error(reply, moveError, 400);
|
|
|
|
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'project_file', entityId: projectId, description: `Přesun/přejmenování v projektu '${project.project_number}'` });
|
|
return success(reply, null, 200, 'Soubor byl přesunut');
|
|
});
|
|
|
|
// DELETE
|
|
fastify.delete('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => {
|
|
const query = request.query as Record<string, string>;
|
|
const projectId = Number(query.project_id);
|
|
if (!projectId) return error(reply, 'ID projektu je povinné', 400);
|
|
|
|
const project = await getProject(projectId);
|
|
if (!project || !project.project_number) return error(reply, 'Projekt nebyl nalezen', 404);
|
|
|
|
if (!fm.isConfigured()) return error(reply, 'Souborový systém není nakonfigurován', 500);
|
|
|
|
const filePath = query.path || '';
|
|
if (!filePath) return error(reply, 'Cesta k souboru je povinná', 400);
|
|
|
|
const deleteError = await fm.deleteItem(project.project_number, filePath);
|
|
if (deleteError) return error(reply, deleteError, 400);
|
|
|
|
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'project_file', entityId: projectId, description: `Smazán soubor/složka v projektu '${project.project_number}'` });
|
|
return success(reply, null, 200, 'Soubor byl smazán');
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Register route in server.ts**
|
|
|
|
Add import and registration in `src/server.ts`:
|
|
|
|
```typescript
|
|
import projectFilesRoutes from './routes/admin/project-files';
|
|
```
|
|
|
|
And in the routes section:
|
|
```typescript
|
|
await app.register(projectFilesRoutes, { prefix: '/api/admin/project-files' });
|
|
```
|
|
|
|
- [ ] **Step 3: Verify TypeScript compiles**
|
|
|
|
```bash
|
|
npx tsc -p tsconfig.server.json --noEmit
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/routes/admin/project-files.ts src/server.ts
|
|
git commit -m "feat: add project files REST endpoints with auth and audit logging"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Integrate File Operations with Project CRUD
|
|
|
|
**Files:**
|
|
- Modify: `src/services/projects.service.ts`
|
|
- Modify: `src/routes/admin/projects.ts`
|
|
|
|
- [ ] **Step 1: Update projects service**
|
|
|
|
In `src/services/projects.service.ts`:
|
|
|
|
Add import:
|
|
```typescript
|
|
import { NasFileManager } from './nas-file-manager';
|
|
const nasFileManager = new NasFileManager();
|
|
```
|
|
|
|
Update `createProject()` — after DB insert, create NAS folder:
|
|
```typescript
|
|
if (project.project_number && nasFileManager.isConfigured()) {
|
|
nasFileManager.createProjectFolder(project.project_number, project.name || '');
|
|
}
|
|
```
|
|
|
|
Update `updateProject()` — if name changed, rename folder:
|
|
```typescript
|
|
if (existing.name !== data.name && existing.project_number && nasFileManager.isConfigured()) {
|
|
nasFileManager.renameProjectFolder(existing.project_number, data.name || '');
|
|
}
|
|
```
|
|
|
|
Update `deleteProject()` — accept `deleteFiles` param, delete folder if true:
|
|
```typescript
|
|
if (deleteFiles && project.project_number && nasFileManager.isConfigured()) {
|
|
await nasFileManager.deleteProjectFolder(project.project_number);
|
|
}
|
|
```
|
|
|
|
Update `getProject()` — add `has_nas_folder` to response:
|
|
```typescript
|
|
const result = {
|
|
...project,
|
|
has_nas_folder: project.project_number ? nasFileManager.projectFolderExists(project.project_number) : false,
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Update projects route for delete_files**
|
|
|
|
In the DELETE handler in `src/routes/admin/projects.ts`, extract `delete_files` from the request body and pass it to the service:
|
|
|
|
```typescript
|
|
const body = request.body as Record<string, unknown>;
|
|
const deleteFiles = !!body?.delete_files;
|
|
const result = await deleteProject(id, deleteFiles);
|
|
```
|
|
|
|
Update the `deleteProject` service function signature to `deleteProject(id: number, deleteFiles: boolean = false)`.
|
|
|
|
- [ ] **Step 3: Verify TypeScript compiles**
|
|
|
|
```bash
|
|
npx tsc -p tsconfig.server.json --noEmit
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/services/projects.service.ts src/routes/admin/projects.ts src/types/index.ts
|
|
git commit -m "feat: integrate NAS file operations with project CRUD"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Create ProjectFileManager Frontend Component
|
|
|
|
**Files:**
|
|
- Create: `src/admin/components/ProjectFileManager.tsx`
|
|
- Modify: `src/admin/admin.css`
|
|
|
|
- [ ] **Step 1: Create the component**
|
|
|
|
Create `src/admin/components/ProjectFileManager.tsx`. This is a direct TypeScript port of `D:\cortex\boha-app\src\admin\components\ProjectFileManager.jsx` (657 lines).
|
|
|
|
Read the PHP JSX file completely and port it to TypeScript with these changes:
|
|
- All API URLs use `/api/admin/project-files` instead of `/api/admin/project-files.php`
|
|
- Add TypeScript interfaces for props, items, etc.
|
|
- Use `apiFetch` from `../utils/api`
|
|
- Use `ConfirmModal` from `./ConfirmModal`
|
|
- Use `useAlert` from `../context/AlertContext`
|
|
- Download uses blob URL approach (same as PHP frontend — the spec suggestion for direct links is nice-to-have but the PHP uses blob and it works fine for typical project files)
|
|
|
|
The component includes:
|
|
- `getFileIcon()` helper with SVG icons by extension
|
|
- `FileNameCell` sub-component for folder links and file names
|
|
- State management for items, loading, path, breadcrumb, upload, create folder, rename, delete
|
|
- `fetchFiles()`, `handleUpload()`, `handleDownload()`, `handleDelete()`, `handleRename()`, `handleCreateFolder()` handlers
|
|
- Drag-and-drop upload zone
|
|
- Toolbar with breadcrumb, full path display, folder/upload buttons
|
|
- File table with icon, name, size, modified, actions columns
|
|
- ConfirmModal for delete confirmation
|
|
|
|
- [ ] **Step 2: Add CSS styles**
|
|
|
|
Append the file manager CSS to `src/admin/admin.css`. Copy from `D:\cortex\boha-app\src\admin\admin.css` lines 2508-2674 (the `.fm-*` classes).
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/admin/components/ProjectFileManager.tsx src/admin/admin.css
|
|
git commit -m "feat: add ProjectFileManager component with file browser UI"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Integrate FileManager into ProjectDetail
|
|
|
|
**Files:**
|
|
- Modify: `src/admin/pages/ProjectDetail.tsx`
|
|
|
|
- [ ] **Step 1: Replace placeholder with ProjectFileManager**
|
|
|
|
In `src/admin/pages/ProjectDetail.tsx`:
|
|
|
|
Add import:
|
|
```typescript
|
|
import ProjectFileManager from '../components/ProjectFileManager'
|
|
```
|
|
|
|
Find the files placeholder section (the `admin-card` with "Správa souborů projektu bude dostupná v příští verzi") and replace it with:
|
|
|
|
```tsx
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, delay: 0.12 }}
|
|
>
|
|
<ProjectFileManager
|
|
projectId={project.id}
|
|
projectNumber={project.project_number}
|
|
hasPermission={hasPermission}
|
|
hasNasFolder={project.has_nas_folder}
|
|
/>
|
|
</motion.div>
|
|
```
|
|
|
|
- [ ] **Step 2: Update delete dialog**
|
|
|
|
The delete dialog should already send `delete_files` — verify the existing `deleteFiles` state and checkbox are wired up correctly. If not, add a checkbox:
|
|
|
|
```tsx
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem' }}>
|
|
<input type="checkbox" checked={deleteFiles} onChange={e => setDeleteFiles(e.target.checked)} />
|
|
Smazat i soubory na disku
|
|
</label>
|
|
```
|
|
|
|
- [ ] **Step 3: Verify everything works**
|
|
|
|
Start the dev server manually and test:
|
|
1. Navigate to a project detail page
|
|
2. Verify the file manager loads (may show empty folder message)
|
|
3. Test upload, create folder, rename, delete, download
|
|
4. Verify permissions (non-admin without `projects.files` should not see write buttons)
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/admin/pages/ProjectDetail.tsx
|
|
git commit -m "feat: integrate ProjectFileManager into project detail page"
|
|
```
|