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

@@ -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