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>
204 lines
6.7 KiB
Python
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)
|