Files
PROJECT-CONTORL/backend/app/services/file_storage_service.py
beabigegg 9b220523ff feat: complete issue fixes and implement remaining features
## Critical Issues (CRIT-001~003) - All Fixed
- JWT secret key validation with pydantic field_validator
- Login audit logging for success/failure attempts
- Frontend API path prefix removal

## High Priority Issues (HIGH-001~008) - All Fixed
- Project soft delete using is_active flag
- Redis session token bytes handling
- Rate limiting with slowapi (5 req/min for login)
- Attachment API permission checks
- Kanban view with drag-and-drop
- Workload heatmap UI (WorkloadPage, WorkloadHeatmap)
- TaskDetailModal integrating Comments/Attachments
- UserSelect component for task assignment

## Medium Priority Issues (MED-001~012) - All Fixed
- MED-001~005: DB commits, N+1 queries, datetime, error format, blocker flag
- MED-006: Project health dashboard (HealthService, ProjectHealthPage)
- MED-007: Capacity update API (PUT /api/users/{id}/capacity)
- MED-008: Schedule triggers (cron parsing, deadline reminders)
- MED-009: Watermark feature (image/PDF watermarking)
- MED-010~012: useEffect deps, DOM operations, PDF export

## New Files
- backend/app/api/health/ - Project health API
- backend/app/services/health_service.py
- backend/app/services/trigger_scheduler.py
- backend/app/services/watermark_service.py
- backend/app/core/rate_limiter.py
- frontend/src/pages/ProjectHealthPage.tsx
- frontend/src/components/ProjectHealthCard.tsx
- frontend/src/components/KanbanBoard.tsx
- frontend/src/components/WorkloadHeatmap.tsx

## Tests
- 113 new tests passing (health: 32, users: 14, triggers: 35, watermark: 32)

## OpenSpec Archives
- add-project-health-dashboard
- add-capacity-update-api
- add-schedule-triggers
- add-watermark-feature
- add-rate-limiting
- enhance-frontend-ux
- add-resource-management-ui

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 21:49:52 +08:00

195 lines
6.0 KiB
Python

import os
import hashlib
import shutil
from pathlib import Path
from typing import BinaryIO, Optional, Tuple
from fastapi import UploadFile, HTTPException
from app.core.config import settings
class FileStorageService:
"""Service for handling file storage operations."""
def __init__(self):
self.base_dir = Path(settings.UPLOAD_DIR)
self._ensure_base_dir()
def _ensure_base_dir(self):
"""Ensure the base upload directory exists."""
self.base_dir.mkdir(parents=True, exist_ok=True)
def _get_file_path(self, project_id: str, task_id: str, attachment_id: str, version: int) -> Path:
"""Generate the file path for an attachment version."""
return self.base_dir / project_id / task_id / attachment_id / str(version)
@staticmethod
def calculate_checksum(file: BinaryIO) -> str:
"""Calculate SHA-256 checksum of a file."""
sha256_hash = hashlib.sha256()
# Read in chunks to handle large files
for chunk in iter(lambda: file.read(8192), b""):
sha256_hash.update(chunk)
file.seek(0) # Reset file position
return sha256_hash.hexdigest()
@staticmethod
def get_extension(filename: str) -> str:
"""Get file extension in lowercase."""
return filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
@staticmethod
def validate_file(file: UploadFile) -> Tuple[str, str]:
"""
Validate file size and type.
Returns (extension, mime_type) if valid.
Raises HTTPException if invalid.
"""
# Check file size
file.file.seek(0, 2) # Seek to end
file_size = file.file.tell()
file.file.seek(0) # Reset
if file_size > settings.MAX_FILE_SIZE:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_FILE_SIZE_MB}MB"
)
if file_size == 0:
raise HTTPException(status_code=400, detail="Empty file not allowed")
# Get extension
extension = FileStorageService.get_extension(file.filename or "")
# Check blocked extensions
if extension in settings.BLOCKED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"File type '.{extension}' is not allowed for security reasons"
)
# Check allowed extensions (if whitelist is enabled)
if settings.ALLOWED_EXTENSIONS and extension not in settings.ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"File type '.{extension}' is not supported"
)
mime_type = file.content_type or "application/octet-stream"
return extension, mime_type
async def save_file(
self,
file: UploadFile,
project_id: str,
task_id: str,
attachment_id: str,
version: int
) -> Tuple[str, int, str]:
"""
Save uploaded file to storage.
Returns (file_path, file_size, checksum).
"""
# Validate file
extension, _ = self.validate_file(file)
# Calculate checksum first
checksum = self.calculate_checksum(file.file)
# Create directory structure
dir_path = self._get_file_path(project_id, task_id, attachment_id, version)
dir_path.mkdir(parents=True, exist_ok=True)
# Save file with original extension
filename = f"file.{extension}" if extension else "file"
file_path = dir_path / filename
# Get file size
file.file.seek(0, 2)
file_size = file.file.tell()
file.file.seek(0)
# Write file in chunks (streaming)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
return str(file_path), file_size, checksum
def get_file(
self,
project_id: str,
task_id: str,
attachment_id: str,
version: int
) -> Optional[Path]:
"""
Get the file path for an attachment version.
Returns None if file doesn't exist.
"""
dir_path = self._get_file_path(project_id, task_id, attachment_id, version)
if not dir_path.exists():
return None
# Find the file in the directory
files = list(dir_path.iterdir())
if not files:
return None
return files[0]
def get_file_by_path(self, file_path: str) -> Optional[Path]:
"""Get file by stored path. Handles both absolute and relative paths."""
path = Path(file_path)
# If path is absolute and exists, return it directly
if path.is_absolute() and path.exists():
return path
# If path is relative, try prepending base_dir
full_path = self.base_dir / path
if full_path.exists():
return full_path
# Fallback: check if original path exists (e.g., relative from current dir)
if path.exists():
return path
return None
def delete_file(
self,
project_id: str,
task_id: str,
attachment_id: str,
version: Optional[int] = None
) -> bool:
"""
Delete file(s) from storage.
If version is None, deletes all versions.
Returns True if successful.
"""
if version is not None:
# Delete specific version
dir_path = self._get_file_path(project_id, task_id, attachment_id, version)
else:
# Delete all versions (attachment directory)
dir_path = self.base_dir / project_id / task_id / attachment_id
if dir_path.exists():
shutil.rmtree(dir_path)
return True
return False
def delete_task_files(self, project_id: str, task_id: str) -> bool:
"""Delete all files for a task."""
dir_path = self.base_dir / project_id / task_id
if dir_path.exists():
shutil.rmtree(dir_path)
return True
return False
# Singleton instance
file_storage_service = FileStorageService()