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>
269 lines
7.8 KiB
Markdown
269 lines
7.8 KiB
Markdown
# 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) |