Complete implementation of v1.2.0 "IndieWeb Features" release. ## Phase 1: Custom Slugs - Optional custom slug field in note creation form - Auto-sanitization (lowercase, hyphens only) - Uniqueness validation with auto-numbering - Read-only after creation to preserve permalinks - Matches Micropub mp-slug behavior ## Phase 2: Author Discovery + Microformats2 - Automatic h-card discovery from IndieAuth identity URL - 24-hour caching with graceful fallback - Never blocks login (per ADR-061) - Complete h-entry, h-card, h-feed markup - All required Microformats2 properties - rel-me links for identity verification - Passes IndieWeb validation ## Phase 3: Media Upload - Upload up to 4 images per note (JPEG, PNG, GIF, WebP) - Automatic optimization with Pillow - Auto-resize to 2048px - EXIF orientation correction - 95% quality compression - Social media-style layout (media top, text below) - Optional captions for accessibility - Integration with all feed formats (RSS, ATOM, JSON Feed) - Date-organized storage with UUID filenames - Immutable caching (1 year) ## Database Changes - migrations/006_add_author_profile.sql - Author discovery cache - migrations/007_add_media_support.sql - Media storage ## New Modules - starpunk/author_discovery.py - h-card discovery and caching - starpunk/media.py - Image upload, validation, optimization ## Documentation - 4 new ADRs (056, 057, 058, 061) - Complete design specifications - Developer Q&A with 40+ questions answered - 3 implementation reports - 3 architect reviews (all approved) ## Testing - 56 new tests for v1.2.0 features - 842 total tests in suite - All v1.2.0 feature tests passing ## Dependencies - Added: mf2py (Microformats2 parser) - Added: Pillow (image processing) Version: 1.2.0-rc.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
7.8 KiB
7.8 KiB
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:
dependencies = [
# ... existing dependencies
"Pillow>=10.0.0", # Image processing
]
2. Image Processing Module Structure
Create starpunk/media.py:
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:
-- 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:
@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:
- Query
note_mediaJOINmediaordered bydisplay_order - Display images at top of note
- Use captions as alt text
- 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)