a7eff2c033
- Convert f-string log interpolation to %s-style formatting in
app/api/pipelines.py and app/api/saved_searches.py to prevent
clear-text logging of request-derived data (CodeQL: clear-text
logging of sensitive information)
- Replace plain hashlib.sha256() with PBKDF2-HMAC-SHA256 via
hash_token() in app/auth.py for Bearer token verification,
consistent with how tokens are stored in api_tokens.py (CodeQL:
use of weak cryptographic hashing on sensitive data)
- Remove redundant {exc} from logger.exception() calls (the
traceback is already captured by logger.exception())
- Update test to verify PBKDF2 hash instead of plain SHA-256
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
306 lines
9.6 KiB
Python
306 lines
9.6 KiB
Python
"""
|
|
Saved searches API endpoints.
|
|
|
|
Provides CRUD operations for user-defined saved search filters.
|
|
Each user can save, list, update, and delete named filter combinations
|
|
for quick access on the files page.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from typing import Annotated, Any
|
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.auth import get_current_user, require_login
|
|
from app.database import get_db
|
|
from app.models import SavedSearch
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/saved-searches", tags=["saved-searches"])
|
|
|
|
DbSession = Annotated[Session, Depends(get_db)]
|
|
|
|
# Allowed filter keys that can be saved.
|
|
# Files-view keys: search, mime_type, status, storage_provider, sort_by, sort_order
|
|
# Search-view keys: q, document_type, language, sender, text_quality
|
|
# Shared keys: tags, date_from, date_to
|
|
ALLOWED_FILTER_KEYS = frozenset(
|
|
{
|
|
"search",
|
|
"q",
|
|
"mime_type",
|
|
"status",
|
|
"date_from",
|
|
"date_to",
|
|
"storage_provider",
|
|
"tags",
|
|
"sort_by",
|
|
"sort_order",
|
|
"document_type",
|
|
"language",
|
|
"sender",
|
|
"text_quality",
|
|
}
|
|
)
|
|
|
|
# Maximum number of saved searches per user
|
|
MAX_SAVED_SEARCHES_PER_USER = 50
|
|
|
|
# Maximum length for saved search name
|
|
MAX_NAME_LENGTH = 100
|
|
|
|
|
|
def _get_user_id(request: Request) -> str:
|
|
"""Extract user identifier from the session.
|
|
|
|
Returns the preferred_username, email, or 'anonymous' if auth is disabled.
|
|
|
|
Args:
|
|
request: The incoming HTTP request.
|
|
|
|
Returns:
|
|
A string identifying the current user.
|
|
"""
|
|
user = get_current_user(request)
|
|
if user:
|
|
return user.get("preferred_username") or user.get("email") or user.get("name", "anonymous")
|
|
return "anonymous"
|
|
|
|
|
|
def _validate_filters(filters: Any) -> dict:
|
|
"""Validate and sanitize filter parameters.
|
|
|
|
Ensures only allowed filter keys are present and values are strings.
|
|
|
|
Args:
|
|
filters: The raw filter value from the client.
|
|
|
|
Returns:
|
|
A sanitized filter dictionary with only allowed keys.
|
|
|
|
Raises:
|
|
HTTPException: If filters is not a dict or contains invalid values.
|
|
"""
|
|
if not isinstance(filters, dict):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="filters must be a JSON object",
|
|
)
|
|
sanitized = {}
|
|
for key, value in filters.items():
|
|
if key in ALLOWED_FILTER_KEYS and isinstance(value, str) and value.strip():
|
|
sanitized[key] = value.strip()
|
|
return sanitized
|
|
|
|
|
|
def _serialize_saved_search(s: SavedSearch) -> dict:
|
|
"""Serialize a SavedSearch model instance to a JSON-compatible dict.
|
|
|
|
Args:
|
|
s: The SavedSearch model instance.
|
|
|
|
Returns:
|
|
A dictionary with id, name, filters, created_at, and updated_at.
|
|
"""
|
|
return {
|
|
"id": s.id,
|
|
"name": s.name,
|
|
"filters": json.loads(s.filters),
|
|
"created_at": s.created_at.isoformat() if s.created_at else None,
|
|
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
|
|
}
|
|
|
|
|
|
@router.get("")
|
|
@require_login
|
|
def list_saved_searches(request: Request, db: DbSession):
|
|
"""List all saved searches for the current user.
|
|
|
|
Returns:
|
|
A list of saved search objects with id, name, filters, and timestamps.
|
|
"""
|
|
user_id = _get_user_id(request)
|
|
searches = db.query(SavedSearch).filter(SavedSearch.user_id == user_id).order_by(SavedSearch.name).all()
|
|
return [_serialize_saved_search(s) for s in searches]
|
|
|
|
|
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
|
@require_login
|
|
def create_saved_search(
|
|
request: Request,
|
|
db: DbSession,
|
|
name: str = Body(..., embed=True),
|
|
filters: dict = Body(..., embed=True),
|
|
):
|
|
"""Create a new saved search for the current user.
|
|
|
|
Request body (JSON):
|
|
name: Display name for the saved search (required, max 100 chars)
|
|
filters: Dictionary of filter parameters (required)
|
|
|
|
Returns:
|
|
The created saved search object.
|
|
|
|
Raises:
|
|
HTTPException 422: If name or filters are invalid.
|
|
HTTPException 409: If a saved search with the same name already exists.
|
|
"""
|
|
user_id = _get_user_id(request)
|
|
|
|
name = name.strip() if isinstance(name, str) else ""
|
|
if not name or len(name) > MAX_NAME_LENGTH:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"name is required and must be at most {MAX_NAME_LENGTH} characters",
|
|
)
|
|
|
|
sanitized_filters = _validate_filters(filters)
|
|
if not sanitized_filters:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="At least one filter parameter is required",
|
|
)
|
|
|
|
# Check user limit
|
|
count = db.query(SavedSearch).filter(SavedSearch.user_id == user_id).count()
|
|
if count >= MAX_SAVED_SEARCHES_PER_USER:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Maximum of {MAX_SAVED_SEARCHES_PER_USER} saved searches reached",
|
|
)
|
|
|
|
# Check for duplicate name
|
|
existing = db.query(SavedSearch).filter(SavedSearch.user_id == user_id, SavedSearch.name == name).first()
|
|
if existing:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"A saved search named '{name}' already exists",
|
|
)
|
|
|
|
saved_search = SavedSearch(
|
|
user_id=user_id,
|
|
name=name,
|
|
filters=json.dumps(sanitized_filters),
|
|
)
|
|
|
|
try:
|
|
db.add(saved_search)
|
|
db.commit()
|
|
db.refresh(saved_search)
|
|
except Exception:
|
|
db.rollback()
|
|
logger.exception("Failed to create saved search for user=%s", user_id)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to save search",
|
|
)
|
|
|
|
logger.info("Saved search created: user=%s, name=%r", user_id, name)
|
|
return _serialize_saved_search(saved_search)
|
|
|
|
|
|
@router.put("/{search_id}")
|
|
@require_login
|
|
def update_saved_search(
|
|
search_id: int,
|
|
request: Request,
|
|
db: DbSession,
|
|
name: str | None = Body(None, embed=True),
|
|
filters: dict | None = Body(None, embed=True),
|
|
):
|
|
"""Update an existing saved search.
|
|
|
|
Path Parameters:
|
|
search_id: The ID of the saved search to update.
|
|
|
|
Request body (JSON):
|
|
name: New display name (optional)
|
|
filters: New filter parameters (optional)
|
|
|
|
Returns:
|
|
The updated saved search object.
|
|
|
|
Raises:
|
|
HTTPException 404: If the saved search is not found.
|
|
HTTPException 409: If the new name conflicts with an existing saved search.
|
|
"""
|
|
user_id = _get_user_id(request)
|
|
saved_search = db.query(SavedSearch).filter(SavedSearch.id == search_id, SavedSearch.user_id == user_id).first()
|
|
if not saved_search:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Saved search not found")
|
|
|
|
if name is not None:
|
|
new_name = name.strip() if isinstance(name, str) else ""
|
|
if not new_name or len(new_name) > MAX_NAME_LENGTH:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"name must be non-empty and at most {MAX_NAME_LENGTH} characters",
|
|
)
|
|
# Check for name conflict
|
|
if new_name != saved_search.name:
|
|
existing = (
|
|
db.query(SavedSearch).filter(SavedSearch.user_id == user_id, SavedSearch.name == new_name).first()
|
|
)
|
|
if existing:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"A saved search named '{new_name}' already exists",
|
|
)
|
|
saved_search.name = new_name
|
|
|
|
if filters is not None:
|
|
sanitized_filters = _validate_filters(filters)
|
|
if not sanitized_filters:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="At least one filter parameter is required",
|
|
)
|
|
saved_search.filters = json.dumps(sanitized_filters)
|
|
|
|
try:
|
|
db.commit()
|
|
db.refresh(saved_search)
|
|
except Exception:
|
|
db.rollback()
|
|
logger.exception("Failed to update saved search id=%s, user=%s", search_id, user_id)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to update saved search",
|
|
)
|
|
|
|
logger.info("Saved search updated: id=%s, user=%s", search_id, user_id)
|
|
return _serialize_saved_search(saved_search)
|
|
|
|
|
|
@router.delete("/{search_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
@require_login
|
|
def delete_saved_search(search_id: int, request: Request, db: DbSession):
|
|
"""Delete a saved search.
|
|
|
|
Path Parameters:
|
|
search_id: The ID of the saved search to delete.
|
|
|
|
Raises:
|
|
HTTPException 404: If the saved search is not found.
|
|
"""
|
|
user_id = _get_user_id(request)
|
|
saved_search = db.query(SavedSearch).filter(SavedSearch.id == search_id, SavedSearch.user_id == user_id).first()
|
|
if not saved_search:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Saved search not found")
|
|
|
|
try:
|
|
db.delete(saved_search)
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
logger.exception("Failed to delete saved search id=%s, user=%s", search_id, user_id)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to delete saved search",
|
|
)
|
|
|
|
logger.info("Saved search deleted: id=%s, user=%s", search_id, user_id)
|