Files
StarPunk/starpunk/routes/micropub.py
Phil Skentelbery c64feaea23 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>
2025-12-10 18:32:21 -07:00

204 lines
6.7 KiB
Python

"""
Micropub endpoint routes for StarPunk
Implements the W3C Micropub specification for creating posts via
external IndieWeb clients.
Endpoints:
GET/POST /micropub - Main Micropub endpoint
GET: Query operations (config, source, syndicate-to)
POST: Action operations (create in V1, update/delete in future)
Authentication:
Bearer token authentication required for all endpoints.
Token must have appropriate scope for requested operation.
References:
- W3C Micropub Specification: https://www.w3.org/TR/micropub/
- ADR-028: Micropub Implementation Strategy
- ADR-029: Micropub IndieAuth Integration Strategy
"""
from flask import Blueprint, current_app, request, make_response
from starpunk.micropub import (
MicropubError,
extract_bearer_token,
error_response,
handle_create,
handle_query,
)
from starpunk.auth_external import verify_external_token, check_scope
# Create blueprint
bp = Blueprint("micropub", __name__)
@bp.route("/micropub", methods=["GET", "POST"])
def micropub_endpoint():
"""
Main Micropub endpoint for all operations
GET requests:
Handle query operations via q= parameter:
- q=config: Return server capabilities
- q=source&url={url}: Return post source
- q=syndicate-to: Return syndication targets
POST requests:
Handle action operations (form-encoded or JSON):
- action=create (or no action): Create new post
- action=update: Update existing post (not supported in V1)
- action=delete: Delete post (not supported in V1)
Authentication:
Requires valid bearer token in Authorization header or
access_token parameter.
Returns:
GET: JSON response with query results
POST create: 201 Created with Location header
POST other: Error responses
Error responses follow OAuth 2.0 format:
{
"error": "error_code",
"error_description": "Human-readable description"
}
"""
# 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)
# Handle query endpoints (GET requests)
if request.method == "GET":
try:
return handle_query(request.args.to_dict(), token_info)
except MicropubError as e:
return error_response(e.error, e.error_description, e.status_code)
except Exception as e:
current_app.logger.error(f"Micropub query error: {e}")
return error_response("server_error", "An unexpected error occurred", 500)
# Handle action endpoints (POST requests)
content_type = request.headers.get("Content-Type", "")
try:
# Parse request based on content type
if "application/json" in content_type:
data = request.get_json() or {}
action = data.get("action", "create")
else:
# Form-encoded or multipart (V1 only supports form-encoded)
data = request.form.to_dict(flat=False)
action = data.get("action", ["create"])[0]
# Route to appropriate handler
if action == "create":
return handle_create(data, token_info)
elif action == "update":
# V1: Update not supported
return error_response(
"invalid_request", "Update action not supported in V1", 400
)
elif action == "delete":
# V1: Delete not supported
return error_response(
"invalid_request", "Delete action not supported in V1", 400
)
else:
return error_response("invalid_request", f"Unknown action: {action}", 400)
except MicropubError as e:
return error_response(e.error, e.error_description, e.status_code)
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)