Spaces:
Runtime error
Runtime error
Download app/utils/file_utils.py from rahim237cm/jurix-api: direct link, hf CLI and curl.
- Browser
- Download file 11.7 kB
-
https://huggingface.co/spaces/rahim237cm/jurix-api/resolve/main/app/utils/file_utils.py
- Command line
-
hf download hf://spaces/rahim237cm/jurix-api/app/utils/file_utils.py
-
curl -L -o file_utils.py https://huggingface.co/spaces/rahim237cm/jurix-api/resolve/main/app/utils/file_utils.py
11.7 kB
| """ | |
| Utility functions for file operations. | |
| Provides helpers for: | |
| - File hashing (SHA-256) | |
| - File type detection (magic bytes) | |
| - File validation (PDF, DOCX structure) | |
| - Filename generation (UUID-based) | |
| - Storage management | |
| Author: JuriX Development Team | |
| Date: 2026-01-11 | |
| """ | |
| import hashlib | |
| import logging | |
| import re | |
| import uuid | |
| from pathlib import Path | |
| from typing import Optional, Tuple | |
| logger = logging.getLogger(__name__) | |
| # File type magic bytes signatures | |
| MAGIC_BYTES = { | |
| "pdf": b"%PDF", | |
| "docx": b"PK\x03\x04", # ZIP signature (DOCX is a ZIP archive) | |
| } | |
| # MIME types | |
| MIME_TYPES = { | |
| "pdf": "application/pdf", | |
| "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", | |
| } | |
| def get_file_hash(file_path: Path, algorithm: str = "sha256") -> str: | |
| """ | |
| Calculate hash of a file. | |
| Args: | |
| file_path: Path to file | |
| algorithm: Hash algorithm (default: sha256) | |
| Returns: | |
| Hex digest of file hash | |
| Raises: | |
| FileNotFoundError: If file doesn't exist | |
| ValueError: If algorithm not supported | |
| Example: | |
| >>> hash_value = get_file_hash(Path("document.pdf")) | |
| >>> len(hash_value) | |
| 64 | |
| """ | |
| if not file_path.exists(): | |
| raise FileNotFoundError(f"File not found: {file_path}") | |
| if algorithm not in hashlib.algorithms_available: | |
| raise ValueError(f"Hash algorithm '{algorithm}' not supported") | |
| hash_obj = hashlib.new(algorithm) | |
| # Read file in chunks to handle large files | |
| with open(file_path, "rb") as f: | |
| for chunk in iter(lambda: f.read(8192), b""): | |
| hash_obj.update(chunk) | |
| return hash_obj.hexdigest() | |
| def get_magic_bytes(file_path: Path, num_bytes: int = 4) -> bytes: | |
| """ | |
| Read the first N bytes of a file (magic bytes). | |
| Args: | |
| file_path: Path to file | |
| num_bytes: Number of bytes to read (default: 4) | |
| Returns: | |
| First N bytes of file | |
| Raises: | |
| FileNotFoundError: If file doesn't exist | |
| Example: | |
| >>> magic = get_magic_bytes(Path("document.pdf")) | |
| >>> magic | |
| b'%PDF' | |
| """ | |
| if not file_path.exists(): | |
| raise FileNotFoundError(f"File not found: {file_path}") | |
| with open(file_path, "rb") as f: | |
| return f.read(num_bytes) | |
| def detect_file_type(file_path: Path) -> Optional[str]: | |
| """ | |
| Detect file type from magic bytes. | |
| Args: | |
| file_path: Path to file | |
| Returns: | |
| File type ('pdf' or 'docx') or None if unknown | |
| Example: | |
| >>> detect_file_type(Path("document.pdf")) | |
| 'pdf' | |
| """ | |
| try: | |
| magic = get_magic_bytes(file_path, num_bytes=4) | |
| for file_type, signature in MAGIC_BYTES.items(): | |
| if magic.startswith(signature): | |
| return file_type | |
| return None | |
| except Exception as e: | |
| logger.warning(f"Failed to detect file type for {file_path}: {e}") | |
| return None | |
| def is_valid_pdf(file_path: Path) -> Tuple[bool, Optional[str]]: | |
| """ | |
| Validate PDF file structure. | |
| Checks: | |
| - Magic bytes (%PDF) | |
| - File ends with %%EOF | |
| - Minimum file size | |
| Args: | |
| file_path: Path to PDF file | |
| Returns: | |
| Tuple of (is_valid, error_message) | |
| Example: | |
| >>> is_valid, error = is_valid_pdf(Path("document.pdf")) | |
| >>> is_valid | |
| True | |
| """ | |
| try: | |
| # Check file exists and has minimum size | |
| if not file_path.exists(): | |
| return False, "File does not exist" | |
| file_size = file_path.stat().st_size | |
| if file_size < 100: # Minimum PDF size | |
| return False, "File too small to be a valid PDF" | |
| # Check magic bytes | |
| magic = get_magic_bytes(file_path, num_bytes=4) | |
| if not magic.startswith(b"%PDF"): | |
| return False, "Invalid PDF magic bytes" | |
| # Check EOF marker (read last 1024 bytes) | |
| with open(file_path, "rb") as f: | |
| f.seek(max(0, file_size - 1024)) | |
| tail = f.read() | |
| if b"%%EOF" not in tail: | |
| return False, "Missing PDF EOF marker" | |
| return True, None | |
| except Exception as e: | |
| return False, f"PDF validation error: {str(e)}" | |
| def is_valid_docx(file_path: Path) -> Tuple[bool, Optional[str]]: | |
| """ | |
| Validate DOCX file structure. | |
| Checks: | |
| - Magic bytes (PK - ZIP signature) | |
| - Contains required DOCX files (word/document.xml) | |
| - Minimum file size | |
| Args: | |
| file_path: Path to DOCX file | |
| Returns: | |
| Tuple of (is_valid, error_message) | |
| Example: | |
| >>> is_valid, error = is_valid_docx(Path("document.docx")) | |
| >>> is_valid | |
| True | |
| """ | |
| try: | |
| # Check file exists and has minimum size | |
| if not file_path.exists(): | |
| return False, "File does not exist" | |
| file_size = file_path.stat().st_size | |
| if file_size < 1000: # Minimum DOCX size | |
| return False, "File too small to be a valid DOCX" | |
| # Check magic bytes (ZIP signature) | |
| magic = get_magic_bytes(file_path, num_bytes=4) | |
| if not magic.startswith(b"PK\x03\x04"): | |
| return False, "Invalid DOCX magic bytes (not a ZIP file)" | |
| # Try to open as ZIP and check for required DOCX structure | |
| import zipfile | |
| if not zipfile.is_zipfile(file_path): | |
| return False, "File is not a valid ZIP archive" | |
| with zipfile.ZipFile(file_path, "r") as zip_file: | |
| # Check for required DOCX files | |
| required_files = ["[Content_Types].xml", "word/document.xml"] | |
| zip_contents = zip_file.namelist() | |
| for required_file in required_files: | |
| if required_file not in zip_contents: | |
| return False, f"Missing required DOCX file: {required_file}" | |
| return True, None | |
| except zipfile.BadZipFile: | |
| return False, "Corrupted ZIP/DOCX file" | |
| except Exception as e: | |
| return False, f"DOCX validation error: {str(e)}" | |
| def generate_unique_filename(original_filename: str, preserve_extension: bool = True) -> str: | |
| """ | |
| Generate a unique filename using UUID. | |
| Args: | |
| original_filename: Original filename | |
| preserve_extension: Whether to keep original extension (default: True) | |
| Returns: | |
| Unique filename (UUID-based) | |
| Example: | |
| >>> filename = generate_unique_filename("document.pdf") | |
| >>> filename.endswith(".pdf") | |
| True | |
| >>> len(filename.split(".")[0]) | |
| 36 # UUID length | |
| """ | |
| unique_id = str(uuid.uuid4()) | |
| if preserve_extension: | |
| # Extract extension from original filename | |
| extension = Path(original_filename).suffix | |
| return f"{unique_id}{extension}" | |
| else: | |
| return unique_id | |
| def ensure_storage_directory(path: Path) -> None: | |
| """ | |
| Ensure storage directory exists, create if necessary. | |
| Args: | |
| path: Directory path | |
| Raises: | |
| PermissionError: If directory cannot be created | |
| OSError: If path exists but is not a directory | |
| Example: | |
| >>> ensure_storage_directory(Path("/tmp/uploads")) | |
| """ | |
| if path.exists(): | |
| if not path.is_dir(): | |
| raise OSError(f"Path exists but is not a directory: {path}") | |
| logger.debug(f"Storage directory already exists: {path}") | |
| else: | |
| path.mkdir(parents=True, exist_ok=True) | |
| logger.info(f"Created storage directory: {path}") | |
| def get_file_size_mb(file_path: Path) -> float: | |
| """ | |
| Get file size in megabytes. | |
| Args: | |
| file_path: Path to file | |
| Returns: | |
| File size in MB (rounded to 2 decimals) | |
| Example: | |
| >>> size_mb = get_file_size_mb(Path("document.pdf")) | |
| >>> size_mb | |
| 2.5 | |
| """ | |
| if not file_path.exists(): | |
| raise FileNotFoundError(f"File not found: {file_path}") | |
| size_bytes = file_path.stat().st_size | |
| size_mb = size_bytes / (1024 * 1024) | |
| return round(size_mb, 2) | |
| def is_file_size_valid(file_path: Path, max_size_mb: int = 50) -> Tuple[bool, Optional[str]]: | |
| """ | |
| Check if file size is within allowed limit. | |
| Args: | |
| file_path: Path to file | |
| max_size_mb: Maximum allowed size in MB (default: 50) | |
| Returns: | |
| Tuple of (is_valid, error_message) | |
| Example: | |
| >>> is_valid, error = is_file_size_valid(Path("document.pdf"), max_size_mb=50) | |
| >>> is_valid | |
| True | |
| """ | |
| try: | |
| # Les DEUX comparaisons portent sur les octets, jamais sur les Mo. | |
| # get_file_size_mb arrondit a deux decimales : tout fichier de moins de | |
| # ~5 Ko donnait 0.0 et etait rejete comme "File is empty (0 bytes)", | |
| # message faux qui refusait des PDF et DOCX parfaitement valides — un | |
| # decret d'une page pese souvent moins que ca. Symetriquement, un | |
| # fichier de 50,004 Mo s'arrondissait a 50.0 et passait sous la limite. | |
| size_bytes = file_path.stat().st_size | |
| size_mb = round(size_bytes / (1024 * 1024), 2) | |
| if size_bytes > max_size_mb * 1024 * 1024: | |
| return False, f"File size {size_mb} MB exceeds maximum {max_size_mb} MB" | |
| if size_bytes == 0: | |
| return False, "File is empty (0 bytes)" | |
| return True, None | |
| except Exception as e: | |
| return False, f"Size validation error: {str(e)}" | |
| def get_mime_type(file_type: str) -> str: | |
| """ | |
| Get MIME type for file type. | |
| Args: | |
| file_type: File type ('pdf' or 'docx') | |
| Returns: | |
| MIME type string | |
| Raises: | |
| ValueError: If file type not supported | |
| Example: | |
| >>> get_mime_type("pdf") | |
| 'application/pdf' | |
| """ | |
| if file_type not in MIME_TYPES: | |
| raise ValueError(f"Unsupported file type: {file_type}") | |
| return MIME_TYPES[file_type] | |
| # ==================== RESOLUTION SECURISEE DES FICHIERS ==================== | |
| _FILE_ID_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$") | |
| def resolve_upload_path(file_id: str) -> Path: | |
| """ | |
| Résout un identifiant de fichier en chemin, à l'intérieur du répertoire d'upload. | |
| Motivation : `/api/v1/ocr/detect` et `/api/v1/ocr/process` acceptaient un | |
| `pdf_path` arbitraire — un chemin du système de fichiers du serveur — et se | |
| contentaient d'un `.exists()`. N'importe qui pouvait donc énumérer les | |
| fichiers de la machine (404 contre 500) et se faire renvoyer le texte de tout | |
| document lisible par Tesseract. L'authentification seule ne suffit pas : un | |
| jeton d'administration volé rouvrirait la faille. | |
| Trois barrières indépendantes : | |
| 1. le motif rejette `..`, `/`, `%2f`, les chemins absolus et les octets nuls | |
| AVANT tout accès au disque ; | |
| 2. `resolve()` + `is_relative_to` bloquent les évasions par lien symbolique ; | |
| 3. `is_file()` écarte les répertoires et les fichiers spéciaux. | |
| Args: | |
| file_id: Identifiant du fichier, sans extension | |
| Returns: | |
| Chemin absolu du fichier | |
| Raises: | |
| ValueError: identifiant invalide ou chemin sortant du répertoire | |
| FileNotFoundError: aucun fichier correspondant | |
| """ | |
| if not _FILE_ID_RE.match(file_id or ""): | |
| raise ValueError(f"Identifiant de fichier invalide : {file_id!r}") | |
| from app.services.file_upload_service import get_upload_service | |
| root = get_upload_service().storage_path.resolve() | |
| for extension in (".pdf", ".docx"): | |
| candidate = (root / f"{file_id}{extension}").resolve() | |
| if not candidate.is_relative_to(root): | |
| raise ValueError("Le chemin sort du répertoire d'upload") | |
| if candidate.is_file(): | |
| return candidate | |
| raise FileNotFoundError(file_id) | |