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

7.5 KiB

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

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