Spaces:
Running
Running
| """Author RAG Chatbot SaaS — File Utilities. | |
| SHA-256 hashing, MIME type detection by magic bytes, size checking. | |
| RULE: Always validate by magic bytes, NEVER by file extension alone. | |
| """ | |
| import hashlib | |
| import os | |
| import zipfile | |
| from pathlib import Path | |
| import structlog | |
| from app.config import get_settings | |
| from app.exceptions.ingestion import ( | |
| EmptyFileError, | |
| FileTooLargeError, | |
| UnsupportedFormatError, | |
| ) | |
| logger = structlog.get_logger(__name__) | |
| cfg = get_settings() | |
| # MIME types we accept → maps to our extension labels | |
| _SUPPORTED_MIME_TYPES: dict[str, str] = { | |
| "application/pdf": "pdf", | |
| "application/epub+zip": "epub", | |
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", | |
| "text/plain": "txt", | |
| "text/x-tex": "txt", | |
| } | |
| def _detect_mime_from_signature(file_path: str) -> str | None: | |
| """Detect MIME type from file signatures when libmagic is unavailable.""" | |
| with open(file_path, "rb") as handle: | |
| header = handle.read(8) | |
| if header.startswith(b"%PDF-"): | |
| return "application/pdf" | |
| if zipfile.is_zipfile(file_path): | |
| with zipfile.ZipFile(file_path) as archive: | |
| names = archive.namelist() | |
| if any(name.startswith("word/") for name in names): | |
| return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" | |
| if "mimetype" in names: | |
| with archive.open("mimetype") as mime_file: | |
| if b"epub" in mime_file.read().lower(): | |
| return "application/epub+zip" | |
| if any("META-INF/container.xml" in name for name in names): | |
| return "application/epub+zip" | |
| with open(file_path, "rb") as handle: | |
| sample = handle.read(8192) | |
| try: | |
| sample.decode("utf-8") | |
| return "text/plain" | |
| except UnicodeDecodeError: | |
| return None | |
| def detect_mime_type(file_path: str) -> str: | |
| """Detect MIME type by inspecting file magic bytes. | |
| Args: | |
| file_path: Absolute path to the file. | |
| Returns: | |
| MIME type string (e.g., 'application/pdf'). | |
| """ | |
| try: | |
| import magic | |
| return magic.from_file(file_path, mime=True) | |
| except (ImportError, OSError) as exc: | |
| logger.warning("libmagic unavailable, using signature fallback", error=str(exc)) | |
| mime_type = _detect_mime_from_signature(file_path) | |
| if mime_type is None: | |
| raise UnsupportedFormatError(extension=Path(file_path).suffix.lstrip(".")) from exc | |
| return mime_type | |
| def compute_sha256(file_path: str) -> str: | |
| """Compute SHA-256 hash of a file. | |
| Args: | |
| file_path: Absolute path to the file. | |
| Returns: | |
| Hex string of the SHA-256 hash (64 characters). | |
| """ | |
| sha256 = hashlib.sha256() | |
| with open(file_path, "rb") as f: | |
| for chunk in iter(lambda: f.read(65536), b""): | |
| sha256.update(chunk) | |
| return sha256.hexdigest() | |
| def get_file_extension_from_mime(mime_type: str) -> str | None: | |
| """Map a MIME type to our extension label. | |
| Args: | |
| mime_type: MIME type string. | |
| Returns: | |
| Extension label ('pdf', 'epub', 'docx', 'txt') or None if unsupported. | |
| """ | |
| if mime_type == "application/zip": | |
| return None | |
| return _SUPPORTED_MIME_TYPES.get(mime_type) | |
| def validate_upload(file_path: str, claimed_size_bytes: int) -> str: | |
| """Run all pre-processing validation checks on an uploaded file. | |
| Checks: existence, empty, size limit, MIME type (by magic bytes). | |
| Args: | |
| file_path: Absolute path to the uploaded file. | |
| claimed_size_bytes: File size in bytes (from client or OS stat). | |
| Returns: | |
| Detected extension label ('pdf', 'epub', 'docx', 'txt'). | |
| Raises: | |
| EmptyFileError: If file has no content. | |
| FileTooLargeError: If file exceeds size limit. | |
| UnsupportedFormatError: If MIME type is not supported. | |
| """ | |
| # Empty file check | |
| actual_size = os.path.getsize(file_path) | |
| if actual_size == 0: | |
| raise EmptyFileError() | |
| # Size limit check | |
| max_bytes = cfg.UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024 | |
| if actual_size > max_bytes: | |
| size_mb = actual_size / (1024 * 1024) | |
| raise FileTooLargeError(size_mb=size_mb, max_mb=cfg.UPLOAD_MAX_FILE_SIZE_MB) | |
| # MIME type check by magic bytes | |
| mime_type = detect_mime_type(file_path) | |
| extension = get_file_extension_from_mime(mime_type) | |
| if extension is None: | |
| detected_ext = Path(file_path).suffix.lstrip(".") | |
| raise UnsupportedFormatError(extension=detected_ext or mime_type) | |
| logger.debug("File validation passed", path=file_path, mime=mime_type, size_mb=actual_size / 1024 / 1024) | |
| return extension | |
| def ensure_directory(path: str) -> None: | |
| """Create a directory (and all parents) if it doesn't exist. | |
| Args: | |
| path: Directory path to create. | |
| """ | |
| Path(path).mkdir(parents=True, exist_ok=True) | |
| def get_author_upload_path(author_id: str) -> str: | |
| """Get the upload directory path for a specific author. | |
| Args: | |
| author_id: UUID of the author. | |
| Returns: | |
| Absolute directory path string. | |
| """ | |
| base = cfg.UPLOAD_STORAGE_PATH | |
| path = os.path.join(base, "authors", author_id) | |
| ensure_directory(path) | |
| return path | |