feat: v1.4.0 Phase 3 - Micropub Media Endpoint
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>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user