# Media Upload Implementation Guide ## Overview This guide provides implementation details for the v1.2.0 media upload feature based on the finalized design. ## Key Design Decisions ### Image Limits (per ADR-058) - **Max file size**: 10MB per image (reject before processing) - **Max dimensions**: 4096x4096 pixels (reject if larger) - **Auto-resize threshold**: 2048 pixels on longest edge - **Max images per note**: 4 - **Accepted formats**: JPEG, PNG, GIF, WebP only ### Features - **Caption support**: Each image has optional caption field - **No reordering**: Display order matches upload order - **Auto-optimization**: Images >2048px automatically resized - **EXIF correction**: Orientation fixed during processing ## Implementation Approach ### 1. Dependencies Add to `pyproject.toml`: ```toml dependencies = [ # ... existing dependencies "Pillow>=10.0.0", # Image processing ] ``` ### 2. Image Processing Module Structure Create `starpunk/media.py`: ```python from PIL import Image, ImageOps import hashlib import os from pathlib import Path from datetime import datetime class MediaProcessor: MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB MAX_DIMENSIONS = 4096 RESIZE_THRESHOLD = 2048 ALLOWED_MIMES = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp' } def validate_file_size(self, file_obj): """Check file size before processing.""" file_obj.seek(0, os.SEEK_END) size = file_obj.tell() file_obj.seek(0) if size > self.MAX_FILE_SIZE: raise ValueError(f"File too large: {size} bytes (max {self.MAX_FILE_SIZE})") return size def optimize_image(self, file_obj): """Optimize image for web display.""" # Open and validate try: img = Image.open(file_obj) except Exception as e: raise ValueError(f"Invalid or corrupted image: {e}") # Correct EXIF orientation img = ImageOps.exif_transpose(img) # Check dimensions width, height = img.size if max(width, height) > self.MAX_DIMENSIONS: raise ValueError(f"Image too large: {width}x{height} (max {self.MAX_DIMENSIONS})") # Resize if needed if max(width, height) > self.RESIZE_THRESHOLD: img.thumbnail((self.RESIZE_THRESHOLD, self.RESIZE_THRESHOLD), Image.Resampling.LANCZOS) return img def generate_filename(self, original_name, content): """Generate unique filename with date path.""" # Create hash for uniqueness hash_obj = hashlib.sha256(content) hash_hex = hash_obj.hexdigest()[:8] # Get extension _, ext = os.path.splitext(original_name) # Generate date-based path now = datetime.now() year = now.strftime('%Y') month = now.strftime('%m') # Create filename filename = f"{now.strftime('%Y%m%d')}-{hash_hex}{ext}" return f"{year}/{month}/{filename}" ``` ### 3. Database Migration Create migration for media tables: ```sql -- Create media table CREATE TABLE IF NOT EXISTS media ( id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT NOT NULL, original_name TEXT NOT NULL, path TEXT NOT NULL UNIQUE, mime_type TEXT NOT NULL, size INTEGER NOT NULL, width INTEGER, height INTEGER, uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Create note_media junction table with caption support CREATE TABLE IF NOT EXISTS note_media ( id INTEGER PRIMARY KEY AUTOINCREMENT, note_id INTEGER NOT NULL, media_id INTEGER NOT NULL, display_order INTEGER NOT NULL DEFAULT 0, caption TEXT, -- Optional caption for accessibility created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE, FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE, UNIQUE(note_id, media_id) ); -- Create indexes CREATE INDEX idx_note_media_note ON note_media(note_id); CREATE INDEX idx_note_media_order ON note_media(note_id, display_order); ``` ### 4. Upload Endpoint In `starpunk/routes/admin.py`: ```python @bp.route('/admin/upload', methods=['POST']) @require_auth def upload_media(): """Handle AJAX media upload.""" if 'file' not in request.files: return jsonify({'error': 'No file provided'}), 400 file = request.files['file'] try: # Process with MediaProcessor processor = MediaProcessor() # Validate size first (before loading image) size = processor.validate_file_size(file.file) # Optimize image optimized = processor.optimize_image(file.file) # Generate path path = processor.generate_filename(file.filename, file.read()) # Save to disk save_path = Path(app.config['MEDIA_PATH']) / path save_path.parent.mkdir(parents=True, exist_ok=True) optimized.save(save_path, quality=95, optimize=True) # Save to database media_id = save_media_metadata( filename=path.name, original_name=file.filename, path=path, mime_type=file.content_type, size=save_path.stat().st_size, width=optimized.width, height=optimized.height ) # Return success return jsonify({ 'success': True, 'media_id': media_id, 'url': f'/media/{path}' }) except ValueError as e: return jsonify({'error': str(e)}), 400 except Exception as e: app.logger.error(f"Upload failed: {e}") return jsonify({'error': 'Upload failed'}), 500 ``` ### 5. Template Updates Update note creation/edit forms to include: - Multiple file input with accept attribute - Caption fields for each uploaded image - Client-side preview with caption inputs - Remove button for each image - Hidden fields to track attached media IDs ### 6. Display Implementation When rendering notes: 1. Query `note_media` JOIN `media` ordered by `display_order` 2. Display images at top of note 3. Use captions as alt text 4. Apply responsive grid layout CSS ## Testing Checklist ### Unit Tests - [ ] File size validation (reject >10MB) - [ ] Dimension validation (reject >4096px) - [ ] MIME type validation (accept only JPEG/PNG/GIF/WebP) - [ ] Image resize logic (>2048px gets resized) - [ ] Filename generation (unique, date-based) - [ ] EXIF orientation correction ### Integration Tests - [ ] Upload single image - [ ] Upload multiple images (up to 4) - [ ] Reject 5th image - [ ] Upload with captions - [ ] Delete uploaded image - [ ] Edit note with existing media - [ ] Corrupted file handling - [ ] Oversized file handling ### Manual Testing - [ ] Upload from phone camera - [ ] Upload screenshots - [ ] Test all supported formats - [ ] Verify captions appear as alt text - [ ] Check responsive layouts (1-4 images) - [ ] Verify images in RSS/ATOM/JSON feeds ## Error Messages Provide clear, actionable error messages: - "File too large. Maximum size is 10MB" - "Image dimensions too large. Maximum is 4096x4096 pixels" - "Invalid image format. Accepted: JPEG, PNG, GIF, WebP" - "Maximum 4 images per note" - "Image appears to be corrupted" ## Performance Considerations - Process images synchronously (single-user CMS) - Use quality=95 for good balance of size/quality - Consider lazy loading for feed pages - Cache resized images (future enhancement) ## Security Notes - Always validate MIME type server-side - Use Pillow to verify file integrity - Sanitize filenames before saving - Prevent directory traversal in media paths - Strip EXIF data that might contain GPS/personal info ## Future Enhancements (NOT in v1.2.0) - Micropub media endpoint support - Video upload support - Separate thumbnail generation - CDN integration - Bulk upload interface - Image editing tools (crop, rotate)