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>
194 lines
5.6 KiB
Python
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("<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,
|
|
)
|