Implements media upload logging per docs/design/v1.4.1/media-logging-design.md Changes: - Add logging to save_media() in starpunk/media.py: * INFO: Successful uploads with file details * WARNING: Validation/optimization/variant failures * ERROR: Unexpected system errors - Remove duplicate logging in Micropub media endpoint - Add 5 comprehensive logging tests in TestMediaLogging class - Bump version to 1.4.1 - Update CHANGELOG.md All media upload operations now logged for debugging and observability. Validation errors, optimization failures, and variant generation issues are tracked at appropriate log levels. Original functionality unchanged. Test results: 28/28 media tests pass, 5 new logging tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
203 lines
6.6 KiB
Python
203 lines
6.6 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:
|
|
return error_response("server_error", "Failed to process upload", 500)
|