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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user