feat: Implement Phase 4 Web Interface with bugfixes (v0.5.2)

## Phase 4: Web Interface Implementation

Implemented complete web interface with public and admin routes,
templates, CSS, and development authentication.

### Core Features

**Public Routes**:
- Homepage with recent published notes
- Note permalinks with microformats2
- Server-side rendering (Jinja2)

**Admin Routes**:
- Login via IndieLogin
- Dashboard with note management
- Create, edit, delete notes
- Protected with @require_auth decorator

**Development Authentication**:
- Dev login bypass for local testing (DEV_MODE only)
- Security safeguards per ADR-011
- Returns 404 when disabled

**Templates & Frontend**:
- Base layouts (public + admin)
- 8 HTML templates with microformats2
- Custom responsive CSS (114 lines)
- Error pages (404, 500)

### Bugfixes (v0.5.1 → v0.5.2)

1. **Cookie collision fix (v0.5.1)**:
   - Renamed auth cookie from "session" to "starpunk_session"
   - Fixed redirect loop between dev login and admin dashboard
   - Flask's session cookie no longer conflicts with auth

2. **HTTP 404 error handling (v0.5.1)**:
   - Update route now returns 404 for nonexistent notes
   - Delete route now returns 404 for nonexistent notes
   - Follows ADR-012 HTTP Error Handling Policy
   - Pattern consistency across all admin routes

3. **Note model enhancement (v0.5.2)**:
   - Exposed deleted_at field from database schema
   - Enables soft deletion verification in tests
   - Follows ADR-013 transparency principle

### Architecture

**New ADRs**:
- ADR-011: Development Authentication Mechanism
- ADR-012: HTTP Error Handling Policy
- ADR-013: Expose deleted_at Field in Note Model

**Standards Compliance**:
- Uses uv for Python environment
- Black formatted, Flake8 clean
- Follows git branching strategy
- Version incremented per versioning strategy

### Test Results

- 405/406 tests passing (99.75%)
- 87% code coverage
- All security tests passing
- Manual testing confirmed working

### Documentation

- Complete implementation reports in docs/reports/
- Architecture reviews in docs/reviews/
- Design documents in docs/design/
- CHANGELOG updated for v0.5.2

### Files Changed

**New Modules**:
- starpunk/dev_auth.py
- starpunk/routes/ (public, admin, auth, dev_auth)

**Templates**: 10 files (base, pages, admin, errors)
**Static**: CSS and optional JavaScript
**Tests**: 4 test files for routes and templates
**Docs**: 20+ architectural and implementation documents

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-18 23:01:53 -07:00
parent 575a02186b
commit 0cca8169ce
56 changed files with 13151 additions and 304 deletions

View File

@@ -0,0 +1,47 @@
"""
Route registration module for StarPunk
This module handles registration of all route blueprints including public,
admin, auth, and (conditionally) dev auth routes.
"""
from flask import Flask
from starpunk.routes import admin, auth, public
def register_routes(app: Flask) -> None:
"""
Register all route blueprints with the Flask app
Args:
app: Flask application instance
Registers:
- Public routes (homepage, note permalinks)
- Auth routes (login, callback, logout)
- Admin routes (dashboard, note management)
- Dev auth routes (if DEV_MODE enabled)
"""
# Register public routes
app.register_blueprint(public.bp)
# Register auth routes
app.register_blueprint(auth.bp)
# Register admin routes
app.register_blueprint(admin.bp)
# Conditionally register dev auth routes
if app.config.get("DEV_MODE"):
app.logger.warning(
"=" * 60
+ "\n"
+ "WARNING: Development authentication enabled!\n"
+ "This should NEVER be used in production.\n"
+ "Set DEV_MODE=false for production deployments.\n"
+ "=" * 60
)
from starpunk.routes import dev_auth
app.register_blueprint(dev_auth.bp)

212
starpunk/routes/admin.py Normal file
View File

@@ -0,0 +1,212 @@
"""
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, redirect, render_template, request, url_for
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)
Returns:
Redirect to dashboard on success, back to form on error
Decorator: @require_auth
"""
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.new_note_form"))
try:
note = create_note(content, published=published)
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"))

181
starpunk/routes/auth.py Normal file
View File

@@ -0,0 +1,181 @@
"""
Authentication routes for StarPunk
Handles IndieLogin authentication flow including login form, OAuth callback,
and logout functionality.
"""
from flask import (
Blueprint,
current_app,
flash,
redirect,
render_template,
request,
url_for,
)
from starpunk.auth import (
IndieLoginError,
InvalidStateError,
UnauthorizedError,
destroy_session,
handle_callback,
initiate_login,
require_auth,
verify_session,
)
# Create blueprint
bp = Blueprint("auth", __name__, url_prefix="/admin")
@bp.route("/login", methods=["GET"])
def login_form():
"""
Display login form
If user is already authenticated, redirects to admin dashboard.
Otherwise shows login form for IndieLogin authentication.
Returns:
Redirect to dashboard if authenticated, otherwise login template
Template: templates/admin/login.html
"""
# Check if already logged in
session_token = request.cookies.get("starpunk_session")
if session_token and verify_session(session_token):
return redirect(url_for("admin.dashboard"))
return render_template("admin/login.html")
@bp.route("/login", methods=["POST"])
def login_initiate():
"""
Initiate IndieLogin authentication flow
Validates the submitted 'me' URL and redirects to IndieLogin.com
for authentication.
Form data:
me: User's personal website URL
Returns:
Redirect to IndieLogin.com or back to login form on error
Raises:
Flashes error message and redirects on validation failure
"""
me_url = request.form.get("me", "").strip()
if not me_url:
flash("Please enter your website URL", "error")
return redirect(url_for("auth.login_form"))
try:
# Initiate IndieLogin flow
auth_url = initiate_login(me_url)
return redirect(auth_url)
except ValueError as e:
flash(str(e), "error")
return redirect(url_for("auth.login_form"))
@bp.route("/callback")
def callback():
"""
Handle IndieLogin callback
Processes the OAuth callback from IndieLogin.com, validates the
authorization code and state token, and creates an authenticated session.
Query parameters:
code: Authorization code from IndieLogin
state: CSRF state token
Returns:
Redirect to admin dashboard on success, login form on failure
Sets:
session cookie (HttpOnly, Secure, SameSite=Lax, 30 day expiry)
"""
code = request.args.get("code")
state = request.args.get("state")
if not code or not state:
flash("Missing authentication parameters", "error")
return redirect(url_for("auth.login_form"))
try:
# Handle callback and create session
session_token = handle_callback(code, state)
# Create response with redirect
response = redirect(url_for("admin.dashboard"))
# Set secure session cookie
secure = current_app.config.get("SITE_URL", "").startswith("https://")
response.set_cookie(
"starpunk_session",
session_token,
httponly=True,
secure=secure,
samesite="Lax",
max_age=30 * 24 * 60 * 60, # 30 days
)
flash("Login successful!", "success")
return response
except InvalidStateError as e:
current_app.logger.error(f"Invalid state error: {e}")
flash("Authentication failed: Invalid state token (possible CSRF)", "error")
return redirect(url_for("auth.login_form"))
except UnauthorizedError as e:
current_app.logger.error(f"Unauthorized: {e}")
flash("Authentication failed: Not authorized as admin", "error")
return redirect(url_for("auth.login_form"))
except IndieLoginError as e:
current_app.logger.error(f"IndieLogin error: {e}")
flash(f"Authentication failed: {e}", "error")
return redirect(url_for("auth.login_form"))
except Exception as e:
current_app.logger.error(f"Unexpected auth error: {e}")
flash("Authentication failed: An unexpected error occurred", "error")
return redirect(url_for("auth.login_form"))
@bp.route("/logout", methods=["POST"])
@require_auth
def logout():
"""
Logout and destroy session
Destroys the user's session and clears the session cookie.
Requires authentication (user must be logged in to logout).
Returns:
Redirect to homepage with session cookie cleared
Decorator: @require_auth
"""
session_token = request.cookies.get("starpunk_session")
# Destroy session in database
if session_token:
try:
destroy_session(session_token)
except Exception as e:
current_app.logger.error(f"Error destroying session: {e}")
# Clear cookie and redirect
response = redirect(url_for("public.index"))
response.delete_cookie("starpunk_session")
flash("Logged out successfully", "success")
return response

View File

@@ -0,0 +1,84 @@
"""
Development authentication routes for StarPunk
WARNING: These routes provide instant authentication bypass for local development.
They are ONLY registered when DEV_MODE=true and return 404 otherwise.
This file contains routes that should never be accessible in production.
"""
from flask import Blueprint, abort, current_app, flash, redirect, url_for
from starpunk.dev_auth import create_dev_session, is_dev_mode
# Create blueprint
bp = Blueprint("dev_auth", __name__, url_prefix="/dev")
@bp.before_request
def check_dev_mode():
"""
Security guard: Block all dev auth routes if DEV_MODE is disabled
This executes before every request to dev auth routes.
Returns 404 if DEV_MODE is not explicitly enabled.
Returns:
None if DEV_MODE is enabled, 404 abort otherwise
Security:
This is the primary safeguard preventing dev auth in production.
Even if routes are accidentally registered, they will return 404.
"""
if not is_dev_mode():
# Return 404 - dev routes don't exist in production
abort(404)
@bp.route("/login", methods=["GET", "POST"])
def dev_login():
"""
Instant development login (no authentication required)
WARNING: This creates an authenticated session WITHOUT any verification.
Only accessible when DEV_MODE=true.
Returns:
Redirect to admin dashboard with session cookie set
Sets:
session cookie (HttpOnly, NOT Secure in dev mode, 30 day expiry)
Logs:
WARNING: Logs that dev authentication was used
Security:
- Blocked by before_request if DEV_MODE=false
- Logs warning on every use
- Creates session for DEV_ADMIN_ME identity
"""
# Get configured dev admin identity
me = current_app.config.get("DEV_ADMIN_ME")
if not me:
flash("DEV_MODE misconfiguration: DEV_ADMIN_ME not set", "error")
return redirect(url_for("auth.login_form"))
# Create session without authentication
session_token = create_dev_session(me)
# Create response with redirect
response = redirect(url_for("admin.dashboard"))
# Set session cookie (NOT secure in dev mode)
response.set_cookie(
"starpunk_session",
session_token,
httponly=True,
secure=False, # Allow HTTP in development
samesite="Lax",
max_age=30 * 24 * 60 * 60, # 30 days
)
flash("DEV MODE: Logged in without authentication", "warning")
return response

57
starpunk/routes/public.py Normal file
View File

@@ -0,0 +1,57 @@
"""
Public routes for StarPunk
Handles public-facing pages including homepage and note permalinks.
No authentication required for these routes.
"""
from flask import Blueprint, abort, render_template
from starpunk.notes import list_notes, get_note
# Create blueprint
bp = Blueprint("public", __name__)
@bp.route("/")
def index():
"""
Homepage displaying recent published notes
Returns:
Rendered homepage template with note list
Template: templates/index.html
Microformats: h-feed containing h-entry items
"""
# Get recent published notes (limit 20)
notes = list_notes(published_only=True, limit=20)
return render_template("index.html", notes=notes)
@bp.route("/note/<slug>")
def note(slug: str):
"""
Individual note permalink page
Args:
slug: URL-safe note identifier
Returns:
Rendered note template with full content
Raises:
404: If note not found or not published
Template: templates/note.html
Microformats: h-entry
"""
# Get note by slug
note_obj = get_note(slug=slug)
# Return 404 if note doesn't exist or isn't published
if not note_obj or not note_obj.published:
abort(404)
return render_template("note.html", note=note_obj)