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>
This commit is contained in:
193
starpunk/routes/search.py
Normal file
193
starpunk/routes/search.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
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("<mark>", "<mark>").replace("</mark>", "</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,
|
||||
)
|
||||
Reference in New Issue
Block a user