style: run prettier on entire codebase
This commit is contained in:
385
docs/deployment-guide.md
Normal file
385
docs/deployment-guide.md
Normal file
@@ -0,0 +1,385 @@
|
||||
# Deployment Guide — boha-app-ts (Ubuntu Server)
|
||||
|
||||
Migration from PHP boha-app to TypeScript boha-app-ts on the same Ubuntu server.
|
||||
Both apps share the same MySQL database. The PHP app stays running during migration.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Ubuntu server with the PHP boha-app already running
|
||||
- nginx with SSL (Let's Encrypt) already configured
|
||||
- MySQL database already running with production data
|
||||
- NAS storage mounted (e.g., `/mnt/nas/02_PROJEKTY`)
|
||||
- SSH access to the server
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Prepare on Dev Machine (Windows)
|
||||
|
||||
### 1.1 Create Prisma migration baseline
|
||||
|
||||
```bash
|
||||
cd D:\cortex\boha-app-ts
|
||||
mkdir -p prisma/migrations/0_init
|
||||
npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script > prisma/migrations/0_init/migration.sql
|
||||
npx prisma migrate resolve --applied 0_init
|
||||
git add prisma/migrations/
|
||||
git commit -m "chore: create Prisma migration baseline"
|
||||
```
|
||||
|
||||
### 1.2 Build the application
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
This creates:
|
||||
- `dist/` — compiled server (Node.js)
|
||||
- `dist-client/` — compiled frontend (static files)
|
||||
|
||||
### 1.3 Generate new production secrets
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
# Save this as JWT_SECRET
|
||||
|
||||
openssl rand -hex 32
|
||||
# Save this as TOTP_ENCRYPTION_KEY (only if you want a new key)
|
||||
```
|
||||
|
||||
### 1.4 Test the production build locally (optional)
|
||||
|
||||
```bash
|
||||
APP_ENV=production JWT_SECRET=<your-dev-key> TOTP_ENCRYPTION_KEY=<your-dev-key> DATABASE_URL=<your-dev-db> node dist/server.js
|
||||
```
|
||||
|
||||
Verify it starts and responds at `http://localhost:3001/api/health`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Prepare Ubuntu Server
|
||||
|
||||
### 2.1 Install Node.js 22
|
||||
|
||||
```bash
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
node -v
|
||||
npm -v
|
||||
```
|
||||
|
||||
### 2.2 Install PM2
|
||||
|
||||
```bash
|
||||
sudo npm install -g pm2
|
||||
```
|
||||
|
||||
### 2.3 Create application directory
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/boha-app-ts
|
||||
sudo chown $USER:$USER /var/www/boha-app-ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Transfer Files to Server
|
||||
|
||||
### 3.1 Copy built files
|
||||
|
||||
From your Windows machine (Git Bash or PowerShell with SCP):
|
||||
|
||||
```bash
|
||||
scp -r dist/ dist-client/ package.json package-lock.json prisma/ scripts/ .env.example user@server:/var/www/boha-app-ts/
|
||||
```
|
||||
|
||||
Or use rsync if available:
|
||||
|
||||
```bash
|
||||
rsync -avz --exclude node_modules --exclude .env dist/ dist-client/ package.json package-lock.json prisma/ scripts/ .env.example user@server:/var/www/boha-app-ts/
|
||||
```
|
||||
|
||||
### 3.2 Install production dependencies on server
|
||||
|
||||
```bash
|
||||
ssh user@server
|
||||
cd /var/www/boha-app-ts
|
||||
npm install --production
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Configure Environment
|
||||
|
||||
### 4.1 Create production .env
|
||||
|
||||
```bash
|
||||
cd /var/www/boha-app-ts
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Fill in:
|
||||
|
||||
```env
|
||||
# Database — same as the PHP app
|
||||
DATABASE_URL=mysql://user:password@localhost:3306/your_db_name
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
HOST=127.0.0.1
|
||||
APP_ENV=production
|
||||
|
||||
# Auth — use the NEW secrets generated in step 1.3
|
||||
JWT_SECRET=<paste-new-jwt-secret>
|
||||
ACCESS_TOKEN_EXPIRY=900
|
||||
REFRESH_TOKEN_SESSION_EXPIRY=3600
|
||||
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
|
||||
|
||||
# TOTP — use SAME key as PHP app (unless you want to re-encrypt)
|
||||
TOTP_ENCRYPTION_KEY=<same-key-as-php-app>
|
||||
|
||||
# NAS — Linux mount point
|
||||
NAS_PATH=/mnt/nas/02_PROJEKTY
|
||||
MAX_UPLOAD_SIZE=52428800
|
||||
|
||||
# Email
|
||||
CONTACT_EMAIL_TO=manager@boha-automation.cz
|
||||
CONTACT_EMAIL_FROM=web@boha-automation.cz
|
||||
SMTP_FROM=noreply@boha-automation.cz
|
||||
|
||||
# CORS — your production domain(s)
|
||||
CORS_ORIGINS=https://app.boha-automation.cz,https://www.boha-automation.cz
|
||||
```
|
||||
|
||||
**Important decisions:**
|
||||
|
||||
| Setting | Recommendation |
|
||||
|---------|---------------|
|
||||
| `JWT_SECRET` | **New key.** All PHP sessions will be invalid — users re-login. This is expected. |
|
||||
| `TOTP_ENCRYPTION_KEY` | **Same key as PHP app.** Avoids re-encrypting all TOTP secrets. |
|
||||
| `DATABASE_URL` | **Same database as PHP app.** Both apps share it. |
|
||||
| `NAS_PATH` | **Linux mount point** instead of Windows drive letter. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Database Setup
|
||||
|
||||
### 5.1 Mark Prisma baseline as applied
|
||||
|
||||
```bash
|
||||
cd /var/www/boha-app-ts
|
||||
npx prisma migrate resolve --applied 0_init
|
||||
```
|
||||
|
||||
This tells Prisma the database already has all tables. No SQL is executed.
|
||||
|
||||
### 5.2 Verify database connection
|
||||
|
||||
```bash
|
||||
npx prisma db pull --print | head -20
|
||||
```
|
||||
|
||||
Should show your existing tables.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: TOTP Key Rotation (only if using new encryption key)
|
||||
|
||||
**Skip this section if you're using the same `TOTP_ENCRYPTION_KEY` as the PHP app.**
|
||||
|
||||
If you generated a new encryption key:
|
||||
|
||||
```bash
|
||||
cd /var/www/boha-app-ts
|
||||
|
||||
# Dry run — verify all secrets can be decrypted and re-encrypted
|
||||
npx tsx scripts/rotate-totp-key.ts <old-key> <new-key> --dry-run
|
||||
|
||||
# If all [OK], run for real
|
||||
npx tsx scripts/rotate-totp-key.ts <old-key> <new-key>
|
||||
```
|
||||
|
||||
After this, the PHP app's TOTP verification will break (secrets are now encrypted with the new key). Only do this when you're ready to cut over.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Start Application with PM2
|
||||
|
||||
### 7.1 Create PM2 config
|
||||
|
||||
```bash
|
||||
cd /var/www/boha-app-ts
|
||||
cat > ecosystem.config.js << 'EOF'
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'boha-app-ts',
|
||||
script: 'dist/server.js',
|
||||
cwd: '/var/www/boha-app-ts',
|
||||
instances: 1,
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
}]
|
||||
};
|
||||
EOF
|
||||
```
|
||||
|
||||
### 7.2 Start the app
|
||||
|
||||
```bash
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
pm2 startup
|
||||
# Follow the printed command to enable auto-start on boot
|
||||
```
|
||||
|
||||
### 7.3 Verify
|
||||
|
||||
```bash
|
||||
pm2 status
|
||||
pm2 logs boha-app-ts --lines 20
|
||||
curl http://localhost:3001/api/health
|
||||
```
|
||||
|
||||
Expected: `{"status":"ok","timestamp":"..."}`
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Nginx Configuration
|
||||
|
||||
### 8.1 Create nginx config
|
||||
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/boha-app-ts
|
||||
```
|
||||
|
||||
Paste:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name app.boha-automation.cz;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/app.boha-automation.cz/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/app.boha-automation.cz/privkey.pem;
|
||||
|
||||
client_max_body_size 55M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name app.boha-automation.cz;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
Adjust `server_name` and SSL paths to match your setup.
|
||||
|
||||
### 8.2 Enable and reload
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/boha-app-ts /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Testing
|
||||
|
||||
### 9.1 Verify in browser
|
||||
|
||||
Open `https://app.boha-automation.cz` and test:
|
||||
|
||||
- [ ] Login page loads
|
||||
- [ ] Login works (users will need to re-login due to new JWT_SECRET)
|
||||
- [ ] TOTP verification works (if using same encryption key)
|
||||
- [ ] Dashboard loads with data
|
||||
- [ ] Offers — list, create, edit, PDF export
|
||||
- [ ] Orders — list, create from offer, status transitions
|
||||
- [ ] Invoices — list, create, PDF with QR code
|
||||
- [ ] Projects — list, create, file manager (upload/download)
|
||||
- [ ] Attendance — clock in/out, admin view, print
|
||||
- [ ] Trips — list, history
|
||||
- [ ] Settings — company settings, users, roles
|
||||
|
||||
### 9.2 Check logs for errors
|
||||
|
||||
```bash
|
||||
pm2 logs boha-app-ts --lines 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Cutover Strategy
|
||||
|
||||
Both apps run simultaneously on the same database. Recommended approach:
|
||||
|
||||
1. **Week 1:** Run both apps. Use the TS app for daily work. Fall back to PHP if issues arise.
|
||||
2. **Week 2:** If stable, redirect the main domain to the TS app.
|
||||
3. **Week 3:** Stop the PHP app.
|
||||
|
||||
To redirect the PHP domain to the TS app, update the nginx config for the PHP domain to proxy to port 3001 instead.
|
||||
|
||||
---
|
||||
|
||||
## Future Updates
|
||||
|
||||
When you push code changes:
|
||||
|
||||
```bash
|
||||
# On dev machine
|
||||
npm run build
|
||||
git push
|
||||
|
||||
# On server
|
||||
cd /var/www/boha-app-ts
|
||||
git pull
|
||||
npm install --production
|
||||
npx prisma generate
|
||||
npx prisma migrate deploy # runs any new migrations
|
||||
pm2 restart boha-app-ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| `EADDRINUSE: port 3001` | `pm2 stop boha-app-ts` then `pm2 start` |
|
||||
| Prisma connection error | Check `DATABASE_URL` in `.env` |
|
||||
| TOTP verification fails | Verify `TOTP_ENCRYPTION_KEY` matches the key used to encrypt secrets |
|
||||
| NAS files not accessible | Check mount: `ls /mnt/nas/02_PROJEKTY`, verify permissions |
|
||||
| 502 Bad Gateway | App not running: `pm2 status`, check logs: `pm2 logs` |
|
||||
| CSS/JS not loading | Verify `dist-client/` was copied, check `APP_ENV=production` |
|
||||
| CORS errors | Check `CORS_ORIGINS` in `.env` matches your domain exactly |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `pm2 start ecosystem.config.js` | Start the app |
|
||||
| `pm2 restart boha-app-ts` | Restart after update |
|
||||
| `pm2 stop boha-app-ts` | Stop the app |
|
||||
| `pm2 logs boha-app-ts` | View logs |
|
||||
| `pm2 monit` | Live monitoring |
|
||||
| `npx prisma migrate deploy` | Apply database migrations |
|
||||
| `npx prisma studio` | Database GUI (dev only) |
|
||||
517
docs/superpowers/plans/2026-03-23-project-files.md
Normal file
517
docs/superpowers/plans/2026-03-23-project-files.md
Normal file
@@ -0,0 +1,517 @@
|
||||
# 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"
|
||||
```
|
||||
177
docs/superpowers/specs/2026-03-23-project-files-design.md
Normal file
177
docs/superpowers/specs/2026-03-23-project-files-design.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user