"""Image storage utilities for managing image files and directory structure.""" import re import uuid from pathlib import Path from typing import List, Optional from urllib.parse import quote # Root directory for all images IMAGES_ROOT = Path("images") def sanitize_filename(filename: str) -> str: """ Sanitize a filename by removing unsafe characters and limiting length. Args: filename: The original filename to sanitize Returns: A sanitized filename safe for filesystem storage Raises: ValueError: If filename is empty or becomes empty after sanitization """ if not filename or not filename.strip(): raise ValueError("Filename cannot be empty") # Remove path traversal attempts (/, \, ..) sanitized = re.sub(r'[/\\]', '', filename) sanitized = sanitized.replace('..', '') # Remove null bytes and control characters sanitized = re.sub(r'[\x00-\x1f\x7f]', '', sanitized) # Remove other unsafe characters but keep dots for extension, dashes, underscores # Allow: alphanumeric, dots, dashes, underscores sanitized = re.sub(r'[^a-zA-Z0-9._\-]', '', sanitized) # Convert to lowercase sanitized = sanitized.lower() # Check if filename is now empty or only dots if not sanitized or re.match(r'^\.+$', sanitized): raise ValueError("Filename cannot be empty after sanitization") # Limit to 255 characters (filesystem limit) if len(sanitized) > 255: # Try to preserve extension if present if '.' in sanitized: parts = sanitized.rsplit('.', 1) name_part = parts[0][:251] # Leave room for .ext ext_part = parts[1] sanitized = f"{name_part}.{ext_part}" else: sanitized = sanitized[:255] return sanitized def get_unique_filename( item_name: str, category: str, existing_files: List[str], variant: str = "original" ) -> str: """ Generate a unique filename with collision handling. If a file with the sanitized name already exists, appends a UUID suffix. Format: {name}_{uuid_first_8}_{variant}.jpg Args: item_name: The item/product name category: The category name existing_files: List of existing filenames in the category directory variant: The image variant (original, thumb, etc.) Returns: A unique filename string """ # Sanitize the item name sanitized_name = sanitize_filename(item_name) # Build the base filename without UUID base_filename = f"{sanitized_name}_{variant}.jpg" # Check for collision (case-insensitive) existing_lower = [f.lower() for f in existing_files] if base_filename.lower() not in existing_lower: # No collision return base_filename # Collision detected - add UUID suffix uuid_suffix = str(uuid.uuid4()).replace('-', '')[:8] unique_filename = f"{sanitized_name}_{uuid_suffix}_{variant}.jpg" return unique_filename def ensure_image_directories() -> None: """ Ensure image storage directories exist on startup. Creates /images/ root and category-specific subdirectories. """ # Create root images directory IMAGES_ROOT.mkdir(parents=True, exist_ok=True) # Create category subdirectories try: categories = get_categories() for category in categories: cat_dir = IMAGES_ROOT / category cat_dir.mkdir(parents=True, exist_ok=True) except Exception: # If get_categories fails (e.g., DB not ready), just create root # Categories will be created on-demand in save_image pass def save_image( file_bytes: bytes, category: str, filename_base: str, variant: str = "original" ) -> str: """ Save an image file to the storage directory. Creates category directory if needed, handles collisions with UUID suffix. Args: file_bytes: The image file content as bytes category: The category name (e.g., "networking") filename_base: The base filename without extension (e.g., "SFP-LR") variant: The image variant (original, thumb, etc.) Returns: The relative path to the saved image (e.g., "/images/networking/sfp-lr_original.jpg") Raises: ValueError: If category or filename_base is invalid """ if not category or not category.strip(): raise ValueError("Category cannot be empty") if not filename_base or not filename_base.strip(): raise ValueError("Filename base cannot be empty") # Sanitize category sanitized_category = sanitize_filename(category) # Create category directory cat_dir = IMAGES_ROOT / sanitized_category cat_dir.mkdir(parents=True, exist_ok=True) # Get existing files in the category existing_files = [f.name for f in cat_dir.iterdir() if f.is_file()] # Get unique filename unique_filename = get_unique_filename(filename_base, sanitized_category, existing_files, variant) # Write file file_path = cat_dir / unique_filename file_path.write_bytes(file_bytes) # Return relative path with forward slashes relative_path = f"/images/{sanitized_category}/{unique_filename}" return relative_path def get_categories() -> List[str]: """ Get list of categories from the database. This is a stub that will be called from ensure_image_directories. In production, this should query the database for categories. Returns: List of category names """ # This will be implemented to query the database # For now, return empty list (handled in ensure_image_directories) return []