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

@@ -4,7 +4,6 @@ Creates and configures the Flask application
"""
from flask import Flask
from pathlib import Path
def create_app(config=None):
@@ -17,40 +16,46 @@ def create_app(config=None):
Returns:
Configured Flask application instance
"""
app = Flask(
__name__,
static_folder='../static',
template_folder='../templates'
)
app = Flask(__name__, static_folder="../static", template_folder="../templates")
# Load configuration
from starpunk.config import load_config
load_config(app, config)
# Initialize database
from starpunk.database import init_db
init_db(app)
# Register blueprints
# TODO: Implement blueprints in separate modules
# from starpunk.routes import public, admin, api
# app.register_blueprint(public.bp)
# app.register_blueprint(admin.bp)
# app.register_blueprint(api.bp)
from starpunk.routes import register_routes
register_routes(app)
# Error handlers
@app.errorhandler(404)
def not_found(error):
return {'error': 'Not found'}, 404
from flask import render_template, request
# Return HTML for browser requests, JSON for API requests
if request.path.startswith("/api/"):
return {"error": "Not found"}, 404
return render_template("404.html"), 404
@app.errorhandler(500)
def server_error(error):
return {'error': 'Internal server error'}, 500
from flask import render_template, request
# Return HTML for browser requests, JSON for API requests
if request.path.startswith("/api/"):
return {"error": "Internal server error"}, 500
return render_template("500.html"), 500
return app
# Package version (Semantic Versioning 2.0.0)
# See docs/standards/versioning-strategy.md for details
__version__ = "0.4.0"
__version_info__ = (0, 4, 0)
__version__ = "0.5.1"
__version_info__ = (0, 5, 1)

View File

@@ -387,7 +387,7 @@ def require_auth(f):
@wraps(f)
def decorated_function(*args, **kwargs):
# Get session token from cookie
session_token = request.cookies.get("session")
session_token = request.cookies.get("starpunk_session")
# Verify session
session_info = verify_session(session_token)
@@ -395,7 +395,7 @@ def require_auth(f):
if not session_info:
# Store intended destination
session["next"] = request.url
return redirect(url_for("auth.login"))
return redirect(url_for("auth.login_form"))
# Store user info in g for use in views
g.user = session_info

View File

@@ -20,54 +20,104 @@ def load_config(app, config_override=None):
load_dotenv()
# Site configuration
app.config['SITE_URL'] = os.getenv('SITE_URL', 'http://localhost:5000')
app.config['SITE_NAME'] = os.getenv('SITE_NAME', 'StarPunk')
app.config['SITE_AUTHOR'] = os.getenv('SITE_AUTHOR', 'Unknown')
app.config['SITE_DESCRIPTION'] = os.getenv(
'SITE_DESCRIPTION',
'A minimal IndieWeb CMS'
app.config["SITE_URL"] = os.getenv("SITE_URL", "http://localhost:5000")
app.config["SITE_NAME"] = os.getenv("SITE_NAME", "StarPunk")
app.config["SITE_AUTHOR"] = os.getenv("SITE_AUTHOR", "Unknown")
app.config["SITE_DESCRIPTION"] = os.getenv(
"SITE_DESCRIPTION", "A minimal IndieWeb CMS"
)
# Authentication
app.config['ADMIN_ME'] = os.getenv('ADMIN_ME')
app.config['SESSION_SECRET'] = os.getenv('SESSION_SECRET')
app.config['SESSION_LIFETIME'] = int(os.getenv('SESSION_LIFETIME', '30'))
app.config['INDIELOGIN_URL'] = os.getenv(
'INDIELOGIN_URL',
'https://indielogin.com'
)
app.config["ADMIN_ME"] = os.getenv("ADMIN_ME")
app.config["SESSION_SECRET"] = os.getenv("SESSION_SECRET")
app.config["SESSION_LIFETIME"] = int(os.getenv("SESSION_LIFETIME", "30"))
app.config["INDIELOGIN_URL"] = os.getenv("INDIELOGIN_URL", "https://indielogin.com")
# Validate required configuration
if not app.config['SESSION_SECRET']:
if not app.config["SESSION_SECRET"]:
raise ValueError(
"SESSION_SECRET must be set in .env file. "
"Generate with: python3 -c \"import secrets; print(secrets.token_hex(32))\""
'Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"'
)
# Flask secret key (uses SESSION_SECRET by default)
app.config['SECRET_KEY'] = os.getenv(
'FLASK_SECRET_KEY',
app.config['SESSION_SECRET']
app.config["SECRET_KEY"] = os.getenv(
"FLASK_SECRET_KEY", app.config["SESSION_SECRET"]
)
# Data paths
app.config['DATA_PATH'] = Path(os.getenv('DATA_PATH', './data'))
app.config['NOTES_PATH'] = Path(os.getenv('NOTES_PATH', './data/notes'))
app.config['DATABASE_PATH'] = Path(
os.getenv('DATABASE_PATH', './data/starpunk.db')
)
app.config["DATA_PATH"] = Path(os.getenv("DATA_PATH", "./data"))
app.config["NOTES_PATH"] = Path(os.getenv("NOTES_PATH", "./data/notes"))
app.config["DATABASE_PATH"] = Path(os.getenv("DATABASE_PATH", "./data/starpunk.db"))
# Flask environment
app.config['ENV'] = os.getenv('FLASK_ENV', 'development')
app.config['DEBUG'] = os.getenv('FLASK_DEBUG', '1') == '1'
app.config["ENV"] = os.getenv("FLASK_ENV", "development")
app.config["DEBUG"] = os.getenv("FLASK_DEBUG", "1") == "1"
# Logging
app.config['LOG_LEVEL'] = os.getenv('LOG_LEVEL', 'INFO')
app.config["LOG_LEVEL"] = os.getenv("LOG_LEVEL", "INFO")
# Development mode configuration
app.config["DEV_MODE"] = os.getenv("DEV_MODE", "false").lower() == "true"
app.config["DEV_ADMIN_ME"] = os.getenv("DEV_ADMIN_ME", "")
# Application version
app.config["VERSION"] = os.getenv("VERSION", "0.5.0")
# Apply overrides if provided
if config_override:
app.config.update(config_override)
# Convert path strings to Path objects (in case overrides provided strings)
if isinstance(app.config["DATA_PATH"], str):
app.config["DATA_PATH"] = Path(app.config["DATA_PATH"])
if isinstance(app.config["NOTES_PATH"], str):
app.config["NOTES_PATH"] = Path(app.config["NOTES_PATH"])
if isinstance(app.config["DATABASE_PATH"], str):
app.config["DATABASE_PATH"] = Path(app.config["DATABASE_PATH"])
# Validate configuration
validate_config(app)
# Ensure data directories exist
app.config['DATA_PATH'].mkdir(parents=True, exist_ok=True)
app.config['NOTES_PATH'].mkdir(parents=True, exist_ok=True)
app.config["DATA_PATH"].mkdir(parents=True, exist_ok=True)
app.config["NOTES_PATH"].mkdir(parents=True, exist_ok=True)
def validate_config(app):
"""
Validate application configuration on startup
Ensures required configuration is present based on mode (dev/production)
and warns prominently if development mode is enabled.
Args:
app: Flask application instance
Raises:
ValueError: If required configuration is missing
"""
dev_mode = app.config.get("DEV_MODE", False)
if dev_mode:
# Prominently warn about development 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
)
# Require DEV_ADMIN_ME in dev mode
if not app.config.get("DEV_ADMIN_ME"):
raise ValueError(
"DEV_MODE=true requires DEV_ADMIN_ME to be set. "
"Set DEV_ADMIN_ME=https://your-dev-identity.example.com in .env"
)
else:
# Production mode: ADMIN_ME is required
if not app.config.get("ADMIN_ME"):
raise ValueError(
"Production mode requires ADMIN_ME to be set. "
"Set ADMIN_ME=https://your-site.com in .env"
)

69
starpunk/dev_auth.py Normal file
View File

@@ -0,0 +1,69 @@
"""
Development authentication utilities for StarPunk
WARNING: These functions provide authentication bypass for local development.
They should ONLY be used when DEV_MODE=true.
This module contains utilities that should never be used in production.
"""
import logging
from flask import current_app
from starpunk.auth import create_session
logger = logging.getLogger(__name__)
def is_dev_mode() -> bool:
"""
Check if development mode is enabled
Returns:
bool: True if DEV_MODE is explicitly set to True, False otherwise
Security:
This function is used to guard all development authentication features.
It explicitly checks for True (not just truthy values).
"""
return current_app.config.get("DEV_MODE", False) is True
def create_dev_session(me: str) -> str:
"""
Create a development session without authentication
WARNING: This creates an authenticated session WITHOUT any verification.
Only call this function after verifying is_dev_mode() returns True.
Args:
me: The identity URL to create a session for (typically DEV_ADMIN_ME)
Returns:
str: Session token for the created session
Raises:
ValueError: If me is empty or invalid
Logs:
WARNING: Logs that dev authentication was used (for security audit trail)
Security:
- Should only be called when DEV_MODE=true
- Logs warning on every use
- Uses same session creation as production (just skips auth)
"""
if not me:
raise ValueError("Identity (me) is required")
# Log security warning
logger.warning(
f"DEV MODE: Creating session for {me} WITHOUT authentication. "
"This should NEVER happen in production!"
)
# Create session using production session creation
# This ensures dev sessions work exactly like production sessions
session_token = create_session(me)
return session_token

View File

@@ -57,6 +57,7 @@ class Note:
published: Whether note is published (visible publicly)
created_at: Creation timestamp (UTC)
updated_at: Last update timestamp (UTC)
deleted_at: Soft deletion timestamp (UTC, None if not deleted)
content_hash: SHA-256 hash of content (for integrity checking)
_data_dir: Base data directory path (used for file loading)
_cached_content: Cached markdown content (lazy-loaded)
@@ -111,6 +112,7 @@ class Note:
_data_dir: Path = field(repr=False, compare=False)
# Optional fields
deleted_at: Optional[datetime] = None
content_hash: Optional[str] = None
_cached_content: Optional[str] = field(
default=None, repr=False, compare=False, init=False
@@ -150,6 +152,10 @@ class Note:
if isinstance(updated_at, str):
updated_at = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
deleted_at = data.get("deleted_at")
if deleted_at and isinstance(deleted_at, str):
deleted_at = datetime.fromisoformat(deleted_at.replace("Z", "+00:00"))
return cls(
id=data["id"],
slug=data["slug"],
@@ -157,6 +163,7 @@ class Note:
published=bool(data["published"]),
created_at=created_at,
updated_at=updated_at,
deleted_at=deleted_at,
_data_dir=data_dir,
content_hash=data.get("content_hash"),
)

View File

@@ -39,14 +39,16 @@ from starpunk.utils import (
delete_note_file,
calculate_content_hash,
validate_note_path,
validate_slug
validate_slug,
)
# Custom Exceptions
class NoteError(Exception):
"""Base exception for note operations"""
pass
@@ -61,6 +63,7 @@ class NoteNotFoundError(NoteError):
identifier: The slug or ID used to search for the note
message: Human-readable error message
"""
def __init__(self, identifier: str | int, message: Optional[str] = None):
self.identifier = identifier
if message is None:
@@ -80,6 +83,7 @@ class InvalidNoteDataError(NoteError, ValueError):
value: The invalid value
message: Human-readable error message
"""
def __init__(self, field: str, value: any, message: Optional[str] = None):
self.field = field
self.value = value
@@ -100,6 +104,7 @@ class NoteSyncError(NoteError):
details: Additional details about the failure
message: Human-readable error message
"""
def __init__(self, operation: str, details: str, message: Optional[str] = None):
self.operation = operation
self.details = details
@@ -110,6 +115,7 @@ class NoteSyncError(NoteError):
# Helper Functions
def _get_existing_slugs(db) -> set[str]:
"""
Query all existing slugs from database
@@ -121,15 +127,14 @@ def _get_existing_slugs(db) -> set[str]:
Set of existing slug strings
"""
rows = db.execute("SELECT slug FROM notes").fetchall()
return {row['slug'] for row in rows}
return {row["slug"] for row in rows}
# Core CRUD Functions
def create_note(
content: str,
published: bool = False,
created_at: Optional[datetime] = None
content: str, published: bool = False, created_at: Optional[datetime] = None
) -> Note:
"""
Create a new note
@@ -192,9 +197,7 @@ def create_note(
# 1. VALIDATION (before any changes)
if not content or not content.strip():
raise InvalidNoteDataError(
'content',
content,
'Content cannot be empty or whitespace-only'
"content", content, "Content cannot be empty or whitespace-only"
)
# 2. SETUP
@@ -203,7 +206,7 @@ def create_note(
updated_at = created_at # Same as created_at for new notes
data_dir = Path(current_app.config['DATA_PATH'])
data_dir = Path(current_app.config["DATA_PATH"])
# 3. GENERATE UNIQUE SLUG
# Query all existing slugs from database
@@ -218,7 +221,7 @@ def create_note(
# Validate final slug (defensive check)
if not validate_slug(slug):
raise InvalidNoteDataError('slug', slug, f'Generated slug is invalid: {slug}')
raise InvalidNoteDataError("slug", slug, f"Generated slug is invalid: {slug}")
# 4. GENERATE FILE PATH
note_path = generate_note_path(slug, created_at, data_dir)
@@ -226,9 +229,9 @@ def create_note(
# Security: Validate path stays within data directory
if not validate_note_path(note_path, data_dir):
raise NoteSyncError(
'create',
f'Generated path outside data directory: {note_path}',
'Path validation failed'
"create",
f"Generated path outside data directory: {note_path}",
"Path validation failed",
)
# 5. CALCULATE CONTENT HASH
@@ -241,9 +244,9 @@ def create_note(
except OSError as e:
# File write failed, nothing to clean up
raise NoteSyncError(
'create',
f'Failed to write file: {e}',
f'Could not write note file: {note_path}'
"create",
f"Failed to write file: {e}",
f"Could not write note file: {note_path}",
)
# 7. INSERT DATABASE RECORD (transaction starts here)
@@ -255,7 +258,7 @@ def create_note(
INSERT INTO notes (slug, file_path, published, created_at, updated_at, content_hash)
VALUES (?, ?, ?, ?, ?, ?)
""",
(slug, file_path_rel, published, created_at, updated_at, content_hash)
(slug, file_path_rel, published, created_at, updated_at, content_hash),
)
db.commit()
except Exception as e:
@@ -264,13 +267,13 @@ def create_note(
note_path.unlink()
except OSError:
# Log warning but don't fail - file cleanup is best effort
current_app.logger.warning(f'Failed to clean up file after DB error: {note_path}')
current_app.logger.warning(
f"Failed to clean up file after DB error: {note_path}"
)
# Raise sync error
raise NoteSyncError(
'create',
f'Database insert failed: {e}',
f'Failed to create note: {slug}'
"create", f"Database insert failed: {e}", f"Failed to create note: {slug}"
)
# 8. RETRIEVE AND RETURN NOTE OBJECT
@@ -278,10 +281,7 @@ def create_note(
note_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
# Fetch the complete record
row = db.execute(
"SELECT * FROM notes WHERE id = ?",
(note_id,)
).fetchone()
row = db.execute("SELECT * FROM notes WHERE id = ?", (note_id,)).fetchone()
# Create Note object
note = Note.from_row(row, data_dir)
@@ -290,9 +290,7 @@ def create_note(
def get_note(
slug: Optional[str] = None,
id: Optional[int] = None,
load_content: bool = True
slug: Optional[str] = None, id: Optional[int] = None, load_content: bool = True
) -> Optional[Note]:
"""
Get a note by slug or ID
@@ -357,14 +355,12 @@ def get_note(
if slug is not None:
# Query by slug
row = db.execute(
"SELECT * FROM notes WHERE slug = ? AND deleted_at IS NULL",
(slug,)
"SELECT * FROM notes WHERE slug = ? AND deleted_at IS NULL", (slug,)
).fetchone()
else:
# Query by ID
row = db.execute(
"SELECT * FROM notes WHERE id = ? AND deleted_at IS NULL",
(id,)
"SELECT * FROM notes WHERE id = ? AND deleted_at IS NULL", (id,)
).fetchone()
# 3. CHECK IF FOUND
@@ -372,7 +368,7 @@ def get_note(
return None
# 4. CREATE NOTE OBJECT
data_dir = Path(current_app.config['DATA_PATH'])
data_dir = Path(current_app.config["DATA_PATH"])
note = Note.from_row(row, data_dir)
# 5. OPTIONALLY LOAD CONTENT
@@ -382,7 +378,7 @@ def get_note(
_ = note.content
except (FileNotFoundError, OSError) as e:
current_app.logger.warning(
f'Failed to load content for note {note.slug}: {e}'
f"Failed to load content for note {note.slug}: {e}"
)
# 6. OPTIONALLY VERIFY INTEGRITY
@@ -391,12 +387,12 @@ def get_note(
try:
if not note.verify_integrity():
current_app.logger.warning(
f'Content hash mismatch for note {note.slug}. '
f'File may have been modified externally.'
f"Content hash mismatch for note {note.slug}. "
f"File may have been modified externally."
)
except Exception as e:
current_app.logger.warning(
f'Failed to verify integrity for note {note.slug}: {e}'
f"Failed to verify integrity for note {note.slug}: {e}"
)
# 7. RETURN NOTE
@@ -407,8 +403,8 @@ def list_notes(
published_only: bool = False,
limit: int = 50,
offset: int = 0,
order_by: str = 'created_at',
order_dir: str = 'DESC'
order_by: str = "created_at",
order_dir: str = "DESC",
) -> list[Note]:
"""
List notes with filtering and pagination
@@ -470,7 +466,7 @@ def list_notes(
"""
# 1. VALIDATE PARAMETERS
# Prevent SQL injection - validate order_by column
ALLOWED_ORDER_FIELDS = ['id', 'slug', 'created_at', 'updated_at', 'published']
ALLOWED_ORDER_FIELDS = ["id", "slug", "created_at", "updated_at", "published"]
if order_by not in ALLOWED_ORDER_FIELDS:
raise ValueError(
f"Invalid order_by field: {order_by}. "
@@ -479,7 +475,7 @@ def list_notes(
# Validate order direction
order_dir = order_dir.upper()
if order_dir not in ['ASC', 'DESC']:
if order_dir not in ["ASC", "DESC"]:
raise ValueError(f"Invalid order_dir: {order_dir}. Must be 'ASC' or 'DESC'")
# Validate limit (prevent excessive queries)
@@ -488,10 +484,10 @@ def list_notes(
raise ValueError(f"Limit {limit} exceeds maximum {MAX_LIMIT}")
if limit < 1:
raise ValueError(f"Limit must be >= 1")
raise ValueError("Limit must be >= 1")
if offset < 0:
raise ValueError(f"Offset must be >= 0")
raise ValueError("Offset must be >= 0")
# 2. BUILD QUERY
# Start with base query
@@ -514,7 +510,7 @@ def list_notes(
rows = db.execute(query, params).fetchall()
# 4. CREATE NOTE OBJECTS (without loading content)
data_dir = Path(current_app.config['DATA_PATH'])
data_dir = Path(current_app.config["DATA_PATH"])
notes = [Note.from_row(row, data_dir) for row in rows]
return notes
@@ -524,7 +520,7 @@ def update_note(
slug: Optional[str] = None,
id: Optional[int] = None,
content: Optional[str] = None,
published: Optional[bool] = None
published: Optional[bool] = None,
) -> Note:
"""
Update a note's content and/or published status
@@ -600,9 +596,7 @@ def update_note(
if content is not None:
if not content or not content.strip():
raise InvalidNoteDataError(
'content',
content,
'Content cannot be empty or whitespace-only'
"content", content, "Content cannot be empty or whitespace-only"
)
# 2. GET EXISTING NOTE
@@ -614,15 +608,15 @@ def update_note(
# 3. SETUP
updated_at = datetime.utcnow()
data_dir = Path(current_app.config['DATA_PATH'])
data_dir = Path(current_app.config["DATA_PATH"])
note_path = data_dir / existing_note.file_path
# Validate path (security check)
if not validate_note_path(note_path, data_dir):
raise NoteSyncError(
'update',
f'Note file path outside data directory: {note_path}',
'Path validation failed'
"update",
f"Note file path outside data directory: {note_path}",
"Path validation failed",
)
# 4. UPDATE FILE (if content changed)
@@ -636,24 +630,24 @@ def update_note(
new_content_hash = calculate_content_hash(content)
except OSError as e:
raise NoteSyncError(
'update',
f'Failed to write file: {e}',
f'Could not update note file: {note_path}'
"update",
f"Failed to write file: {e}",
f"Could not update note file: {note_path}",
)
# 5. UPDATE DATABASE
db = get_db(current_app)
# Build update query based on what changed
update_fields = ['updated_at = ?']
update_fields = ["updated_at = ?"]
params = [updated_at]
if content is not None:
update_fields.append('content_hash = ?')
update_fields.append("content_hash = ?")
params.append(new_content_hash)
if published is not None:
update_fields.append('published = ?')
update_fields.append("published = ?")
params.append(published)
# Add WHERE clause parameter
@@ -674,12 +668,12 @@ def update_note(
# File has been updated, but we can't roll that back easily
# Log error and raise
current_app.logger.error(
f'Database update failed for note {existing_note.slug}: {e}'
f"Database update failed for note {existing_note.slug}: {e}"
)
raise NoteSyncError(
'update',
f'Database update failed: {e}',
f'Failed to update note: {existing_note.slug}'
"update",
f"Database update failed: {e}",
f"Failed to update note: {existing_note.slug}",
)
# 6. RETURN UPDATED NOTE
@@ -689,9 +683,7 @@ def update_note(
def delete_note(
slug: Optional[str] = None,
id: Optional[int] = None,
soft: bool = True
slug: Optional[str] = None, id: Optional[int] = None, soft: bool = True
) -> None:
"""
Delete a note (soft or hard delete)
@@ -769,20 +761,14 @@ def delete_note(
# Hard delete: query including soft-deleted notes
db = get_db(current_app)
if slug is not None:
row = db.execute(
"SELECT * FROM notes WHERE slug = ?",
(slug,)
).fetchone()
row = db.execute("SELECT * FROM notes WHERE slug = ?", (slug,)).fetchone()
else:
row = db.execute(
"SELECT * FROM notes WHERE id = ?",
(id,)
).fetchone()
row = db.execute("SELECT * FROM notes WHERE id = ?", (id,)).fetchone()
if row is None:
existing_note = None
else:
data_dir = Path(current_app.config['DATA_PATH'])
data_dir = Path(current_app.config["DATA_PATH"])
existing_note = Note.from_row(row, data_dir)
# 3. CHECK IF NOTE EXISTS
@@ -792,15 +778,15 @@ def delete_note(
return
# 4. SETUP
data_dir = Path(current_app.config['DATA_PATH'])
data_dir = Path(current_app.config["DATA_PATH"])
note_path = data_dir / existing_note.file_path
# Validate path (security check)
if not validate_note_path(note_path, data_dir):
raise NoteSyncError(
'delete',
f'Note file path outside data directory: {note_path}',
'Path validation failed'
"delete",
f"Note file path outside data directory: {note_path}",
"Path validation failed",
)
# 5. PERFORM DELETION
@@ -813,14 +799,14 @@ def delete_note(
try:
db.execute(
"UPDATE notes SET deleted_at = ? WHERE id = ?",
(deleted_at, existing_note.id)
(deleted_at, existing_note.id),
)
db.commit()
except Exception as e:
raise NoteSyncError(
'delete',
f'Database update failed: {e}',
f'Failed to soft delete note: {existing_note.slug}'
"delete",
f"Database update failed: {e}",
f"Failed to soft delete note: {existing_note.slug}",
)
# Optionally move file to trash (best effort)
@@ -829,23 +815,20 @@ def delete_note(
delete_note_file(note_path, soft=True, data_dir=data_dir)
except Exception as e:
current_app.logger.warning(
f'Failed to move file to trash for note {existing_note.slug}: {e}'
f"Failed to move file to trash for note {existing_note.slug}: {e}"
)
# Don't fail - database update succeeded
else:
# HARD DELETE: Remove from database and filesystem
try:
db.execute(
"DELETE FROM notes WHERE id = ?",
(existing_note.id,)
)
db.execute("DELETE FROM notes WHERE id = ?", (existing_note.id,))
db.commit()
except Exception as e:
raise NoteSyncError(
'delete',
f'Database delete failed: {e}',
f'Failed to delete note: {existing_note.slug}'
"delete",
f"Database delete failed: {e}",
f"Failed to delete note: {existing_note.slug}",
)
# Delete file (best effort)
@@ -854,11 +837,11 @@ def delete_note(
except FileNotFoundError:
# File already gone - that's fine
current_app.logger.info(
f'File already deleted for note {existing_note.slug}'
f"File already deleted for note {existing_note.slug}"
)
except Exception as e:
current_app.logger.warning(
f'Failed to delete file for note {existing_note.slug}: {e}'
f"Failed to delete file for note {existing_note.slug}: {e}"
)
# Don't fail - database record already deleted

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)