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