Complete implementation of v1.2.0 "IndieWeb Features" release. ## Phase 1: Custom Slugs - Optional custom slug field in note creation form - Auto-sanitization (lowercase, hyphens only) - Uniqueness validation with auto-numbering - Read-only after creation to preserve permalinks - Matches Micropub mp-slug behavior ## Phase 2: Author Discovery + Microformats2 - Automatic h-card discovery from IndieAuth identity URL - 24-hour caching with graceful fallback - Never blocks login (per ADR-061) - Complete h-entry, h-card, h-feed markup - All required Microformats2 properties - rel-me links for identity verification - Passes IndieWeb validation ## Phase 3: Media Upload - Upload up to 4 images per note (JPEG, PNG, GIF, WebP) - Automatic optimization with Pillow - Auto-resize to 2048px - EXIF orientation correction - 95% quality compression - Social media-style layout (media top, text below) - Optional captions for accessibility - Integration with all feed formats (RSS, ATOM, JSON Feed) - Date-organized storage with UUID filenames - Immutable caching (1 year) ## Database Changes - migrations/006_add_author_profile.sql - Author discovery cache - migrations/007_add_media_support.sql - Media storage ## New Modules - starpunk/author_discovery.py - h-card discovery and caching - starpunk/media.py - Image upload, validation, optimization ## Documentation - 4 new ADRs (056, 057, 058, 061) - Complete design specifications - Developer Q&A with 40+ questions answered - 3 implementation reports - 3 architect reviews (all approved) ## Testing - 56 new tests for v1.2.0 features - 842 total tests in suite - All v1.2.0 feature tests passing ## Dependencies - Added: mf2py (Microformats2 parser) - Added: Pillow (image processing) Version: 1.2.0-rc.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
578 lines
17 KiB
Python
578 lines
17 KiB
Python
"""
|
|
Admin routes for StarPunk
|
|
|
|
Handles authenticated admin functionality including dashboard, note creation,
|
|
editing, and deletion. All routes require authentication.
|
|
"""
|
|
|
|
from flask import Blueprint, flash, g, jsonify, redirect, render_template, request, url_for
|
|
import os
|
|
import shutil
|
|
from datetime import datetime
|
|
|
|
from starpunk.auth import require_auth
|
|
from starpunk.notes import (
|
|
create_note,
|
|
delete_note,
|
|
list_notes,
|
|
get_note,
|
|
update_note,
|
|
)
|
|
|
|
# Create blueprint
|
|
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
|
|
|
|
|
@bp.route("/")
|
|
@require_auth
|
|
def dashboard():
|
|
"""
|
|
Admin dashboard with note list
|
|
|
|
Displays all notes (published and drafts) with management controls.
|
|
Requires authentication.
|
|
|
|
Returns:
|
|
Rendered dashboard template with complete note list
|
|
|
|
Decorator: @require_auth
|
|
Template: templates/admin/dashboard.html
|
|
Access: g.user_me (set by require_auth decorator)
|
|
"""
|
|
# Get all notes (published and drafts)
|
|
notes = list_notes()
|
|
|
|
return render_template("admin/dashboard.html", notes=notes, user_me=g.me)
|
|
|
|
|
|
@bp.route("/new", methods=["GET"])
|
|
@require_auth
|
|
def new_note_form():
|
|
"""
|
|
Display create note form
|
|
|
|
Shows empty form for creating a new note.
|
|
Requires authentication.
|
|
|
|
Returns:
|
|
Rendered new note form template
|
|
|
|
Decorator: @require_auth
|
|
Template: templates/admin/new.html
|
|
"""
|
|
return render_template("admin/new.html")
|
|
|
|
|
|
@bp.route("/new", methods=["POST"])
|
|
@require_auth
|
|
def create_note_submit():
|
|
"""
|
|
Handle new note submission
|
|
|
|
Creates a new note from submitted form data.
|
|
Requires authentication.
|
|
|
|
Form data:
|
|
content: Markdown content (required)
|
|
published: Checkbox for published status (optional)
|
|
custom_slug: Optional custom slug (v1.2.0 Phase 1)
|
|
media_files: Multiple file upload (v1.2.0 Phase 3)
|
|
captions[]: Captions for each media file (v1.2.0 Phase 3)
|
|
|
|
Returns:
|
|
Redirect to dashboard on success, back to form on error
|
|
|
|
Decorator: @require_auth
|
|
"""
|
|
from starpunk.media import save_media, attach_media_to_note
|
|
|
|
content = request.form.get("content", "").strip()
|
|
published = "published" in request.form
|
|
custom_slug = request.form.get("custom_slug", "").strip()
|
|
|
|
if not content:
|
|
flash("Content cannot be empty", "error")
|
|
return redirect(url_for("admin.new_note_form"))
|
|
|
|
try:
|
|
# Create note first (per Q4)
|
|
note = create_note(
|
|
content,
|
|
published=published,
|
|
custom_slug=custom_slug if custom_slug else None
|
|
)
|
|
|
|
# Handle media uploads (v1.2.0 Phase 3)
|
|
media_files = request.files.getlist('media_files')
|
|
captions = request.form.getlist('captions[]')
|
|
|
|
if media_files and any(f.filename for f in media_files):
|
|
# Per Q35: Accept valid, reject invalid (not atomic)
|
|
media_ids = []
|
|
errors = []
|
|
|
|
for i, file in enumerate(media_files):
|
|
if not file.filename:
|
|
continue
|
|
|
|
try:
|
|
# Read file data
|
|
file_data = file.read()
|
|
|
|
# Save and optimize media
|
|
media_info = save_media(file_data, file.filename)
|
|
media_ids.append(media_info['id'])
|
|
except ValueError as e:
|
|
errors.append(f"{file.filename}: {str(e)}")
|
|
except Exception as e:
|
|
errors.append(f"{file.filename}: Upload failed")
|
|
|
|
if media_ids:
|
|
# Ensure captions list matches media_ids length
|
|
while len(captions) < len(media_ids):
|
|
captions.append('')
|
|
|
|
# Attach media to note
|
|
attach_media_to_note(note.id, media_ids, captions[:len(media_ids)])
|
|
|
|
if errors:
|
|
flash(f"Note created, but some images failed: {'; '.join(errors)}", "warning")
|
|
|
|
flash(f"Note created: {note.slug}", "success")
|
|
return redirect(url_for("admin.dashboard"))
|
|
|
|
except ValueError as e:
|
|
flash(f"Error creating note: {e}", "error")
|
|
return redirect(url_for("admin.new_note_form"))
|
|
except Exception as e:
|
|
flash(f"Unexpected error creating note: {e}", "error")
|
|
return redirect(url_for("admin.new_note_form"))
|
|
|
|
|
|
@bp.route("/edit/<int:note_id>", methods=["GET"])
|
|
@require_auth
|
|
def edit_note_form(note_id: int):
|
|
"""
|
|
Display edit note form
|
|
|
|
Shows form pre-filled with existing note content for editing.
|
|
Requires authentication.
|
|
|
|
Args:
|
|
note_id: Database ID of note to edit
|
|
|
|
Returns:
|
|
Rendered edit form template or 404 if note not found
|
|
|
|
Decorator: @require_auth
|
|
Template: templates/admin/edit.html
|
|
"""
|
|
note = get_note(id=note_id)
|
|
|
|
if not note:
|
|
flash("Note not found", "error")
|
|
return redirect(url_for("admin.dashboard")), 404
|
|
|
|
return render_template("admin/edit.html", note=note)
|
|
|
|
|
|
@bp.route("/edit/<int:note_id>", methods=["POST"])
|
|
@require_auth
|
|
def update_note_submit(note_id: int):
|
|
"""
|
|
Handle note update submission
|
|
|
|
Updates existing note with submitted form data.
|
|
Requires authentication.
|
|
|
|
Args:
|
|
note_id: Database ID of note to update
|
|
|
|
Form data:
|
|
content: Updated markdown content (required)
|
|
published: Checkbox for published status (optional)
|
|
|
|
Returns:
|
|
Redirect to dashboard on success, back to form on error
|
|
|
|
Decorator: @require_auth
|
|
"""
|
|
# Check if note exists first
|
|
existing_note = get_note(id=note_id, load_content=False)
|
|
if not existing_note:
|
|
flash("Note not found", "error")
|
|
return redirect(url_for("admin.dashboard")), 404
|
|
|
|
content = request.form.get("content", "").strip()
|
|
published = "published" in request.form
|
|
|
|
if not content:
|
|
flash("Content cannot be empty", "error")
|
|
return redirect(url_for("admin.edit_note_form", note_id=note_id))
|
|
|
|
try:
|
|
note = update_note(id=note_id, content=content, published=published)
|
|
flash(f"Note updated: {note.slug}", "success")
|
|
return redirect(url_for("admin.dashboard"))
|
|
except ValueError as e:
|
|
flash(f"Error updating note: {e}", "error")
|
|
return redirect(url_for("admin.edit_note_form", note_id=note_id))
|
|
except Exception as e:
|
|
flash(f"Unexpected error updating note: {e}", "error")
|
|
return redirect(url_for("admin.edit_note_form", note_id=note_id))
|
|
|
|
|
|
@bp.route("/delete/<int:note_id>", methods=["POST"])
|
|
@require_auth
|
|
def delete_note_submit(note_id: int):
|
|
"""
|
|
Handle note deletion
|
|
|
|
Deletes a note after confirmation.
|
|
Requires authentication.
|
|
|
|
Args:
|
|
note_id: Database ID of note to delete
|
|
|
|
Form data:
|
|
confirm: Must be 'yes' to proceed with deletion
|
|
|
|
Returns:
|
|
Redirect to dashboard with success/error message
|
|
|
|
Decorator: @require_auth
|
|
"""
|
|
# Check if note exists first (per ADR-012)
|
|
existing_note = get_note(id=note_id, load_content=False)
|
|
if not existing_note:
|
|
flash("Note not found", "error")
|
|
return redirect(url_for("admin.dashboard")), 404
|
|
|
|
# Check for confirmation
|
|
if request.form.get("confirm") != "yes":
|
|
flash("Deletion cancelled", "info")
|
|
return redirect(url_for("admin.dashboard"))
|
|
|
|
try:
|
|
delete_note(id=note_id, soft=False)
|
|
flash("Note deleted successfully", "success")
|
|
except ValueError as e:
|
|
flash(f"Error deleting note: {e}", "error")
|
|
except Exception as e:
|
|
flash(f"Unexpected error deleting note: {e}", "error")
|
|
|
|
return redirect(url_for("admin.dashboard"))
|
|
|
|
|
|
def transform_metrics_for_template(metrics_stats):
|
|
"""
|
|
Transform metrics stats to match template structure
|
|
|
|
The template expects direct access to metrics.database.count, but
|
|
get_metrics_stats() returns metrics.by_type.database.count.
|
|
This function adapts the data structure to match template expectations.
|
|
|
|
Args:
|
|
metrics_stats: Dict from get_metrics_stats() with nested by_type structure
|
|
|
|
Returns:
|
|
Dict with flattened structure matching template expectations
|
|
|
|
Per ADR-060: Route Adapter Pattern for template compatibility
|
|
"""
|
|
transformed = {}
|
|
|
|
# Map by_type to direct access
|
|
for op_type in ['database', 'http', 'render']:
|
|
if 'by_type' in metrics_stats and op_type in metrics_stats['by_type']:
|
|
type_data = metrics_stats['by_type'][op_type]
|
|
transformed[op_type] = {
|
|
'count': type_data.get('count', 0),
|
|
'avg': type_data.get('avg_duration_ms', 0),
|
|
'min': type_data.get('min_duration_ms', 0),
|
|
'max': type_data.get('max_duration_ms', 0)
|
|
}
|
|
else:
|
|
# Provide defaults for missing types or when by_type doesn't exist
|
|
transformed[op_type] = {
|
|
'count': 0,
|
|
'avg': 0,
|
|
'min': 0,
|
|
'max': 0
|
|
}
|
|
|
|
# Keep other top-level stats
|
|
transformed['total_count'] = metrics_stats.get('total_count', 0)
|
|
transformed['max_size'] = metrics_stats.get('max_size', 1000)
|
|
transformed['process_id'] = metrics_stats.get('process_id', 0)
|
|
|
|
return transformed
|
|
|
|
|
|
@bp.route("/metrics-dashboard")
|
|
@require_auth
|
|
def metrics_dashboard():
|
|
"""
|
|
Metrics visualization dashboard (Phase 3)
|
|
|
|
Displays performance metrics, database statistics, feed statistics,
|
|
and system health with visual charts and auto-refresh capability.
|
|
|
|
Per Q19 requirements:
|
|
- Server-side rendering with Jinja2
|
|
- htmx for auto-refresh
|
|
- Chart.js from CDN for graphs
|
|
- Progressive enhancement (works without JS)
|
|
|
|
Per v1.1.2 Phase 3:
|
|
- Feed statistics by format
|
|
- Cache hit/miss rates
|
|
- Format popularity breakdown
|
|
|
|
Returns:
|
|
Rendered dashboard template with metrics
|
|
|
|
Decorator: @require_auth
|
|
Template: templates/admin/metrics_dashboard.html
|
|
"""
|
|
# Defensive imports with graceful degradation for missing modules
|
|
try:
|
|
from starpunk.database.pool import get_pool_stats
|
|
from starpunk.monitoring import get_metrics_stats
|
|
from starpunk.monitoring.business import get_feed_statistics
|
|
monitoring_available = True
|
|
except ImportError:
|
|
monitoring_available = False
|
|
# Provide fallback functions that return error messages
|
|
def get_pool_stats():
|
|
return {"error": "Database pool monitoring not available"}
|
|
def get_metrics_stats():
|
|
return {"error": "Monitoring module not implemented"}
|
|
def get_feed_statistics():
|
|
return {"error": "Feed statistics not available"}
|
|
|
|
# Get current metrics for initial page load
|
|
metrics_data = {}
|
|
pool_stats = {}
|
|
feed_stats = {}
|
|
|
|
try:
|
|
raw_metrics = get_metrics_stats()
|
|
metrics_data = transform_metrics_for_template(raw_metrics)
|
|
except Exception as e:
|
|
flash(f"Error loading metrics: {e}", "warning")
|
|
# Provide safe defaults matching template expectations
|
|
metrics_data = {
|
|
'database': {'count': 0, 'avg': 0, 'min': 0, 'max': 0},
|
|
'http': {'count': 0, 'avg': 0, 'min': 0, 'max': 0},
|
|
'render': {'count': 0, 'avg': 0, 'min': 0, 'max': 0},
|
|
'total_count': 0,
|
|
'max_size': 1000,
|
|
'process_id': 0
|
|
}
|
|
|
|
try:
|
|
pool_stats = get_pool_stats()
|
|
except Exception as e:
|
|
flash(f"Error loading pool stats: {e}", "warning")
|
|
|
|
try:
|
|
feed_stats = get_feed_statistics()
|
|
except Exception as e:
|
|
flash(f"Error loading feed stats: {e}", "warning")
|
|
# Provide safe defaults
|
|
feed_stats = {
|
|
'by_format': {
|
|
'rss': {'generated': 0, 'cached': 0, 'total': 0, 'avg_duration_ms': 0.0},
|
|
'atom': {'generated': 0, 'cached': 0, 'total': 0, 'avg_duration_ms': 0.0},
|
|
'json': {'generated': 0, 'cached': 0, 'total': 0, 'avg_duration_ms': 0.0},
|
|
},
|
|
'cache': {'hits': 0, 'misses': 0, 'hit_rate': 0.0, 'entries': 0, 'evictions': 0},
|
|
'total_requests': 0,
|
|
'format_percentages': {'rss': 0.0, 'atom': 0.0, 'json': 0.0},
|
|
}
|
|
|
|
return render_template(
|
|
"admin/metrics_dashboard.html",
|
|
metrics=metrics_data,
|
|
pool=pool_stats,
|
|
feeds=feed_stats,
|
|
user_me=g.me
|
|
)
|
|
|
|
|
|
@bp.route("/metrics")
|
|
@require_auth
|
|
def metrics():
|
|
"""
|
|
Performance metrics and database pool statistics endpoint
|
|
|
|
Per Phase 2 requirements:
|
|
- Expose database pool statistics
|
|
- Show performance metrics from MetricsBuffer
|
|
- Requires authentication
|
|
|
|
Per v1.1.2 Phase 3:
|
|
- Include feed statistics
|
|
|
|
Returns:
|
|
JSON with metrics, pool statistics, and feed statistics
|
|
|
|
Response codes:
|
|
200: Metrics retrieved successfully
|
|
|
|
Decorator: @require_auth
|
|
"""
|
|
from flask import current_app
|
|
from starpunk.database.pool import get_pool_stats
|
|
from starpunk.monitoring import get_metrics_stats
|
|
from starpunk.monitoring.business import get_feed_statistics
|
|
|
|
response = {
|
|
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
"process_id": os.getpid(),
|
|
"database": {},
|
|
"performance": {},
|
|
"feeds": {}
|
|
}
|
|
|
|
# Get database pool statistics
|
|
try:
|
|
pool_stats = get_pool_stats()
|
|
response["database"]["pool"] = pool_stats
|
|
except Exception as e:
|
|
response["database"]["pool"] = {"error": str(e)}
|
|
|
|
# Get performance metrics
|
|
try:
|
|
metrics_stats = get_metrics_stats()
|
|
response["performance"] = metrics_stats
|
|
except Exception as e:
|
|
response["performance"] = {"error": str(e)}
|
|
|
|
# Get feed statistics
|
|
try:
|
|
feed_stats = get_feed_statistics()
|
|
response["feeds"] = feed_stats
|
|
except Exception as e:
|
|
response["feeds"] = {"error": str(e)}
|
|
|
|
return jsonify(response), 200
|
|
|
|
|
|
@bp.route("/health")
|
|
@require_auth
|
|
def health_diagnostics():
|
|
"""
|
|
Full health diagnostics endpoint for admin use
|
|
|
|
Per developer Q&A Q10:
|
|
- Always requires authentication
|
|
- Provides comprehensive diagnostics
|
|
- Includes metrics, database pool statistics, and system info
|
|
|
|
Returns:
|
|
JSON with complete system diagnostics
|
|
|
|
Response codes:
|
|
200: Diagnostics retrieved successfully
|
|
500: Critical health issues detected
|
|
|
|
Decorator: @require_auth
|
|
"""
|
|
from flask import current_app
|
|
from starpunk.database.pool import get_pool_stats
|
|
|
|
diagnostics = {
|
|
"status": "healthy",
|
|
"version": current_app.config.get("VERSION", "unknown"),
|
|
"environment": current_app.config.get("ENV", "unknown"),
|
|
"process_id": os.getpid(),
|
|
"checks": {},
|
|
"metrics": {},
|
|
"database": {}
|
|
}
|
|
|
|
overall_healthy = True
|
|
|
|
# Database connectivity check
|
|
try:
|
|
from starpunk.database import get_db
|
|
db = get_db()
|
|
result = db.execute("SELECT 1").fetchone()
|
|
db.close()
|
|
diagnostics["checks"]["database"] = {
|
|
"status": "healthy",
|
|
"message": "Database accessible"
|
|
}
|
|
|
|
# Get database pool statistics
|
|
try:
|
|
pool_stats = get_pool_stats()
|
|
diagnostics["database"]["pool"] = pool_stats
|
|
except Exception as e:
|
|
diagnostics["database"]["pool"] = {"error": str(e)}
|
|
|
|
except Exception as e:
|
|
diagnostics["checks"]["database"] = {
|
|
"status": "unhealthy",
|
|
"error": str(e)
|
|
}
|
|
overall_healthy = False
|
|
|
|
# Filesystem check
|
|
try:
|
|
data_path = current_app.config.get("DATA_PATH", "data")
|
|
if not os.path.exists(data_path):
|
|
raise Exception("Data path not accessible")
|
|
|
|
diagnostics["checks"]["filesystem"] = {
|
|
"status": "healthy",
|
|
"path": data_path,
|
|
"writable": os.access(data_path, os.W_OK),
|
|
"readable": os.access(data_path, os.R_OK)
|
|
}
|
|
except Exception as e:
|
|
diagnostics["checks"]["filesystem"] = {
|
|
"status": "unhealthy",
|
|
"error": str(e)
|
|
}
|
|
overall_healthy = False
|
|
|
|
# Disk space check
|
|
try:
|
|
data_path = current_app.config.get("DATA_PATH", "data")
|
|
stat = shutil.disk_usage(data_path)
|
|
percent_free = (stat.free / stat.total) * 100
|
|
|
|
diagnostics["checks"]["disk"] = {
|
|
"status": "healthy" if percent_free > 10 else ("warning" if percent_free > 5 else "critical"),
|
|
"total_gb": round(stat.total / (1024**3), 2),
|
|
"used_gb": round(stat.used / (1024**3), 2),
|
|
"free_gb": round(stat.free / (1024**3), 2),
|
|
"percent_free": round(percent_free, 2),
|
|
"percent_used": round((stat.used / stat.total) * 100, 2)
|
|
}
|
|
|
|
if percent_free <= 5:
|
|
overall_healthy = False
|
|
except Exception as e:
|
|
diagnostics["checks"]["disk"] = {
|
|
"status": "unhealthy",
|
|
"error": str(e)
|
|
}
|
|
overall_healthy = False
|
|
|
|
# Performance metrics
|
|
try:
|
|
from starpunk.monitoring import get_metrics_stats
|
|
metrics_stats = get_metrics_stats()
|
|
diagnostics["metrics"] = metrics_stats
|
|
except Exception as e:
|
|
diagnostics["metrics"] = {"error": str(e)}
|
|
|
|
# Update overall status
|
|
diagnostics["status"] = "healthy" if overall_healthy else "unhealthy"
|
|
|
|
return jsonify(diagnostics), 200 if overall_healthy else 500
|