diff --git a/docs/design/v1.3.1/feed-tags-design.md b/docs/design/v1.3.1/feed-tags-design.md new file mode 100644 index 0000000..7461d0f --- /dev/null +++ b/docs/design/v1.3.1/feed-tags-design.md @@ -0,0 +1,302 @@ +# Feed Tags Implementation Design + +**Version**: 1.3.1 "Syndicate Tags" +**Status**: Ready for Implementation +**Estimated Effort**: 1-2 hours + +## Overview + +This document specifies the implementation for adding tags/categories to all three syndication feed formats. Tags were added to the backend in v1.3.0 but are not currently included in feed output. + +## Current State Analysis + +### Tag Data Structure + +Tags are stored as dictionaries with two fields: +- `name`: Normalized, URL-safe identifier (e.g., `machine-learning`) +- `display_name`: Human-readable label (e.g., `Machine Learning`) + +The `get_note_tags(note_id)` function returns a list of these dictionaries, ordered alphabetically by display_name. + +### Feed Generation Routes + +The `_get_cached_notes()` function in `starpunk/routes/public.py` already attaches media to notes but **does not attach tags**. This is the key change needed to make tags available to feed generators. + +### Feed Generator Functions + +Each feed module uses a consistent pattern: +- Non-streaming function builds complete feed +- Streaming function yields chunks +- Both accept `notes: list[Note]` where notes may have attached attributes + +## Design Decisions + +Per user confirmation: +1. **Omit `scheme`/`domain` attributes** - Keep implementation minimal +2. **Omit `tags` field when empty** - Do not output empty array in JSON Feed + +## Implementation Specification + +### Phase 1: Load Tags in Feed Routes + +**File**: `starpunk/routes/public.py` + +**Change**: Modify `_get_cached_notes()` to attach tags to each note. + +**Current code** (lines 66-69): +```python +# Attach media to each note (v1.2.0 Phase 3) +for note in notes: + media = get_note_media(note.id) + object.__setattr__(note, 'media', media) +``` + +**Required change**: Add tag loading after media loading: +```python +# Attach media to each note (v1.2.0 Phase 3) +for note in notes: + media = get_note_media(note.id) + object.__setattr__(note, 'media', media) + + # Attach tags to each note (v1.3.1) + tags = get_note_tags(note.id) + object.__setattr__(note, 'tags', tags) +``` + +**Import needed**: Add `get_note_tags` to imports from `starpunk.tags`. + +### Phase 2: RSS 2.0 Categories + +**File**: `starpunk/feeds/rss.py` + +**Standard**: RSS 2.0 Specification - `` sub-element of `` + +**Format**: +```xml +Display Name +``` + +#### Non-Streaming Function (`generate_rss`) + +The feedgen library's `FeedEntry` supports categories via `fe.category()`. + +**Location**: After description is set (around line 143), add: +```python +# Add category elements for tags (v1.3.1) +if hasattr(note, 'tags') and note.tags: + for tag in note.tags: + fe.category({'term': tag['display_name']}) +``` + +Note: feedgen's category accepts a dict with 'term' key for RSS output. + +#### Streaming Function (`generate_rss_streaming`) + +**Location**: After description in the item XML building (around line 293), add category elements: + +Insert after the `` CDATA section and before the media elements: +```python +# Add category elements for tags (v1.3.1) +if hasattr(note, 'tags') and note.tags: + for tag in note.tags: + item_xml += f""" + {_escape_xml(tag['display_name'])}""" +``` + +**Expected output**: +```xml + + My Post + https://example.com/note/my-post + https://example.com/note/my-post + Mon, 18 Nov 2024 12:00:00 +0000 + + Machine Learning + Python + ... + +``` + +### Phase 3: Atom 1.0 Categories + +**File**: `starpunk/feeds/atom.py` + +**Standard**: RFC 4287 Section 4.2.2 - The `atom:category` Element + +**Format**: +```xml + +``` + +- `term` (REQUIRED): Normalized tag name for machine processing +- `label` (OPTIONAL): Human-readable display name + +#### Streaming Function (`generate_atom_streaming`) + +Note: `generate_atom()` delegates to streaming, so only one change needed. + +**Location**: After the entry link element (around line 179), before content: + +```python +# Add category elements for tags (v1.3.1) +if hasattr(note, 'tags') and note.tags: + for tag in note.tags: + yield f' \n' +``` + +**Expected output**: +```xml + + https://example.com/note/my-post + My Post + 2024-11-25T12:00:00Z + 2024-11-25T12:00:00Z + + + + ... + +``` + +### Phase 4: JSON Feed 1.1 Tags + +**File**: `starpunk/feeds/json_feed.py` + +**Standard**: JSON Feed 1.1 Specification - `tags` field + +**Format**: +```json +{ + "tags": ["Machine Learning", "Python"] +} +``` + +Per user decision: **Omit `tags` field entirely when no tags** (do not output empty array). + +#### Item Builder Function (`_build_item_object`) + +**Location**: After attachments section (around line 308), before `_starpunk` extension: + +```python +# Add tags array (v1.3.1) +# Per spec: array of plain strings (tags, not categories) +# Omit field when no tags (user decision: no empty array) +if hasattr(note, 'tags') and note.tags: + item["tags"] = [tag['display_name'] for tag in note.tags] +``` + +**Expected output** (note with tags): +```json +{ + "id": "https://example.com/note/my-post", + "url": "https://example.com/note/my-post", + "title": "My Post", + "content_html": "...", + "date_published": "2024-11-25T12:00:00Z", + "tags": ["Machine Learning", "Python"], + "_starpunk": {...} +} +``` + +**Expected output** (note without tags): +```json +{ + "id": "https://example.com/note/my-post", + "url": "https://example.com/note/my-post", + "title": "My Post", + "content_html": "...", + "date_published": "2024-11-25T12:00:00Z", + "_starpunk": {...} +} +``` + +Note: No `"tags"` field at all when empty. + +## Testing Requirements + +### Unit Tests + +Create test file: `tests/unit/feeds/test_feed_tags.py` + +#### RSS Tests +1. `test_rss_note_with_tags_has_category_elements` +2. `test_rss_note_without_tags_has_no_category_elements` +3. `test_rss_multiple_tags_multiple_categories` +4. `test_rss_streaming_tags` + +#### Atom Tests +1. `test_atom_note_with_tags_has_category_elements` +2. `test_atom_category_has_term_and_label_attributes` +3. `test_atom_note_without_tags_has_no_category_elements` +4. `test_atom_streaming_tags` + +#### JSON Feed Tests +1. `test_json_note_with_tags_has_tags_array` +2. `test_json_note_without_tags_omits_tags_field` +3. `test_json_tags_array_contains_display_names` +4. `test_json_streaming_tags` + +### Integration Tests + +Add to existing feed integration tests: + +1. `test_feed_generation_with_mixed_tagged_notes` - Mix of notes with and without tags +2. `test_feed_tags_ordering` - Tags appear in alphabetical order by display_name + +### Test Data Setup + +```python +# Test note with tags attached +note = Note(...) +object.__setattr__(note, 'tags', [ + {'name': 'machine-learning', 'display_name': 'Machine Learning'}, + {'name': 'python', 'display_name': 'Python'}, +]) + +# Test note without tags +note_no_tags = Note(...) +object.__setattr__(note_no_tags, 'tags', []) +``` + +## Implementation Order + +1. **Routes change** (`public.py`) - Load tags in `_get_cached_notes()` +2. **JSON Feed** (`json_feed.py`) - Simplest change, good for validation +3. **Atom Feed** (`atom.py`) - Single streaming function +4. **RSS Feed** (`rss.py`) - Both streaming and non-streaming functions +5. **Tests** - Unit and integration tests + +## Validation Checklist + +- [ ] RSS feed validates against RSS 2.0 spec +- [ ] Atom feed validates against RFC 4287 +- [ ] JSON Feed validates against JSON Feed 1.1 spec +- [ ] Notes without tags produce valid feeds (no empty elements/arrays) +- [ ] Special characters in tag names are properly escaped +- [ ] Existing tests continue to pass +- [ ] Feed caching works correctly with tags + +## Standards References + +- [RSS 2.0 - category element](https://www.rssboard.org/rss-specification#ltcategorygtSubelementOfLtitemgt) +- [RFC 4287 Section 4.2.2 - atom:category](https://datatracker.ietf.org/doc/html/rfc4287#section-4.2.2) +- [JSON Feed 1.1 - tags](https://www.jsonfeed.org/version/1.1/) + +## Files to Modify + +| File | Change | +|------|--------| +| `starpunk/routes/public.py` | Add tag loading to `_get_cached_notes()` | +| `starpunk/feeds/rss.py` | Add `` elements in both functions | +| `starpunk/feeds/atom.py` | Add `` elements | +| `starpunk/feeds/json_feed.py` | Add `tags` array to `_build_item_object()` | +| `tests/unit/feeds/test_feed_tags.py` | New test file | + +## Summary + +This is a straightforward feature addition: +- One route change to load tags +- Three feed module changes to render tags +- Follows established patterns in existing code +- No new dependencies required +- Backward compatible (tags are optional in all specs) diff --git a/docs/design/v1.4.0/media-implementation-design.md b/docs/design/v1.4.0/media-implementation-design.md new file mode 100644 index 0000000..78d44a3 --- /dev/null +++ b/docs/design/v1.4.0/media-implementation-design.md @@ -0,0 +1,1658 @@ +# 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 ' + + # 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''' + ''' + + item_xml += '\n ' + + # Add media:thumbnail + if 'thumb' in variants: + thumb = variants['thumb'] + thumb_url = f"{site_url}/media/{thumb['path']}" + item_xml += f''' + ''' + + # Add media:title for caption + if media_item.get('caption'): + item_xml += f''' + {_escape_xml(media_item['caption'])}''' + + else: + # Fallback for media without variants (legacy) + media_url = f"{site_url}/media/{media_item['path']}" + item_xml += f''' + ''' + + # ... 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' \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) diff --git a/docs/projectplan/BACKLOG.md b/docs/projectplan/BACKLOG.md index 3942f59..2ad262f 100644 --- a/docs/projectplan/BACKLOG.md +++ b/docs/projectplan/BACKLOG.md @@ -36,10 +36,25 @@ - ATOM enclosure links for all media - See: ADR-059 +### POSSE +- Native syndication to social networks +- Supported networks: + - First iteration: + - Mastodon (and compatible services) + - Bluesky + - Second iteration + - TBD +- Solution should include a configuration UI for setup + --- ## Medium +### Default slug change +- The default slug should be a date time stamp +- YYYYMMDDHHMMSS +- Edge case, if the slug would somehow be a duplicate append a "-x" e.g. -1 + ### Tag Enhancements (v1.3.0 Follow-up) - Tag pagination on archive pages (when note count exceeds threshold) - Tag autocomplete in admin interface diff --git a/starpunk/micropub.py b/starpunk/micropub.py index 2b6b2e5..86bbff2 100644 --- a/starpunk/micropub.py +++ b/starpunk/micropub.py @@ -264,6 +264,106 @@ def extract_published_date(properties: dict) -> Optional[datetime]: # Action Handlers +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 + + +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 + + # 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) + + def handle_create(data: dict, token_info: dict): """ Handle Micropub create action @@ -305,6 +405,7 @@ def handle_create(data: dict, token_info: dict): title = extract_title(properties) tags = extract_tags(properties) published_date = extract_published_date(properties) + photos = extract_photos(properties) # v1.4.0 except MicropubValidationError as e: raise e @@ -322,6 +423,10 @@ def handle_create(data: dict, token_info: dict): tags=tags if tags else None # Pass tags to create_note (v1.3.0) ) + # Attach photos if present (v1.4.0) + if photos: + _attach_photos_to_note(note.id, photos) + # Build permalink URL # Note: SITE_URL is normalized to include trailing slash (for IndieAuth spec compliance) site_url = current_app.config.get("SITE_URL", "http://localhost:5000") @@ -358,11 +463,15 @@ def handle_query(args: dict, token_info: dict): q = args.get("q") if q == "config": - # Return server configuration + # Return server configuration with media endpoint (v1.4.0) + site_url = current_app.config.get("SITE_URL", "http://localhost:5000").rstrip('/') config = { - "media-endpoint": None, # No media endpoint in V1 + "media-endpoint": f"{site_url}/micropub/media", "syndicate-to": [], # No syndication targets in V1 - "post-types": [{"type": "note", "name": "Note", "properties": ["content"]}], + "post-types": [ + {"type": "note", "name": "Note", "properties": ["content"]}, + {"type": "photo", "name": "Photo", "properties": ["photo"]} + ], } return jsonify(config), 200 diff --git a/starpunk/routes/micropub.py b/starpunk/routes/micropub.py index 8d853f4..9670f3b 100644 --- a/starpunk/routes/micropub.py +++ b/starpunk/routes/micropub.py @@ -19,7 +19,7 @@ References: - ADR-029: Micropub IndieAuth Integration Strategy """ -from flask import Blueprint, current_app, request +from flask import Blueprint, current_app, request, make_response from starpunk.micropub import ( MicropubError, @@ -28,7 +28,7 @@ from starpunk.micropub import ( handle_create, handle_query, ) -from starpunk.auth_external import verify_external_token +from starpunk.auth_external import verify_external_token, check_scope # Create blueprint bp = Blueprint("micropub", __name__) @@ -119,3 +119,85 @@ def micropub_endpoint(): except Exception as e: current_app.logger.error(f"Micropub action error: {e}") return error_response("server_error", "An unexpected error occurred", 500) + + +@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 + + # 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 (normalize SITE_URL by removing trailing slash) + site_url = current_app.config.get("SITE_URL", "http://localhost:5000").rstrip('/') + 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)