Files
app/docs/superpowers/plans/2026-03-23-project-files.md
2026-03-24 19:59:14 +01:00

20 KiB

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

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:

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):

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):

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:

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
npx tsc -p tsconfig.server.json --noEmit
  • Step 4: Commit
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).

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:

import projectFilesRoutes from './routes/admin/project-files';

And in the routes section:

await app.register(projectFilesRoutes, { prefix: '/api/admin/project-files' });
  • Step 3: Verify TypeScript compiles
npx tsc -p tsconfig.server.json --noEmit
  • Step 4: Commit
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:

import { NasFileManager } from './nas-file-manager';
const nasFileManager = new NasFileManager();

Update createProject() — after DB insert, create NAS folder:

if (project.project_number && nasFileManager.isConfigured()) {
  nasFileManager.createProjectFolder(project.project_number, project.name || '');
}

Update updateProject() — if name changed, rename folder:

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:

if (deleteFiles && project.project_number && nasFileManager.isConfigured()) {
  await nasFileManager.deleteProjectFolder(project.project_number);
}

Update getProject() — add has_nas_folder to response:

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:

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
npx tsc -p tsconfig.server.json --noEmit
  • Step 4: Commit
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
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:

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:

<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:

<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
git add src/admin/pages/ProjectDetail.tsx
git commit -m "feat: integrate ProjectFileManager into project detail page"