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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user