Files
StarPunk/starpunk/routes/search.py
Phil Skentelbery 8f71ff36ec feat(search): Add complete Search UI with API and web interface
Implements full search functionality for StarPunk v1.1.0.

Search API Endpoint (/api/search):
- GET endpoint with query parameter (q) validation
- Pagination via limit (default 20, max 100) and offset parameters
- JSON response with results count and formatted search results
- Authentication-aware: anonymous users see published notes only
- Graceful handling of FTS5 unavailability (503 error)
- Proper error responses for missing/empty queries

Search Web Interface (/search):
- HTML search results page with Bootstrap-inspired styling
- Search form with HTML5 validation (minlength=2, maxlength=100)
- Results display with title, excerpt, date, and links
- Empty state for no results
- Error state for FTS5 unavailability
- Simple pagination (Next/Previous navigation)

Navigation Integration:
- Added search box to site navigation in base.html
- Preserves query parameter on results page
- Responsive design with emoji search icon
- Accessible with proper ARIA labels

FTS Index Population:
- Added startup check in __init__.py for empty FTS index
- Automatic rebuild from existing notes on first run
- Graceful degradation if population fails
- Logging for troubleshooting

Security Features:
- XSS prevention: HTML in search results properly escaped
- Safe highlighting: FTS5 <mark> tags preserved, user content escaped
- Query validation: empty queries rejected, length limits enforced
- SQL injection prevention via FTS5 query parser
- Authentication filtering: unpublished notes hidden from anonymous users

Testing:
- Added 41 comprehensive tests across 3 test files
- test_search_api.py: 12 tests for API endpoint validation
- test_search_integration.py: 17 tests for UI rendering and integration
- test_search_security.py: 12 tests for XSS, SQL injection, auth filtering
- All tests passing with no regressions

Implementation follows architect specifications from:
- docs/architecture/v1.1.0-validation-report.md
- docs/architecture/v1.1.0-feature-architecture.md
- docs/decisions/ADR-034-full-text-search.md

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 10:34:00 -07:00

194 lines
5.6 KiB
Python

"""
Search routes for StarPunk
Provides both API and HTML endpoints for full-text search functionality.
"""
import logging
from pathlib import Path
from flask import Blueprint, current_app, g, jsonify, render_template, request
from starpunk.search import has_fts_table, search_notes
logger = logging.getLogger(__name__)
bp = Blueprint("search", __name__)
@bp.route("/api/search", methods=["GET"])
def api_search():
"""
Search API endpoint
Query Parameters:
q (required): Search query string
limit (optional): Results limit, default 20, max 100
offset (optional): Pagination offset, default 0
Returns:
JSON response with search results
Status Codes:
200: Success (even with 0 results)
400: Bad request (empty query)
503: Service unavailable (FTS5 not available)
"""
# Extract and validate query parameter
query = request.args.get("q", "").strip()
if not query:
return (
jsonify(
{
"error": "Missing required parameter: q",
"message": "Search query cannot be empty",
}
),
400,
)
# Parse limit with bounds checking
try:
limit = min(int(request.args.get("limit", 20)), 100)
if limit < 1:
limit = 20
except ValueError:
limit = 20
# Parse offset
try:
offset = max(int(request.args.get("offset", 0)), 0)
except ValueError:
offset = 0
# Check if user is authenticated (for unpublished notes)
# Anonymous users (g.me not set) see only published notes
published_only = not hasattr(g, "me") or g.me is None
db_path = Path(current_app.config["DATABASE_PATH"])
# Check FTS availability
if not has_fts_table(db_path):
return (
jsonify(
{
"error": "Search unavailable",
"message": "Full-text search is not configured on this server",
}
),
503,
)
try:
results = search_notes(
query=query,
db_path=db_path,
published_only=published_only,
limit=limit,
offset=offset,
)
except Exception as e:
current_app.logger.error(f"Search failed: {e}")
return (
jsonify(
{"error": "Search failed", "message": "An error occurred during search"}
),
500,
)
# Format response
response = {
"query": query,
"count": len(results),
"limit": limit,
"offset": offset,
"results": [
{
"slug": r["slug"],
"title": r["title"] or f"Note from {r['created_at'][:10]}",
"excerpt": r["snippet"], # Already has <mark> tags
"published_at": r["created_at"],
"url": f"/notes/{r['slug']}",
}
for r in results
],
}
return jsonify(response), 200
@bp.route("/search")
def search_page():
"""
Search results HTML page
Query Parameters:
q: Search query string
offset: Pagination offset
"""
query = request.args.get("q", "").strip()
limit = 20 # Fixed for HTML view
# Parse offset
try:
offset = max(int(request.args.get("offset", 0)), 0)
except ValueError:
offset = 0
# Check authentication for unpublished notes
# Anonymous users (g.me not set) see only published notes
published_only = not hasattr(g, "me") or g.me is None
results = []
error = None
if query:
db_path = Path(current_app.config["DATABASE_PATH"])
if not has_fts_table(db_path):
error = "Full-text search is not configured on this server"
else:
try:
results = search_notes(
query=query,
db_path=db_path,
published_only=published_only,
limit=limit,
offset=offset,
)
# Format results for template
# Format results and escape HTML in excerpts for safety
# FTS5 snippet() returns content with <mark> tags but doesn't escape HTML
# We need to escape it but preserve the <mark> tags
from markupsafe import escape, Markup
formatted_results = []
for r in results:
# Escape the snippet but allow <mark> tags
snippet = r["snippet"]
# Simple approach: escape all HTML, then unescape our mark tags
escaped = escape(snippet)
# Replace escaped mark tags with real ones
safe_snippet = str(escaped).replace("&lt;mark&gt;", "<mark>").replace("&lt;/mark&gt;", "</mark>")
formatted_results.append({
"slug": r["slug"],
"title": r["title"] or f"Note from {r['created_at'][:10]}",
"excerpt": Markup(safe_snippet), # Mark as safe since we've escaped it ourselves
"published_at": r["created_at"],
"url": f"/notes/{r['slug']}",
})
results = formatted_results
except Exception as e:
current_app.logger.error(f"Search failed: {e}")
error = "An error occurred during search"
return render_template(
"search.html",
query=query,
results=results,
error=error,
limit=limit,
offset=offset,
)