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