Implement W3C Micropub media endpoint for external client uploads.
Changes:
- Add POST /micropub/media endpoint in routes/micropub.py
- Accept multipart/form-data with 'file' field
- Require bearer token with 'create' scope
- Return 201 Created with Location header
- Validate, optimize, and generate variants via save_media()
- Update q=config response to advertise media-endpoint
- Include media-endpoint URL in config response
- Add 'photo' post-type to supported types
- Add photo property support to Micropub create
- extract_photos() function to parse photo property
- Handles both simple URL strings and structured objects with alt text
- _attach_photos_to_note() function to attach photos by URL
- Only attach photos from our server (by URL match)
- External URLs logged but ignored (no download)
- Maximum 4 photos per note (per ADR-057)
- SITE_URL normalization pattern
- Use .rstrip('/') for consistent URL comparison
- Applied in media endpoint and photo attachment
Per design document: docs/design/v1.4.0/media-implementation-design.md
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
1659 lines
52 KiB
Markdown
1659 lines
52 KiB
Markdown
# v1.4.0 "Media" Release - Implementation Design
|
|
|
|
**Version**: 1.4.0
|
|
**Status**: Design Complete
|
|
**Author**: StarPunk Architecture
|
|
**Date**: 2025-12-10
|
|
|
|
---
|
|
|
|
## Revision History
|
|
|
|
| Date | Version | Changes |
|
|
|------|---------|---------|
|
|
| 2025-12-10 | 1.0 | Initial design document |
|
|
| 2025-12-10 | 1.1 | Post-Q&A corrections: validate_image() signature unchanged; file_size computed in save_media(); animated GIF handling; simplified variant path calculation; optimized_bytes passed to variants; file cleanup on variant failure; backwards-compatible variants key; make_response import; photo truncation warning; isDefault fallback logic; configurable about URL; test image noise generation; SITE_URL normalization pattern |
|
|
|
|
## Executive Summary
|
|
|
|
This document provides the complete implementation design for v1.4.0 "Media", which adds three major features:
|
|
|
|
1. **Micropub Media Endpoint** - W3C-compliant media upload via `/micropub/media`
|
|
2. **Large Image Support** - Accept and resize images up to 50MB
|
|
3. **Enhanced Feed Media** - Multiple image sizes with complete Media RSS implementation
|
|
|
|
**Total Estimated Effort**: 28-40 hours
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Confirmed Decisions](#confirmed-decisions)
|
|
2. [Phase 1: Large Image Support](#phase-1-large-image-support)
|
|
3. [Phase 2: Image Variants](#phase-2-image-variants)
|
|
4. [Phase 3: Micropub Media Endpoint](#phase-3-micropub-media-endpoint)
|
|
5. [Phase 4: Enhanced Feed Media](#phase-4-enhanced-feed-media)
|
|
6. [Phase 5: Testing & Documentation](#phase-5-testing--documentation)
|
|
7. [Database Schema](#database-schema)
|
|
8. [API Specifications](#api-specifications)
|
|
9. [File Modifications Summary](#file-modifications-summary)
|
|
10. [Test Requirements](#test-requirements)
|
|
11. [Developer Q&A](#developer-qa)
|
|
|
|
---
|
|
|
|
## Confirmed Decisions
|
|
|
|
The following decisions have been confirmed during the design phase:
|
|
|
|
| Decision | Outcome |
|
|
|----------|---------|
|
|
| Scope flexibility | NOT locked - defer to large image support only if necessary |
|
|
| Token scope | No new scope - existing `create` tokens work for media uploads |
|
|
| Unused upload retention | Delete unused media after 24 hours |
|
|
| Photo property URLs | Accept URL values directly without downloading |
|
|
| Quality edge case | Reject if still >10MB after optimization |
|
|
| EXIF dimensions | Nice to have (not required) |
|
|
| Variant timing | Synchronous/eager generation on upload |
|
|
| Existing media | Only new uploads get variants |
|
|
| Thumbnail cropping | Center crop using `Pillow.ImageOps.fit()` |
|
|
| `media:group` usage | Use for size variants of same image only |
|
|
| JSON Feed extension | `_starpunk` namespace with `about` URL |
|
|
|
|
---
|
|
|
|
## Phase 1: Large Image Support
|
|
|
|
**Estimated Effort**: 4-6 hours
|
|
|
|
### Overview
|
|
|
|
Remove the 10MB file size rejection and implement tiered resize strategy for large images.
|
|
|
|
### Current Behavior (v1.2.0)
|
|
|
|
- Files >10MB rejected with error
|
|
- Files <=10MB accepted and resized if >2048px
|
|
|
|
### New Behavior (v1.4.0)
|
|
|
|
- Files up to 50MB accepted
|
|
- Tiered resize strategy based on input size
|
|
- Final output always <=10MB after optimization
|
|
- Reject if optimization cannot achieve target
|
|
|
|
### Tiered Resize Strategy
|
|
|
|
| Input Size | Max Dimension | Quality | Target Output |
|
|
|------------|---------------|---------|---------------|
|
|
| <=10MB | 2048px | 95% | <=5MB typical |
|
|
| 10-25MB | 1600px | 90% | <=5MB target |
|
|
| 25-50MB | 1280px | 85% | <=5MB target |
|
|
| >50MB | Rejected | - | Error message |
|
|
|
|
### Iterative Quality Reduction
|
|
|
|
If first pass produces >10MB output:
|
|
1. Reduce max dimension by 20%
|
|
2. Reduce quality by 5%
|
|
3. Repeat until <=10MB or min quality (70%) reached
|
|
4. If still >10MB at 70% quality, reject with error
|
|
|
|
### File Modifications
|
|
|
|
#### `/home/phil/Projects/starpunk/starpunk/media.py`
|
|
|
|
**Constants to modify:**
|
|
|
|
```python
|
|
# OLD
|
|
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
|
|
|
# NEW
|
|
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
|
|
MAX_OUTPUT_SIZE = 10 * 1024 * 1024 # 10MB (target after optimization)
|
|
MIN_QUALITY = 70 # Minimum JPEG quality before rejection
|
|
```
|
|
|
|
**New function: `get_optimization_params()`**
|
|
|
|
```python
|
|
def get_optimization_params(file_size: int) -> Tuple[int, int]:
|
|
"""
|
|
Determine optimization parameters based on input file size
|
|
|
|
Args:
|
|
file_size: Original file size in bytes
|
|
|
|
Returns:
|
|
Tuple of (max_dimension, quality_percent)
|
|
"""
|
|
if file_size <= 10 * 1024 * 1024: # <=10MB
|
|
return (2048, 95)
|
|
elif file_size <= 25 * 1024 * 1024: # 10-25MB
|
|
return (1600, 90)
|
|
else: # 25-50MB
|
|
return (1280, 85)
|
|
```
|
|
|
|
**Modified function: `validate_image()`**
|
|
|
|
Changes:
|
|
- Update MAX_FILE_SIZE check to 50MB
|
|
- Add animated GIF detection and specific error message
|
|
- **NOTE**: Return signature remains unchanged (3-tuple). File size is computed in `save_media()`.
|
|
|
|
```python
|
|
def validate_image(file_data: bytes, filename: str) -> Tuple[str, int, int]:
|
|
"""
|
|
Validate image file
|
|
|
|
Returns:
|
|
Tuple of (mime_type, width, height)
|
|
"""
|
|
file_size = len(file_data)
|
|
|
|
# Check file size (new 50MB limit)
|
|
if file_size > MAX_FILE_SIZE:
|
|
raise ValueError("File too large. Maximum size is 50MB")
|
|
|
|
# ... existing validation code ...
|
|
|
|
# Check for animated GIF (reject if >10MB since we can't resize)
|
|
if img.format == 'GIF':
|
|
try:
|
|
img.seek(1)
|
|
# It's animated
|
|
if file_size > MAX_OUTPUT_SIZE:
|
|
raise ValueError(
|
|
"Animated GIF too large. Maximum size for animated GIFs is 10MB. "
|
|
"Consider using a shorter clip or lower resolution."
|
|
)
|
|
img.seek(0)
|
|
except EOFError:
|
|
# Not animated, continue normally
|
|
pass
|
|
|
|
return mime_type, width, height
|
|
```
|
|
|
|
**Modified function: `optimize_image()`**
|
|
|
|
Changes:
|
|
- Accept `original_size` parameter
|
|
- Implement tiered resize strategy
|
|
- Add iterative quality reduction loop
|
|
|
|
```python
|
|
def optimize_image(image_data: bytes, original_size: int = None) -> Tuple[Image.Image, int, int, bytes]:
|
|
"""
|
|
Optimize image for web display with size-aware strategy
|
|
|
|
Args:
|
|
image_data: Raw image bytes
|
|
original_size: Original file size (for tiered optimization)
|
|
|
|
Returns:
|
|
Tuple of (optimized_image, width, height, optimized_bytes)
|
|
|
|
Raises:
|
|
ValueError: If image cannot be optimized to target size
|
|
"""
|
|
if original_size is None:
|
|
original_size = len(image_data)
|
|
|
|
# Get initial optimization parameters
|
|
max_dim, quality = get_optimization_params(original_size)
|
|
|
|
img = Image.open(io.BytesIO(image_data))
|
|
img = ImageOps.exif_transpose(img) if img.format != 'GIF' else img
|
|
|
|
# Iterative optimization loop
|
|
while True:
|
|
# Create copy for this iteration
|
|
work_img = img.copy()
|
|
|
|
# Resize if needed
|
|
if max(work_img.size) > max_dim:
|
|
work_img.thumbnail((max_dim, max_dim), Image.Resampling.LANCZOS)
|
|
|
|
# Save to bytes to check size
|
|
output = io.BytesIO()
|
|
save_format = work_img.format or 'JPEG'
|
|
save_kwargs = {'optimize': True}
|
|
|
|
if save_format in ['JPEG', 'JPG']:
|
|
save_kwargs['quality'] = quality
|
|
elif save_format == 'WEBP':
|
|
save_kwargs['quality'] = quality
|
|
|
|
work_img.save(output, format=save_format, **save_kwargs)
|
|
output_bytes = output.getvalue()
|
|
|
|
# Check output size
|
|
if len(output_bytes) <= MAX_OUTPUT_SIZE:
|
|
width, height = work_img.size
|
|
return work_img, width, height, output_bytes
|
|
|
|
# Need to reduce further
|
|
if quality > MIN_QUALITY:
|
|
# Reduce quality first
|
|
quality -= 5
|
|
else:
|
|
# Already at min quality, reduce dimensions
|
|
max_dim = int(max_dim * 0.8)
|
|
quality = 85 # Reset quality for new dimension
|
|
|
|
# Safety check: minimum dimension
|
|
if max_dim < 640:
|
|
raise ValueError(
|
|
"Image cannot be optimized to target size. "
|
|
"Please use a smaller or lower-resolution image."
|
|
)
|
|
```
|
|
|
|
**Modified function: `save_media()`**
|
|
|
|
Changes:
|
|
- Compute `file_size = len(file_data)` after validation (signature unchanged)
|
|
- Pass original size to `optimize_image()`
|
|
- Use returned bytes directly instead of re-saving
|
|
|
|
```python
|
|
def save_media(file_data: bytes, filename: str) -> Dict:
|
|
"""Save uploaded media file with size-aware optimization"""
|
|
|
|
# Validate image (returns 3-tuple, unchanged signature)
|
|
mime_type, orig_width, orig_height = validate_image(file_data, filename)
|
|
|
|
# Compute file size for optimization strategy
|
|
file_size = len(file_data)
|
|
|
|
# Optimize image with size-aware strategy
|
|
optimized_img, width, height, optimized_bytes = optimize_image(file_data, file_size)
|
|
|
|
# ... generate filename and path ...
|
|
|
|
# Write optimized bytes directly (already saved during optimization)
|
|
full_path.write_bytes(optimized_bytes)
|
|
|
|
# ... database insert and return ...
|
|
```
|
|
|
|
### Error Messages
|
|
|
|
- "File too large. Maximum size is 50MB"
|
|
- "Image cannot be optimized to target size. Please use a smaller or lower-resolution image."
|
|
- "Animated GIF too large. Maximum size for animated GIFs is 10MB. Consider using a shorter clip or lower resolution."
|
|
|
|
---
|
|
|
|
## Phase 2: Image Variants
|
|
|
|
**Estimated Effort**: 8-12 hours
|
|
|
|
### Overview
|
|
|
|
Generate multiple renditions on upload for responsive image delivery and feed optimization.
|
|
|
|
### Variant Specifications
|
|
|
|
| Variant | Dimensions | Method | Use Case |
|
|
|---------|------------|--------|----------|
|
|
| `thumb` | 150x150 (square) | Center crop | Thumbnails, previews |
|
|
| `small` | 320px width | Aspect preserve | Mobile, low bandwidth |
|
|
| `medium` | 640px width | Aspect preserve | Standard display |
|
|
| `large` | 1280px width | Aspect preserve | High-res display |
|
|
| `original` | As uploaded (<=2048px) | From optimization | Full quality |
|
|
|
|
### Storage Structure
|
|
|
|
```
|
|
/data/media/2025/01/
|
|
abc123.jpg # Original/large (from optimization)
|
|
abc123_medium.jpg # 640px width
|
|
abc123_small.jpg # 320px width
|
|
abc123_thumb.jpg # 150x150 center crop
|
|
```
|
|
|
|
### Database Schema
|
|
|
|
**New table: `media_variants`**
|
|
|
|
```sql
|
|
CREATE TABLE media_variants (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
media_id INTEGER NOT NULL,
|
|
variant_type TEXT NOT NULL, -- 'thumb', 'small', 'medium', 'large', 'original'
|
|
path TEXT NOT NULL,
|
|
width INTEGER NOT NULL,
|
|
height INTEGER NOT NULL,
|
|
size_bytes INTEGER NOT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE,
|
|
UNIQUE(media_id, variant_type)
|
|
);
|
|
|
|
CREATE INDEX idx_media_variants_media ON media_variants(media_id);
|
|
```
|
|
|
|
### File Modifications
|
|
|
|
#### `/home/phil/Projects/starpunk/starpunk/media.py`
|
|
|
|
**New constants:**
|
|
|
|
```python
|
|
# Variant specifications
|
|
VARIANT_SPECS = {
|
|
'thumb': {'size': (150, 150), 'crop': True},
|
|
'small': {'width': 320, 'crop': False},
|
|
'medium': {'width': 640, 'crop': False},
|
|
'large': {'width': 1280, 'crop': False},
|
|
}
|
|
```
|
|
|
|
**New function: `generate_variant()`**
|
|
|
|
```python
|
|
def generate_variant(
|
|
img: Image.Image,
|
|
variant_type: str,
|
|
base_path: Path,
|
|
base_filename: str,
|
|
file_ext: str
|
|
) -> Dict:
|
|
"""
|
|
Generate a single image variant
|
|
|
|
Args:
|
|
img: Source PIL Image
|
|
variant_type: One of 'thumb', 'small', 'medium', 'large'
|
|
base_path: Directory to save to
|
|
base_filename: Base filename (UUID without extension)
|
|
file_ext: File extension (e.g., '.jpg')
|
|
|
|
Returns:
|
|
Dict with variant metadata (path, width, height, size_bytes)
|
|
"""
|
|
spec = VARIANT_SPECS[variant_type]
|
|
work_img = img.copy()
|
|
|
|
if spec.get('crop'):
|
|
# Center crop for thumbnails using ImageOps.fit()
|
|
work_img = ImageOps.fit(
|
|
work_img,
|
|
spec['size'],
|
|
method=Image.Resampling.LANCZOS,
|
|
centering=(0.5, 0.5)
|
|
)
|
|
else:
|
|
# Aspect-preserving resize
|
|
target_width = spec['width']
|
|
if work_img.width > target_width:
|
|
ratio = target_width / work_img.width
|
|
new_height = int(work_img.height * ratio)
|
|
work_img = work_img.resize(
|
|
(target_width, new_height),
|
|
Image.Resampling.LANCZOS
|
|
)
|
|
|
|
# Generate variant filename
|
|
variant_filename = f"{base_filename}_{variant_type}{file_ext}"
|
|
variant_path = base_path / variant_filename
|
|
|
|
# Save with appropriate quality
|
|
save_kwargs = {'optimize': True}
|
|
if work_img.format in ['JPEG', 'JPG', None]:
|
|
save_kwargs['quality'] = 85
|
|
|
|
# Determine format from extension
|
|
save_format = 'JPEG' if file_ext.lower() in ['.jpg', '.jpeg'] else file_ext[1:].upper()
|
|
work_img.save(variant_path, format=save_format, **save_kwargs)
|
|
|
|
return {
|
|
'variant_type': variant_type,
|
|
'path': str(variant_path.relative_to(base_path.parent.parent.parent)), # Relative to media root
|
|
'width': work_img.width,
|
|
'height': work_img.height,
|
|
'size_bytes': variant_path.stat().st_size
|
|
}
|
|
```
|
|
|
|
**New function: `generate_all_variants()`**
|
|
|
|
```python
|
|
def generate_all_variants(
|
|
img: Image.Image,
|
|
base_path: Path,
|
|
base_filename: str,
|
|
file_ext: str,
|
|
media_id: int,
|
|
year: str,
|
|
month: str,
|
|
optimized_bytes: bytes
|
|
) -> List[Dict]:
|
|
"""
|
|
Generate all variants for an image and store in database
|
|
|
|
Args:
|
|
img: Source PIL Image (the optimized original)
|
|
base_path: Directory containing the original
|
|
base_filename: Base filename (UUID without extension)
|
|
file_ext: File extension
|
|
media_id: ID of parent media record
|
|
year: Year string (e.g., '2025') for path calculation
|
|
month: Month string (e.g., '01') for path calculation
|
|
optimized_bytes: Bytes of optimized original (avoids re-reading file)
|
|
|
|
Returns:
|
|
List of variant metadata dicts
|
|
"""
|
|
from starpunk.database import get_db
|
|
|
|
variants = []
|
|
db = get_db(current_app)
|
|
created_files = [] # Track files for cleanup on failure
|
|
|
|
try:
|
|
# Generate each variant type
|
|
for variant_type in ['thumb', 'small', 'medium', 'large']:
|
|
# Skip if image is smaller than target
|
|
spec = VARIANT_SPECS[variant_type]
|
|
target_width = spec.get('width') or spec['size'][0]
|
|
|
|
if img.width < target_width and variant_type != 'thumb':
|
|
continue # Skip variants larger than original
|
|
|
|
variant = generate_variant(img, variant_type, base_path, base_filename, file_ext)
|
|
variants.append(variant)
|
|
created_files.append(base_path / f"{base_filename}_{variant_type}{file_ext}")
|
|
|
|
# Insert into database
|
|
db.execute(
|
|
"""
|
|
INSERT INTO media_variants
|
|
(media_id, variant_type, path, width, height, size_bytes)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(media_id, variant['variant_type'], variant['path'],
|
|
variant['width'], variant['height'], variant['size_bytes'])
|
|
)
|
|
|
|
# Also record the original as 'original' variant
|
|
# Use explicit year/month for path calculation (avoids fragile parent traversal)
|
|
original_path = f"{year}/{month}/{base_filename}{file_ext}"
|
|
db.execute(
|
|
"""
|
|
INSERT INTO media_variants
|
|
(media_id, variant_type, path, width, height, size_bytes)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(media_id, 'original', original_path, img.width, img.height,
|
|
len(optimized_bytes)) # Use passed bytes instead of file I/O
|
|
)
|
|
|
|
db.commit()
|
|
return variants
|
|
|
|
except Exception as e:
|
|
# Clean up any created variant files on failure
|
|
for file_path in created_files:
|
|
try:
|
|
if file_path.exists():
|
|
file_path.unlink()
|
|
except OSError:
|
|
pass # Best effort cleanup
|
|
raise # Re-raise the original exception
|
|
```
|
|
|
|
**Modified function: `save_media()`**
|
|
|
|
Add variant generation after saving original:
|
|
|
|
```python
|
|
def save_media(file_data: bytes, filename: str) -> Dict:
|
|
"""Save uploaded media file with variants"""
|
|
|
|
# ... existing validation and optimization ...
|
|
# (optimized_bytes is returned from optimize_image())
|
|
|
|
# Generate path components (year/month already computed for file path)
|
|
year = now.strftime('%Y')
|
|
month = now.strftime('%m')
|
|
|
|
# Save optimized original
|
|
full_path.write_bytes(optimized_bytes)
|
|
|
|
# ... database insert for media table ...
|
|
|
|
# Generate variants (synchronous)
|
|
# Pass year, month, and optimized_bytes to avoid fragile path traversal and file I/O
|
|
base_filename = stored_filename.rsplit('.', 1)[0]
|
|
variants = generate_all_variants(
|
|
optimized_img,
|
|
full_dir,
|
|
base_filename,
|
|
file_ext,
|
|
media_id,
|
|
year,
|
|
month,
|
|
optimized_bytes
|
|
)
|
|
|
|
return {
|
|
'id': media_id,
|
|
# ... existing fields ...
|
|
'variants': variants
|
|
}
|
|
```
|
|
|
|
**Modified function: `get_note_media()`**
|
|
|
|
Include variants in response (only when they exist for backwards compatibility):
|
|
|
|
```python
|
|
def get_note_media(note_id: int) -> List[Dict]:
|
|
"""Get all media attached to a note with variants"""
|
|
|
|
# ... existing query ...
|
|
|
|
media_list = []
|
|
for row in rows:
|
|
media_dict = {
|
|
# ... existing fields ...
|
|
}
|
|
|
|
# Fetch variants for this media
|
|
variants = db.execute(
|
|
"""
|
|
SELECT variant_type, path, width, height, size_bytes
|
|
FROM media_variants
|
|
WHERE media_id = ?
|
|
ORDER BY
|
|
CASE variant_type
|
|
WHEN 'thumb' THEN 1
|
|
WHEN 'small' THEN 2
|
|
WHEN 'medium' THEN 3
|
|
WHEN 'large' THEN 4
|
|
WHEN 'original' THEN 5
|
|
END
|
|
""",
|
|
(row[0],)
|
|
).fetchall()
|
|
|
|
# Only add 'variants' key if variants exist (backwards compatibility)
|
|
# Pre-v1.4.0 media won't have variants, and consumers shouldn't
|
|
# expect the key to be present
|
|
if variants:
|
|
media_dict['variants'] = {
|
|
v[0]: {
|
|
'path': v[1],
|
|
'width': v[2],
|
|
'height': v[3],
|
|
'size_bytes': v[4]
|
|
}
|
|
for v in variants
|
|
}
|
|
|
|
media_list.append(media_dict)
|
|
|
|
return media_list
|
|
```
|
|
|
|
### Migration File
|
|
|
|
**`/home/phil/Projects/starpunk/migrations/009_add_media_variants.sql`**
|
|
|
|
```sql
|
|
-- Migration 009: Add media variants support
|
|
-- Version: 1.4.0 Phase 2
|
|
-- Per ADR-059: Full Feed Media Standardization (Phase A)
|
|
|
|
-- Media variants table for multiple image sizes
|
|
CREATE TABLE IF NOT EXISTS media_variants (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
media_id INTEGER NOT NULL,
|
|
variant_type TEXT NOT NULL, -- 'thumb', 'small', 'medium', 'large', 'original'
|
|
path TEXT NOT NULL,
|
|
width INTEGER NOT NULL,
|
|
height INTEGER NOT NULL,
|
|
size_bytes INTEGER NOT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE,
|
|
UNIQUE(media_id, variant_type)
|
|
);
|
|
|
|
-- Index for efficient variant lookup
|
|
CREATE INDEX IF NOT EXISTS idx_media_variants_media ON media_variants(media_id);
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 3: Micropub Media Endpoint
|
|
|
|
**Estimated Effort**: 6-8 hours
|
|
|
|
### Overview
|
|
|
|
Implement W3C Micropub media endpoint for external client uploads.
|
|
|
|
### Endpoint Specification
|
|
|
|
**Endpoint**: `POST /micropub/media`
|
|
|
|
**Request**:
|
|
- Content-Type: `multipart/form-data`
|
|
- Single file part named `file`
|
|
- Authorization: Bearer token with `create` scope (no new scope needed)
|
|
|
|
**Response**:
|
|
- Success: `201 Created` with `Location` header
|
|
- Error: JSON with `error` and `error_description`
|
|
|
|
### File Modifications
|
|
|
|
#### `/home/phil/Projects/starpunk/starpunk/routes/micropub.py`
|
|
|
|
**New route: `/micropub/media`**
|
|
|
|
```python
|
|
@bp.route('/media', methods=['POST'])
|
|
def media_endpoint():
|
|
"""
|
|
Micropub media endpoint for file uploads
|
|
|
|
W3C Micropub Specification compliant media upload.
|
|
Accepts multipart/form-data with single file part named 'file'.
|
|
|
|
Returns:
|
|
201 Created with Location header on success
|
|
4xx/5xx error responses per OAuth 2.0 format
|
|
"""
|
|
from starpunk.media import save_media, ALLOWED_MIME_TYPES
|
|
|
|
# Extract and verify token
|
|
token = extract_bearer_token(request)
|
|
if not token:
|
|
return error_response("unauthorized", "No access token provided", 401)
|
|
|
|
token_info = verify_external_token(token)
|
|
if not token_info:
|
|
return error_response("unauthorized", "Invalid or expired access token", 401)
|
|
|
|
# Check scope (create scope allows media upload)
|
|
if not check_scope("create", token_info.get("scope", "")):
|
|
return error_response(
|
|
"insufficient_scope",
|
|
"Token lacks create scope",
|
|
403
|
|
)
|
|
|
|
# Validate content type
|
|
content_type = request.headers.get("Content-Type", "")
|
|
if "multipart/form-data" not in content_type:
|
|
return error_response(
|
|
"invalid_request",
|
|
"Content-Type must be multipart/form-data",
|
|
400
|
|
)
|
|
|
|
# Extract file
|
|
if 'file' not in request.files:
|
|
return error_response(
|
|
"invalid_request",
|
|
"No file provided. Use 'file' as the form field name.",
|
|
400
|
|
)
|
|
|
|
uploaded_file = request.files['file']
|
|
|
|
if not uploaded_file.filename:
|
|
return error_response(
|
|
"invalid_request",
|
|
"No filename provided",
|
|
400
|
|
)
|
|
|
|
try:
|
|
# Read file data
|
|
file_data = uploaded_file.read()
|
|
|
|
# Save media (validates, optimizes, generates variants)
|
|
media = save_media(file_data, uploaded_file.filename)
|
|
|
|
# Build media URL
|
|
site_url = current_app.config.get("SITE_URL", "http://localhost:5000")
|
|
media_url = f"{site_url}/media/{media['path']}"
|
|
|
|
# Return 201 with Location header (per W3C Micropub spec)
|
|
response = make_response("", 201)
|
|
response.headers["Location"] = media_url
|
|
return response
|
|
|
|
except ValueError as e:
|
|
# Validation errors (file too large, invalid format, etc.)
|
|
return error_response("invalid_request", str(e), 400)
|
|
|
|
except Exception as e:
|
|
current_app.logger.error(f"Media upload failed: {e}")
|
|
return error_response("server_error", "Failed to process upload", 500)
|
|
```
|
|
|
|
**Import additions at top of file:**
|
|
|
|
```python
|
|
from flask import Blueprint, current_app, request, make_response # NOTE: Add make_response to existing imports
|
|
from starpunk.auth_external import verify_external_token, check_scope
|
|
```
|
|
|
|
#### `/home/phil/Projects/starpunk/starpunk/micropub.py`
|
|
|
|
**Modified function: `handle_query()` - Update q=config response**
|
|
|
|
```python
|
|
def handle_query(args: dict, token_info: dict):
|
|
"""Handle Micropub query endpoints"""
|
|
q = args.get("q")
|
|
|
|
if q == "config":
|
|
# Return server configuration with media endpoint
|
|
site_url = current_app.config.get("SITE_URL", "http://localhost:5000")
|
|
config = {
|
|
"media-endpoint": f"{site_url}/micropub/media", # NEW: Advertise media endpoint
|
|
"syndicate-to": [],
|
|
"post-types": [
|
|
{"type": "note", "name": "Note", "properties": ["content"]},
|
|
{"type": "photo", "name": "Photo", "properties": ["photo"]} # NEW
|
|
],
|
|
}
|
|
return jsonify(config), 200
|
|
|
|
# ... rest of handle_query unchanged ...
|
|
```
|
|
|
|
**New function: `extract_photos()`**
|
|
|
|
```python
|
|
def extract_photos(properties: dict) -> List[Dict[str, str]]:
|
|
"""
|
|
Extract photo URLs and alt text from Micropub properties
|
|
|
|
Handles both simple URL strings and structured photo objects with alt text.
|
|
|
|
Args:
|
|
properties: Normalized Micropub properties dict
|
|
|
|
Returns:
|
|
List of dicts with 'url' and optional 'alt' keys
|
|
|
|
Examples:
|
|
>>> # Simple URL
|
|
>>> extract_photos({'photo': ['https://example.com/photo.jpg']})
|
|
[{'url': 'https://example.com/photo.jpg', 'alt': ''}]
|
|
|
|
>>> # With alt text
|
|
>>> extract_photos({'photo': [{'value': 'https://example.com/photo.jpg', 'alt': 'Sunset'}]})
|
|
[{'url': 'https://example.com/photo.jpg', 'alt': 'Sunset'}]
|
|
"""
|
|
photos = properties.get("photo", [])
|
|
result = []
|
|
|
|
for photo in photos:
|
|
if isinstance(photo, str):
|
|
# Simple URL string
|
|
result.append({'url': photo, 'alt': ''})
|
|
elif isinstance(photo, dict):
|
|
# Structured object with value and alt
|
|
url = photo.get('value') or photo.get('url', '')
|
|
alt = photo.get('alt', '')
|
|
if url:
|
|
result.append({'url': url, 'alt': alt})
|
|
|
|
return result
|
|
```
|
|
|
|
**Modified function: `handle_create()`**
|
|
|
|
Add photo property handling:
|
|
|
|
```python
|
|
def handle_create(data: dict, token_info: dict):
|
|
"""Handle Micropub create action"""
|
|
|
|
# ... existing scope check and property extraction ...
|
|
|
|
# Extract photos (NEW)
|
|
photos = extract_photos(properties)
|
|
|
|
# Create note
|
|
try:
|
|
note = create_note(
|
|
content=content,
|
|
published=True,
|
|
created_at=published_date,
|
|
custom_slug=custom_slug,
|
|
tags=tags if tags else None
|
|
)
|
|
|
|
# Attach photos if present (NEW)
|
|
if photos:
|
|
_attach_photos_to_note(note.id, photos)
|
|
|
|
# ... rest unchanged ...
|
|
```
|
|
|
|
**New function: `_attach_photos_to_note()`**
|
|
|
|
```python
|
|
def _attach_photos_to_note(note_id: int, photos: List[Dict[str, str]]) -> None:
|
|
"""
|
|
Attach photos to a note by URL
|
|
|
|
Photos must already exist on this server (uploaded via media endpoint).
|
|
External URLs are accepted but stored as-is (no download).
|
|
|
|
Args:
|
|
note_id: ID of the note to attach to
|
|
photos: List of dicts with 'url' and 'alt' keys
|
|
"""
|
|
from starpunk.database import get_db
|
|
from starpunk.media import attach_media_to_note
|
|
from urllib.parse import urlparse
|
|
|
|
# Normalize SITE_URL by stripping trailing slash for consistent comparison
|
|
site_url = current_app.config.get("SITE_URL", "http://localhost:5000").rstrip('/')
|
|
db = get_db(current_app)
|
|
|
|
media_ids = []
|
|
captions = []
|
|
|
|
# Log warning if photos are being truncated
|
|
if len(photos) > 4:
|
|
current_app.logger.warning(
|
|
f"Micropub create received {len(photos)} photos, truncating to 4 per ADR-057"
|
|
)
|
|
|
|
for photo in photos[:4]: # Max 4 photos per ADR-057
|
|
url = photo['url']
|
|
alt = photo.get('alt', '')
|
|
|
|
# Check if URL is on our server
|
|
if url.startswith(site_url) or url.startswith('/media/'):
|
|
# Extract path from URL
|
|
if url.startswith(site_url):
|
|
path = url[len(site_url):]
|
|
else:
|
|
path = url
|
|
|
|
# Remove leading /media/ if present
|
|
if path.startswith('/media/'):
|
|
path = path[7:]
|
|
|
|
# Look up media by path
|
|
row = db.execute(
|
|
"SELECT id FROM media WHERE path = ?",
|
|
(path,)
|
|
).fetchone()
|
|
|
|
if row:
|
|
media_ids.append(row[0])
|
|
captions.append(alt)
|
|
else:
|
|
current_app.logger.warning(f"Photo URL not found in media: {url}")
|
|
else:
|
|
# External URL - log but don't fail
|
|
current_app.logger.info(f"External photo URL ignored: {url}")
|
|
|
|
if media_ids:
|
|
attach_media_to_note(note_id, media_ids, captions)
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4: Enhanced Feed Media
|
|
|
|
**Estimated Effort**: 6-8 hours
|
|
|
|
### Overview
|
|
|
|
Implement complete Media RSS specification with multiple image sizes and enhance JSON Feed with variant information.
|
|
|
|
### RSS Feed Enhancements
|
|
|
|
#### `/home/phil/Projects/starpunk/starpunk/feeds/rss.py`
|
|
|
|
**Changes to `generate_rss_streaming()`**:
|
|
|
|
```python
|
|
def generate_rss_streaming(
|
|
site_url: str,
|
|
site_name: str,
|
|
site_description: str,
|
|
notes: list[Note],
|
|
limit: int = 50,
|
|
):
|
|
"""Generate RSS 2.0 with full Media RSS support"""
|
|
|
|
# ... existing header generation ...
|
|
|
|
for note in notes[:limit]:
|
|
# ... existing item generation ...
|
|
|
|
# Enhanced media handling with variants
|
|
if hasattr(note, 'media') and note.media:
|
|
for media_item in note.media:
|
|
variants = media_item.get('variants', {})
|
|
|
|
# Use media:group for multiple sizes of same image
|
|
if variants:
|
|
item_xml += '\n <media:group>'
|
|
|
|
# Determine which variant is the default (largest available)
|
|
# Fallback order: large -> medium -> small
|
|
default_variant = None
|
|
for fallback in ['large', 'medium', 'small']:
|
|
if fallback in variants:
|
|
default_variant = fallback
|
|
break
|
|
|
|
# Add each variant as media:content
|
|
for variant_type in ['large', 'medium', 'small']:
|
|
if variant_type in variants:
|
|
v = variants[variant_type]
|
|
media_url = f"{site_url}/media/{v['path']}"
|
|
is_default = 'true' if variant_type == default_variant else 'false'
|
|
item_xml += f'''
|
|
<media:content url="{_escape_xml(media_url)}"
|
|
type="{media_item.get('mime_type', 'image/jpeg')}"
|
|
medium="image"
|
|
isDefault="{is_default}"
|
|
width="{v['width']}"
|
|
height="{v['height']}"
|
|
fileSize="{v['size_bytes']}"/>'''
|
|
|
|
item_xml += '\n </media:group>'
|
|
|
|
# Add media:thumbnail
|
|
if 'thumb' in variants:
|
|
thumb = variants['thumb']
|
|
thumb_url = f"{site_url}/media/{thumb['path']}"
|
|
item_xml += f'''
|
|
<media:thumbnail url="{_escape_xml(thumb_url)}"
|
|
width="{thumb['width']}"
|
|
height="{thumb['height']}"/>'''
|
|
|
|
# Add media:title for caption
|
|
if media_item.get('caption'):
|
|
item_xml += f'''
|
|
<media:title type="plain">{_escape_xml(media_item['caption'])}</media:title>'''
|
|
|
|
else:
|
|
# Fallback for media without variants (legacy)
|
|
media_url = f"{site_url}/media/{media_item['path']}"
|
|
item_xml += f'''
|
|
<media:content url="{_escape_xml(media_url)}"
|
|
type="{media_item.get('mime_type', 'image/jpeg')}"
|
|
medium="image"
|
|
fileSize="{media_item.get('size', 0)}"/>'''
|
|
|
|
# ... rest of item generation ...
|
|
```
|
|
|
|
### JSON Feed Enhancements
|
|
|
|
#### `/home/phil/Projects/starpunk/starpunk/feeds/json_feed.py`
|
|
|
|
**Changes to `_build_item_object()`**:
|
|
|
|
```python
|
|
def _build_item_object(site_url: str, note: Note) -> Dict[str, Any]:
|
|
"""Build JSON Feed item with enhanced media support"""
|
|
|
|
# ... existing item construction ...
|
|
|
|
# Enhanced _starpunk extension with variants
|
|
# about URL is configurable via STARPUNK_ABOUT_URL config, with sensible default
|
|
about_url = current_app.config.get(
|
|
"STARPUNK_ABOUT_URL",
|
|
"https://github.com/yourusername/starpunk"
|
|
)
|
|
starpunk_ext = {
|
|
"permalink_path": note.permalink,
|
|
"word_count": len(note.content.split()),
|
|
"about": about_url # Extension info URL (configurable)
|
|
}
|
|
|
|
# Add media variants if present
|
|
if hasattr(note, 'media') and note.media:
|
|
media_variants = []
|
|
|
|
for media_item in note.media:
|
|
variants = media_item.get('variants', {})
|
|
|
|
if variants:
|
|
media_info = {
|
|
"caption": media_item.get('caption', ''),
|
|
"variants": {}
|
|
}
|
|
|
|
for variant_type, variant_data in variants.items():
|
|
media_info["variants"][variant_type] = {
|
|
"url": f"{site_url}/media/{variant_data['path']}",
|
|
"width": variant_data['width'],
|
|
"height": variant_data['height'],
|
|
"size_in_bytes": variant_data['size_bytes']
|
|
}
|
|
|
|
media_variants.append(media_info)
|
|
|
|
if media_variants:
|
|
starpunk_ext["media_variants"] = media_variants
|
|
|
|
item["_starpunk"] = starpunk_ext
|
|
|
|
return item
|
|
```
|
|
|
|
### ATOM Feed Enhancements
|
|
|
|
#### `/home/phil/Projects/starpunk/starpunk/feeds/atom.py`
|
|
|
|
**Changes to `generate_atom_streaming()`**:
|
|
|
|
Add enhanced enclosure links with proper attributes:
|
|
|
|
```python
|
|
def generate_atom_streaming(...):
|
|
# ... existing generation ...
|
|
|
|
for note in notes[:limit]:
|
|
# ... existing entry generation ...
|
|
|
|
# Enhanced media enclosures with title attribute
|
|
if hasattr(note, 'media') and note.media:
|
|
for item in note.media:
|
|
media_url = f"{site_url}/media/{item['path']}"
|
|
mime_type = item.get('mime_type', 'image/jpeg')
|
|
size = item.get('size', 0)
|
|
caption = item.get('caption', '')
|
|
|
|
# Include title attribute for caption
|
|
title_attr = f' title="{_escape_xml(caption)}"' if caption else ''
|
|
|
|
yield f' <link rel="enclosure" type="{_escape_xml(mime_type)}" href="{_escape_xml(media_url)}" length="{size}"{title_attr}/>\n'
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 5: Testing & Documentation
|
|
|
|
**Estimated Effort**: 4-6 hours
|
|
|
|
### Test Requirements
|
|
|
|
#### Unit Tests
|
|
|
|
**`/home/phil/Projects/starpunk/tests/test_media_v140.py`**
|
|
|
|
```python
|
|
"""Tests for v1.4.0 media features"""
|
|
|
|
import pytest
|
|
from io import BytesIO
|
|
from PIL import Image
|
|
|
|
|
|
class TestLargeImageSupport:
|
|
"""Tests for large image (>10MB) handling"""
|
|
|
|
def test_accept_file_up_to_50mb(self, app, client):
|
|
"""Files up to 50MB should be accepted"""
|
|
pass
|
|
|
|
def test_reject_file_over_50mb(self, app, client):
|
|
"""Files over 50MB should be rejected"""
|
|
pass
|
|
|
|
def test_tiered_resize_10_to_25mb(self, app, client):
|
|
"""Files 10-25MB should resize to 1600px max"""
|
|
pass
|
|
|
|
def test_tiered_resize_25_to_50mb(self, app, client):
|
|
"""Files 25-50MB should resize to 1280px max"""
|
|
pass
|
|
|
|
def test_iterative_quality_reduction(self, app, client):
|
|
"""Quality should reduce iteratively if output >10MB"""
|
|
pass
|
|
|
|
def test_reject_if_cannot_optimize(self, app, client):
|
|
"""Reject if optimization cannot achieve target size"""
|
|
pass
|
|
|
|
|
|
class TestImageVariants:
|
|
"""Tests for image variant generation"""
|
|
|
|
def test_generate_all_variants(self, app, client):
|
|
"""All four variants should be generated"""
|
|
pass
|
|
|
|
def test_thumb_is_square_crop(self, app, client):
|
|
"""Thumbnail should be 150x150 center crop"""
|
|
pass
|
|
|
|
def test_small_preserves_aspect(self, app, client):
|
|
"""Small variant should preserve aspect ratio"""
|
|
pass
|
|
|
|
def test_variants_stored_in_database(self, app, client):
|
|
"""Variants should be recorded in media_variants table"""
|
|
pass
|
|
|
|
def test_get_note_media_includes_variants(self, app, client):
|
|
"""get_note_media() should include variant data"""
|
|
pass
|
|
|
|
def test_skip_variant_larger_than_original(self, app, client):
|
|
"""Skip generating variants larger than source"""
|
|
pass
|
|
|
|
|
|
class TestMicropubMediaEndpoint:
|
|
"""Tests for /micropub/media endpoint"""
|
|
|
|
def test_upload_success_returns_201(self, app, client):
|
|
"""Successful upload returns 201 with Location header"""
|
|
pass
|
|
|
|
def test_upload_requires_auth(self, app, client):
|
|
"""Upload without token returns 401"""
|
|
pass
|
|
|
|
def test_upload_requires_create_scope(self, app, client):
|
|
"""Upload without create scope returns 403"""
|
|
pass
|
|
|
|
def test_upload_validates_content_type(self, app, client):
|
|
"""Non-multipart requests return 400"""
|
|
pass
|
|
|
|
def test_upload_requires_file_field(self, app, client):
|
|
"""Missing 'file' field returns 400"""
|
|
pass
|
|
|
|
def test_config_query_includes_media_endpoint(self, app, client):
|
|
"""q=config should include media-endpoint URL"""
|
|
pass
|
|
|
|
|
|
class TestPhotoProperty:
|
|
"""Tests for Micropub photo property handling"""
|
|
|
|
def test_photo_url_string(self, app, client):
|
|
"""Simple URL string in photo property"""
|
|
pass
|
|
|
|
def test_photo_with_alt_text(self, app, client):
|
|
"""Photo object with value and alt"""
|
|
pass
|
|
|
|
def test_multiple_photos(self, app, client):
|
|
"""Multiple photos in photo array"""
|
|
pass
|
|
|
|
def test_max_four_photos(self, app, client):
|
|
"""Only first 4 photos should be attached"""
|
|
pass
|
|
|
|
def test_external_url_logged_not_failed(self, app, client):
|
|
"""External URLs should log but not fail"""
|
|
pass
|
|
|
|
|
|
class TestFeedMediaEnhancements:
|
|
"""Tests for enhanced feed media support"""
|
|
|
|
def test_rss_media_group(self, app, client):
|
|
"""RSS should use media:group for variants"""
|
|
pass
|
|
|
|
def test_rss_media_thumbnail(self, app, client):
|
|
"""RSS should include media:thumbnail"""
|
|
pass
|
|
|
|
def test_rss_media_title_for_caption(self, app, client):
|
|
"""RSS should include media:title for captions"""
|
|
pass
|
|
|
|
def test_json_feed_starpunk_variants(self, app, client):
|
|
"""JSON Feed should include variants in _starpunk"""
|
|
pass
|
|
|
|
def test_json_feed_about_url(self, app, client):
|
|
"""JSON Feed _starpunk should include about URL"""
|
|
pass
|
|
|
|
def test_atom_enclosure_title(self, app, client):
|
|
"""ATOM enclosures should have title attribute"""
|
|
pass
|
|
```
|
|
|
|
#### Integration Tests
|
|
|
|
**`/home/phil/Projects/starpunk/tests/integration/test_media_workflow.py`**
|
|
|
|
```python
|
|
"""Integration tests for complete media workflow"""
|
|
|
|
class TestMediaWorkflow:
|
|
"""End-to-end media upload and display"""
|
|
|
|
def test_upload_via_micropub_display_in_feed(self, app, client):
|
|
"""Upload via /micropub/media, create note with photo, verify feed"""
|
|
pass
|
|
|
|
def test_large_image_complete_workflow(self, app, client):
|
|
"""Upload large image, verify resize, verify variants, verify feed"""
|
|
pass
|
|
```
|
|
|
|
### Documentation Updates
|
|
|
|
1. **Update `/home/phil/Projects/starpunk/docs/architecture/syndication-architecture.md`**
|
|
- Add Media RSS variant support
|
|
- Document `_starpunk` extension format
|
|
|
|
2. **Update `/home/phil/Projects/starpunk/CHANGELOG.md`**
|
|
- Add v1.4.0 section with all features
|
|
|
|
3. **Update `/home/phil/Projects/starpunk/docs/standards/testing-checklist.md`**
|
|
- Add media upload validation steps
|
|
- Add feed validation for Media RSS
|
|
|
|
---
|
|
|
|
## Database Schema
|
|
|
|
### Complete Migration SQL
|
|
|
|
**File**: `/home/phil/Projects/starpunk/migrations/009_add_media_variants.sql`
|
|
|
|
```sql
|
|
-- Migration 009: Add media variants support
|
|
-- Version: 1.4.0 Phase 2
|
|
-- Per ADR-059: Full Feed Media Standardization (Phase A)
|
|
|
|
-- Media variants table for multiple image sizes
|
|
-- Each uploaded image gets thumb, small, medium, large, and original variants
|
|
CREATE TABLE IF NOT EXISTS media_variants (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
media_id INTEGER NOT NULL,
|
|
variant_type TEXT NOT NULL CHECK (variant_type IN ('thumb', 'small', 'medium', 'large', 'original')),
|
|
path TEXT NOT NULL, -- Relative path: YYYY/MM/uuid_variant.ext
|
|
width INTEGER NOT NULL,
|
|
height INTEGER NOT NULL,
|
|
size_bytes INTEGER NOT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE,
|
|
UNIQUE(media_id, variant_type)
|
|
);
|
|
|
|
-- Index for efficient variant lookup by media ID
|
|
CREATE INDEX IF NOT EXISTS idx_media_variants_media ON media_variants(media_id);
|
|
```
|
|
|
|
### Schema Diagram
|
|
|
|
```
|
|
+----------------+ +---------------+ +------------------+
|
|
| notes | | note_media | | media |
|
|
+----------------+ +---------------+ +------------------+
|
|
| id (PK) |<------| note_id (FK) | | id (PK) |
|
|
| slug | | media_id (FK) |------>| filename |
|
|
| file_path | | display_order | | stored_filename |
|
|
| published | | caption | | path |
|
|
| created_at | +---------------+ | mime_type |
|
|
| updated_at | | size |
|
|
| deleted_at | | width |
|
|
| content_hash | | height |
|
|
+----------------+ | uploaded_at |
|
|
+------------------+
|
|
|
|
|
| 1:N
|
|
v
|
|
+------------------+
|
|
| media_variants |
|
|
+------------------+
|
|
| id (PK) |
|
|
| media_id (FK) |
|
|
| variant_type |
|
|
| path |
|
|
| width |
|
|
| height |
|
|
| size_bytes |
|
|
| created_at |
|
|
+------------------+
|
|
```
|
|
|
|
---
|
|
|
|
## API Specifications
|
|
|
|
### Micropub Media Endpoint
|
|
|
|
**Endpoint**: `POST /micropub/media`
|
|
|
|
**Request**:
|
|
```http
|
|
POST /micropub/media HTTP/1.1
|
|
Host: example.com
|
|
Authorization: Bearer {token}
|
|
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
|
|
|
|
------WebKitFormBoundary
|
|
Content-Disposition: form-data; name="file"; filename="photo.jpg"
|
|
Content-Type: image/jpeg
|
|
|
|
[binary data]
|
|
------WebKitFormBoundary--
|
|
```
|
|
|
|
**Success Response**:
|
|
```http
|
|
HTTP/1.1 201 Created
|
|
Location: https://example.com/media/2025/01/abc123-def456.jpg
|
|
```
|
|
|
|
**Error Responses**:
|
|
|
|
| Status | Error | Description |
|
|
|--------|-------|-------------|
|
|
| 400 | invalid_request | Missing file, invalid format, file too large |
|
|
| 401 | unauthorized | No token or invalid token |
|
|
| 403 | insufficient_scope | Token lacks create scope |
|
|
| 500 | server_error | Processing failure |
|
|
|
|
### Micropub Config Query
|
|
|
|
**Request**:
|
|
```http
|
|
GET /micropub?q=config HTTP/1.1
|
|
Authorization: Bearer {token}
|
|
```
|
|
|
|
**Response**:
|
|
```json
|
|
{
|
|
"media-endpoint": "https://example.com/micropub/media",
|
|
"syndicate-to": [],
|
|
"post-types": [
|
|
{"type": "note", "name": "Note", "properties": ["content"]},
|
|
{"type": "photo", "name": "Photo", "properties": ["photo"]}
|
|
]
|
|
}
|
|
```
|
|
|
|
### Photo Property in Micropub Create
|
|
|
|
**Simple URL**:
|
|
```http
|
|
POST /micropub HTTP/1.1
|
|
Content-Type: application/x-www-form-urlencoded
|
|
|
|
h=entry&content=My+photo&photo=https://example.com/media/2025/01/abc.jpg
|
|
```
|
|
|
|
**JSON with Alt Text**:
|
|
```json
|
|
{
|
|
"type": ["h-entry"],
|
|
"properties": {
|
|
"content": ["My photo post"],
|
|
"photo": [{
|
|
"value": "https://example.com/media/2025/01/abc.jpg",
|
|
"alt": "A beautiful sunset over the ocean"
|
|
}]
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## File Modifications Summary
|
|
|
|
### Files to Create
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `/home/phil/Projects/starpunk/migrations/009_add_media_variants.sql` | Database migration for variants table |
|
|
| `/home/phil/Projects/starpunk/tests/test_media_v140.py` | Unit tests for new features |
|
|
|
|
### Files to Modify
|
|
|
|
| File | Changes |
|
|
|------|---------|
|
|
| `/home/phil/Projects/starpunk/starpunk/media.py` | Large image support, variant generation |
|
|
| `/home/phil/Projects/starpunk/starpunk/micropub.py` | Photo property extraction, config update |
|
|
| `/home/phil/Projects/starpunk/starpunk/routes/micropub.py` | New media endpoint route |
|
|
| `/home/phil/Projects/starpunk/starpunk/feeds/rss.py` | Media RSS enhancements |
|
|
| `/home/phil/Projects/starpunk/starpunk/feeds/json_feed.py` | Variant info in _starpunk |
|
|
| `/home/phil/Projects/starpunk/starpunk/feeds/atom.py` | Enclosure title attribute |
|
|
| `/home/phil/Projects/starpunk/starpunk/migrations.py` | Add migration 009 detection (if needed) |
|
|
|
|
---
|
|
|
|
## Developer Q&A
|
|
|
|
### General Questions
|
|
|
|
**Q1: What happens to existing media files when upgrading to v1.4.0?**
|
|
|
|
A: Existing media files continue to work unchanged. Variants are only generated for **new uploads** after upgrading. Existing media will show in feeds without variant information - feeds gracefully handle both cases.
|
|
|
|
**Q2: Can I retroactively generate variants for existing media?**
|
|
|
|
A: Not automatically. A management command could be added post-release if needed, but it's not in scope for v1.4.0.
|
|
|
|
**Q3: How much additional storage do variants use?**
|
|
|
|
A: Approximately 4x per image:
|
|
- Original: 100%
|
|
- Large (1280px): ~50%
|
|
- Medium (640px): ~25%
|
|
- Small (320px): ~12%
|
|
- Thumb (150x150): ~3%
|
|
|
|
For a typical 500KB optimized image, expect ~900KB total with variants.
|
|
|
|
### Large Image Support
|
|
|
|
**Q4: What if a user uploads a 45MB image that still can't fit in 10MB after optimization?**
|
|
|
|
A: The iterative optimization will:
|
|
1. Try resize to 1280px at 85% quality
|
|
2. Reduce quality to 80%, 75%, 70%
|
|
3. If still >10MB, reduce dimensions to 1024px at 85%
|
|
4. Continue until success or minimum (640px at 70%)
|
|
5. If 640px at 70% still >10MB, reject with error
|
|
|
|
This handles extreme edge cases like uncompressed TIFFs converted to JPEG.
|
|
|
|
**Q5: Is the 50MB limit configurable?**
|
|
|
|
A: In v1.4.0, it's a constant. Configuration could be added later if needed.
|
|
|
|
### Micropub Media Endpoint
|
|
|
|
**Q6: Do I need a new token scope for media uploads?**
|
|
|
|
A: No. The existing `create` scope is sufficient. Per the confirmed decisions, tokens with `create` scope can upload media.
|
|
|
|
**Q7: What happens if a Micropub client sends a photo URL that doesn't exist on my server?**
|
|
|
|
A: The URL is logged and ignored. The note is still created, but without that photo attached. This prevents failures when clients reference external URLs.
|
|
|
|
**Q8: Can I upload multiple files in one request?**
|
|
|
|
A: No. The W3C Micropub spec defines a single file per request. Upload multiple files with multiple requests, then reference all URLs in the create request's photo property.
|
|
|
|
**Q9: What's the maximum number of photos per note?**
|
|
|
|
A: 4 photos, per ADR-057. This matches Twitter/Mastodon limits.
|
|
|
|
### Feed Enhancements
|
|
|
|
**Q10: How do feed readers handle the media:group element?**
|
|
|
|
A: Most modern feed readers (Feedly, Inoreader, NewsBlur) understand Media RSS and will:
|
|
- Use the `isDefault="true"` variant for display
|
|
- Allow users to view other sizes
|
|
- Show thumbnails in list views
|
|
|
|
Older readers ignore the namespace and fall back to the HTML in description.
|
|
|
|
**Q11: What's the `_starpunk.about` URL for?**
|
|
|
|
A: Per JSON Feed extension best practices, custom namespaces should include an `about` URL that documents the extension. This helps consumers understand the data format.
|
|
|
|
**Q12: Will Media RSS validation pass after these changes?**
|
|
|
|
A: Yes. The implementation follows the Media RSS specification at https://www.rssboard.org/media-rss. Run the W3C Feed Validator to confirm.
|
|
|
|
### Implementation Order
|
|
|
|
**Q13: Can phases be implemented out of order?**
|
|
|
|
A: Phases 1 and 2 should be done together (variants depend on large image support changes to `save_media()`). Phase 3 (Micropub) can be done independently. Phase 4 (feeds) requires Phase 2 completion.
|
|
|
|
**Q14: What's the minimum viable v1.4.0?**
|
|
|
|
A: If time is constrained, Phase 1 (large image support) alone provides significant user value and can ship independently. Other phases can be moved to v1.4.1.
|
|
|
|
### Testing
|
|
|
|
**Q15: How do I test with large images without storing them in the repo?**
|
|
|
|
A: Generate test images programmatically:
|
|
|
|
```python
|
|
from PIL import Image
|
|
import io
|
|
import numpy as np
|
|
|
|
def create_test_image(width, height, target_size_mb):
|
|
"""Create a test image of approximate target size"""
|
|
# Create image with random noise to prevent JPEG compression from
|
|
# shrinking it too much. Solid colors compress extremely well.
|
|
noise = np.random.randint(0, 256, (height, width, 3), dtype=np.uint8)
|
|
img = Image.fromarray(noise, 'RGB')
|
|
|
|
# Iteratively adjust quality to hit target size
|
|
quality = 95
|
|
while quality > 50:
|
|
output = io.BytesIO()
|
|
img.save(output, 'JPEG', quality=quality)
|
|
size_mb = len(output.getvalue()) / (1024 * 1024)
|
|
if size_mb >= target_size_mb * 0.9: # Within 10% of target
|
|
return output.getvalue()
|
|
# Need larger file, but can't increase noise, so use higher resolution
|
|
break
|
|
|
|
output = io.BytesIO()
|
|
img.save(output, 'JPEG', quality=95)
|
|
return output.getvalue()
|
|
|
|
# Example usage in tests:
|
|
# large_image = create_test_image(4000, 3000, 15) # ~15MB test image
|
|
```
|
|
|
|
**Q16: How do I validate Media RSS output?**
|
|
|
|
A: Use the W3C Feed Validator (https://validator.w3.org/feed/) and verify:
|
|
- No errors for `media:` namespace elements
|
|
- Proper attribute validation
|
|
- Valid XML structure
|
|
|
|
---
|
|
|
|
## Acceptance Criteria
|
|
|
|
### Phase 1: Large Image Support
|
|
- [ ] Files up to 50MB accepted
|
|
- [ ] Files >50MB rejected with clear error
|
|
- [ ] Tiered resize strategy applied based on input size
|
|
- [ ] Iterative quality reduction works for edge cases
|
|
- [ ] Final output always <=10MB
|
|
- [ ] All existing tests pass
|
|
|
|
### Phase 2: Image Variants
|
|
- [ ] Migration 009 creates media_variants table
|
|
- [ ] All four variants generated on upload
|
|
- [ ] Thumbnail is center-cropped square
|
|
- [ ] Variants smaller than source not generated
|
|
- [ ] get_note_media() returns variant data
|
|
- [ ] Variants cascade-deleted with parent media
|
|
|
|
### Phase 3: Micropub Media Endpoint
|
|
- [ ] POST /micropub/media accepts uploads
|
|
- [ ] Returns 201 with Location header on success
|
|
- [ ] Requires valid bearer token with create scope
|
|
- [ ] q=config includes media-endpoint URL
|
|
- [ ] Photo property attaches images to notes
|
|
- [ ] Alt text preserved as caption
|
|
|
|
### Phase 4: Enhanced Feed Media
|
|
- [ ] RSS uses media:group for variants
|
|
- [ ] RSS includes media:thumbnail
|
|
- [ ] RSS includes media:title for captions
|
|
- [ ] JSON Feed _starpunk includes variants
|
|
- [ ] JSON Feed _starpunk includes about URL
|
|
- [ ] ATOM enclosures have title attribute
|
|
- [ ] All feeds validate without errors
|
|
|
|
### Phase 5: Testing & Documentation
|
|
- [ ] All new tests pass
|
|
- [ ] Test coverage maintained >80%
|
|
- [ ] CHANGELOG updated
|
|
- [ ] Architecture docs updated
|
|
- [ ] Version bumped to 1.4.0
|
|
|
|
---
|
|
|
|
## Implementation Notes
|
|
|
|
### SITE_URL Normalization
|
|
|
|
Throughout this implementation, `SITE_URL` should be normalized by stripping trailing slashes before use. This ensures consistent URL construction:
|
|
|
|
```python
|
|
# Standard pattern for SITE_URL normalization
|
|
site_url = current_app.config.get("SITE_URL", "http://localhost:5000").rstrip('/')
|
|
media_url = f"{site_url}/media/{path}"
|
|
```
|
|
|
|
This pattern is used in:
|
|
- `_attach_photos_to_note()` for URL comparison
|
|
- Media endpoint for Location header
|
|
- Feed generation for media URLs
|
|
|
|
### Configuration Options
|
|
|
|
| Config Key | Default | Description |
|
|
|------------|---------|-------------|
|
|
| `SITE_URL` | `http://localhost:5000` | Base URL for the site |
|
|
| `STARPUNK_ABOUT_URL` | `https://github.com/yourusername/starpunk` | URL documenting the `_starpunk` JSON Feed extension |
|
|
|
|
---
|
|
|
|
## References
|
|
|
|
- [W3C Micropub Specification](https://www.w3.org/TR/micropub/)
|
|
- [Media RSS Specification](https://www.rssboard.org/media-rss)
|
|
- [JSON Feed 1.1 Specification](https://jsonfeed.org/version/1.1)
|
|
- [ADR-057: Media Attachment Model](/home/phil/Projects/starpunk/docs/decisions/ADR-057-media-attachment-model.md)
|
|
- [ADR-058: Image Optimization Strategy](/home/phil/Projects/starpunk/docs/decisions/ADR-058-image-optimization-strategy.md)
|
|
- [ADR-059: Full Feed Media Standardization](/home/phil/Projects/starpunk/docs/decisions/ADR-059-full-feed-media-standardization.md)
|
|
- [v1.4.0 Release Definition](/home/phil/Projects/starpunk/docs/projectplan/v1.4.0/RELEASE.md)
|