Completed all remaining phases of ADR-030 IndieAuth provider removal. StarPunk no longer acts as an authorization server - all IndieAuth operations delegated to external providers. Phase 2 - Remove Token Issuance: - Deleted /auth/token endpoint - Removed token_endpoint() function from routes/auth.py - Deleted tests/test_routes_token.py Phase 3 - Remove Token Storage: - Deleted starpunk/tokens.py module entirely - Created migration 004 to drop tokens and authorization_codes tables - Deleted tests/test_tokens.py - Removed all internal token CRUD operations Phase 4 - External Token Verification: - Created starpunk/auth_external.py module - Implemented verify_external_token() for external IndieAuth providers - Updated Micropub endpoint to use external verification - Added TOKEN_ENDPOINT configuration - Updated all Micropub tests to mock external verification - HTTP timeout protection (5s) for external requests Additional Changes: - Created migration 003 to remove code_verifier from auth_state - Fixed 5 migration tests that referenced obsolete code_verifier column - Updated 11 Micropub tests for external verification - Fixed test fixture and app context issues - All 501 tests passing Breaking Changes: - Micropub clients must use external IndieAuth providers - TOKEN_ENDPOINT configuration now required - Existing internal tokens invalid (tables dropped) Migration Impact: - Simpler codebase: -500 lines of code - Fewer database tables: -2 tables (tokens, authorization_codes) - More secure: External providers handle token security - More maintainable: Less authentication code to maintain Standards Compliance: - W3C IndieAuth specification - OAuth 2.0 Bearer token authentication - IndieWeb principle: delegate to external services Related: - ADR-030: IndieAuth Provider Removal Strategy - ADR-050: Remove Custom IndieAuth Server - Migration 003: Remove code_verifier from auth_state - Migration 004: Drop tokens and authorization_codes tables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
401 lines
12 KiB
Python
401 lines
12 KiB
Python
"""
|
|
Micropub endpoint implementation for StarPunk
|
|
|
|
This module handles Micropub protocol requests, providing a standard IndieWeb
|
|
interface for creating posts via external clients.
|
|
|
|
Functions:
|
|
normalize_properties: Convert form/JSON data to Micropub properties format
|
|
extract_content: Get content from Micropub properties
|
|
extract_title: Get or generate title from Micropub properties
|
|
extract_tags: Get category tags from Micropub properties
|
|
handle_create: Process Micropub create action
|
|
handle_query: Process Micropub query endpoints
|
|
extract_bearer_token: Get token from Authorization header or form
|
|
|
|
Exceptions:
|
|
MicropubError: Base exception for Micropub operations
|
|
MicropubAuthError: Authentication/authorization errors
|
|
MicropubValidationError: Invalid request data
|
|
|
|
References:
|
|
- W3C Micropub Specification: https://www.w3.org/TR/micropub/
|
|
- IndieAuth Specification: https://www.w3.org/TR/indieauth/
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from flask import Request, current_app, jsonify
|
|
|
|
from starpunk.notes import create_note, get_note, InvalidNoteDataError, NoteNotFoundError
|
|
from starpunk.auth_external import check_scope
|
|
|
|
|
|
# Custom Exceptions
|
|
|
|
|
|
class MicropubError(Exception):
|
|
"""Base exception for Micropub operations"""
|
|
|
|
def __init__(self, error: str, error_description: str, status_code: int = 400):
|
|
self.error = error
|
|
self.error_description = error_description
|
|
self.status_code = status_code
|
|
super().__init__(error_description)
|
|
|
|
|
|
class MicropubAuthError(MicropubError):
|
|
"""Authentication or authorization error"""
|
|
|
|
def __init__(self, error_description: str, status_code: int = 401):
|
|
super().__init__("unauthorized", error_description, status_code)
|
|
|
|
|
|
class MicropubValidationError(MicropubError):
|
|
"""Invalid request data"""
|
|
|
|
def __init__(self, error_description: str):
|
|
super().__init__("invalid_request", error_description, 400)
|
|
|
|
|
|
# Response Helpers
|
|
|
|
|
|
def error_response(error: str, error_description: str, status_code: int = 400):
|
|
"""
|
|
Generate OAuth 2.0 compliant error response
|
|
|
|
Args:
|
|
error: Error code (e.g., "invalid_request")
|
|
error_description: Human-readable error description
|
|
status_code: HTTP status code
|
|
|
|
Returns:
|
|
Tuple of (response, status_code)
|
|
"""
|
|
return (
|
|
jsonify({"error": error, "error_description": error_description}),
|
|
status_code,
|
|
)
|
|
|
|
|
|
# Token Extraction
|
|
|
|
|
|
def extract_bearer_token(request: Request) -> Optional[str]:
|
|
"""
|
|
Extract bearer token from Authorization header or form parameter
|
|
|
|
Micropub spec allows token in either location:
|
|
- Authorization: Bearer <token>
|
|
- access_token form parameter
|
|
|
|
Args:
|
|
request: Flask request object
|
|
|
|
Returns:
|
|
Token string if found, None otherwise
|
|
"""
|
|
# Try Authorization header first
|
|
auth_header = request.headers.get("Authorization", "")
|
|
if auth_header.startswith("Bearer "):
|
|
return auth_header[7:] # Remove "Bearer " prefix
|
|
|
|
# Try form parameter
|
|
if request.method == "POST":
|
|
return request.form.get("access_token")
|
|
elif request.method == "GET":
|
|
return request.args.get("access_token")
|
|
|
|
return None
|
|
|
|
|
|
# Property Normalization
|
|
|
|
|
|
def normalize_properties(data: dict) -> dict:
|
|
"""
|
|
Normalize Micropub properties from both form and JSON formats
|
|
|
|
Handles two input formats:
|
|
- JSON: {"type": ["h-entry"], "properties": {"content": ["value"]}}
|
|
- Form: {content: ["value"], "category[]": ["tag1", "tag2"]}
|
|
|
|
Args:
|
|
data: Raw request data (form dict or JSON dict)
|
|
|
|
Returns:
|
|
Normalized properties dict with all values as lists
|
|
"""
|
|
# JSON format has properties nested
|
|
if "properties" in data:
|
|
return data["properties"]
|
|
|
|
# Form format - convert to properties dict
|
|
properties = {}
|
|
for key, value in data.items():
|
|
# Skip reserved Micropub parameters
|
|
if key.startswith("mp-") or key in ["action", "url", "access_token", "h"]:
|
|
continue
|
|
|
|
# Handle array notation: property[] -> property
|
|
clean_key = key.rstrip("[]")
|
|
|
|
# Ensure value is always a list
|
|
if not isinstance(value, list):
|
|
value = [value]
|
|
|
|
properties[clean_key] = value
|
|
|
|
return properties
|
|
|
|
|
|
# Property Extraction
|
|
|
|
|
|
def extract_content(properties: dict) -> str:
|
|
"""
|
|
Extract content from Micropub properties
|
|
|
|
Args:
|
|
properties: Normalized Micropub properties dict
|
|
|
|
Returns:
|
|
Content string
|
|
|
|
Raises:
|
|
MicropubValidationError: If content is missing or empty
|
|
"""
|
|
content_list = properties.get("content", [])
|
|
|
|
# Handle both plain text and HTML/text objects
|
|
if not content_list:
|
|
raise MicropubValidationError("Content is required")
|
|
|
|
content = content_list[0]
|
|
|
|
# Handle structured content ({"html": "...", "text": "..."})
|
|
if isinstance(content, dict):
|
|
# Prefer text over html for markdown storage
|
|
content = content.get("text") or content.get("html", "")
|
|
|
|
if not content or not content.strip():
|
|
raise MicropubValidationError("Content cannot be empty")
|
|
|
|
return content.strip()
|
|
|
|
|
|
def extract_title(properties: dict) -> Optional[str]:
|
|
"""
|
|
Extract or generate title from Micropub properties
|
|
|
|
Per ADR-029 mapping rules:
|
|
1. Use 'name' property if provided
|
|
2. If no name, extract from content (first line, max 50 chars)
|
|
|
|
Args:
|
|
properties: Normalized Micropub properties dict
|
|
|
|
Returns:
|
|
Title string or None
|
|
"""
|
|
# Try explicit name property first
|
|
name = properties.get("name", [""])[0]
|
|
if name:
|
|
return name.strip()
|
|
|
|
# Generate from content (first line, max 50 chars)
|
|
content_list = properties.get("content", [])
|
|
if content_list:
|
|
content = content_list[0]
|
|
# Handle structured content
|
|
if isinstance(content, dict):
|
|
content = content.get("text") or content.get("html", "")
|
|
|
|
if content:
|
|
first_line = content.split("\n")[0].strip()
|
|
if len(first_line) > 50:
|
|
return first_line[:50] + "..."
|
|
return first_line
|
|
|
|
return None
|
|
|
|
|
|
def extract_tags(properties: dict) -> list[str]:
|
|
"""
|
|
Extract tags from Micropub category property
|
|
|
|
Args:
|
|
properties: Normalized Micropub properties dict
|
|
|
|
Returns:
|
|
List of tag strings
|
|
"""
|
|
categories = properties.get("category", [])
|
|
# Filter out empty strings and strip whitespace
|
|
return [tag.strip() for tag in categories if tag and tag.strip()]
|
|
|
|
|
|
def extract_published_date(properties: dict) -> Optional[datetime]:
|
|
"""
|
|
Extract published date from Micropub properties
|
|
|
|
Args:
|
|
properties: Normalized Micropub properties dict
|
|
|
|
Returns:
|
|
Datetime object if published date provided, None otherwise
|
|
"""
|
|
published = properties.get("published", [""])[0]
|
|
if not published:
|
|
return None
|
|
|
|
try:
|
|
# Parse ISO 8601 datetime
|
|
# datetime.fromisoformat handles most ISO formats
|
|
return datetime.fromisoformat(published.replace("Z", "+00:00"))
|
|
except (ValueError, AttributeError):
|
|
# If parsing fails, log and return None (will use current time)
|
|
current_app.logger.warning(f"Failed to parse published date: {published}")
|
|
return None
|
|
|
|
|
|
# Action Handlers
|
|
|
|
|
|
def handle_create(data: dict, token_info: dict):
|
|
"""
|
|
Handle Micropub create action
|
|
|
|
Creates a note using StarPunk's notes.py CRUD functions after
|
|
mapping Micropub properties to StarPunk's note format.
|
|
|
|
Args:
|
|
data: Raw request data (form or JSON)
|
|
token_info: Authenticated token information (me, client_id, scope)
|
|
|
|
Returns:
|
|
Tuple of (response_body, status_code, headers)
|
|
|
|
Raises:
|
|
MicropubError: If scope insufficient or creation fails
|
|
"""
|
|
# Check scope
|
|
if not check_scope("create", token_info.get("scope", "")):
|
|
raise MicropubError(
|
|
"insufficient_scope", "Token lacks create scope", status_code=403
|
|
)
|
|
|
|
# Normalize and extract properties
|
|
try:
|
|
properties = normalize_properties(data)
|
|
content = extract_content(properties)
|
|
title = extract_title(properties)
|
|
tags = extract_tags(properties)
|
|
published_date = extract_published_date(properties)
|
|
except MicropubValidationError as e:
|
|
raise e
|
|
except Exception as e:
|
|
current_app.logger.error(f"Property extraction failed: {e}")
|
|
raise MicropubValidationError(f"Failed to parse request: {str(e)}")
|
|
|
|
# Create note using existing CRUD
|
|
try:
|
|
note = create_note(
|
|
content=content, published=True, created_at=published_date # Micropub posts are published by default
|
|
)
|
|
|
|
# Build permalink URL
|
|
site_url = current_app.config.get("SITE_URL", "http://localhost:5000")
|
|
permalink = f"{site_url}/notes/{note.slug}"
|
|
|
|
# Return 201 Created with Location header
|
|
return "", 201, {"Location": permalink}
|
|
|
|
except InvalidNoteDataError as e:
|
|
raise MicropubValidationError(str(e))
|
|
except Exception as e:
|
|
current_app.logger.error(f"Failed to create note via Micropub: {e}")
|
|
raise MicropubError(
|
|
"server_error", "Failed to create post", status_code=500
|
|
)
|
|
|
|
|
|
def handle_query(args: dict, token_info: dict):
|
|
"""
|
|
Handle Micropub query endpoints
|
|
|
|
Supports:
|
|
- q=config: Return server configuration
|
|
- q=source: Return post source in Microformats2 JSON
|
|
- q=syndicate-to: Return syndication targets (empty for V1)
|
|
|
|
Args:
|
|
args: Query string arguments
|
|
token_info: Authenticated token information
|
|
|
|
Returns:
|
|
Tuple of (response, status_code)
|
|
"""
|
|
q = args.get("q")
|
|
|
|
if q == "config":
|
|
# Return server configuration
|
|
config = {
|
|
"media-endpoint": None, # No media endpoint in V1
|
|
"syndicate-to": [], # No syndication targets in V1
|
|
"post-types": [{"type": "note", "name": "Note", "properties": ["content"]}],
|
|
}
|
|
return jsonify(config), 200
|
|
|
|
elif q == "source":
|
|
# Return source of a specific post
|
|
url = args.get("url")
|
|
if not url:
|
|
return error_response("invalid_request", "No URL provided")
|
|
|
|
# Extract slug from URL
|
|
try:
|
|
# URL format: https://example.com/notes/{slug}
|
|
slug = url.rstrip("/").split("/")[-1]
|
|
note = get_note(slug)
|
|
|
|
# Check if note exists
|
|
if note is None:
|
|
return error_response("invalid_request", "Post not found")
|
|
|
|
except NoteNotFoundError:
|
|
return error_response("invalid_request", "Post not found")
|
|
except Exception as e:
|
|
current_app.logger.error(f"Failed to get note source: {e}")
|
|
return error_response("server_error", "Failed to retrieve post")
|
|
|
|
# Convert note to Micropub Microformats2 format
|
|
site_url = current_app.config.get("SITE_URL", "http://localhost:5000")
|
|
mf2 = {
|
|
"type": ["h-entry"],
|
|
"properties": {
|
|
"content": [note.content],
|
|
"published": [note.created_at.isoformat()],
|
|
"url": [f"{site_url}/notes/{note.slug}"],
|
|
},
|
|
}
|
|
|
|
# Add optional properties
|
|
if note.title:
|
|
mf2["properties"]["name"] = [note.title]
|
|
|
|
# Tags not implemented in V1, skip category property
|
|
# if hasattr(note, 'tags') and note.tags:
|
|
# mf2["properties"]["category"] = note.tags
|
|
|
|
return jsonify(mf2), 200
|
|
|
|
elif q == "syndicate-to":
|
|
# Return syndication targets (none for V1)
|
|
return jsonify({"syndicate-to": []}), 200
|
|
|
|
else:
|
|
return error_response("invalid_request", f"Unknown query: {q}")
|