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:
2025-12-10 18:32:21 -07:00
parent 501a711050
commit c64feaea23
5 changed files with 2171 additions and 5 deletions

View File

@@ -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

View File

@@ -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)