"""History router. Provides endpoints for forensic exploration of all historical ban records stored in the fail2ban SQLite database. Routes ------ ``GET /api/history`` Paginated list of all historical bans, filterable by jail, IP prefix, and time range. ``GET /api/history/{ip}`` Per-IP detail: complete ban timeline, aggregated totals, and geolocation. """ from __future__ import annotations from typing import Literal from fastapi import APIRouter, Query, Request from app.dependencies import ( AuthDep, Fail2BanMetadataServiceDep, Fail2BanSocketDep, HistoryServiceContextDep, HttpSessionDep, ) from app.exceptions import HistoryNotFoundError from app.mappers import history_mappers from app.models._common import TimeRange from app.models.ban import BanOrigin from app.models.history import HistoryListResponse, IpDetailResponse from app.services import history_service from app.utils.constants import DEFAULT_PAGE_SIZE router: APIRouter = APIRouter(prefix="/api/v1/history", tags=["History"]) @router.get( "", response_model=HistoryListResponse, summary="Return a paginated list of historical bans", ) async def get_history( request: Request, _auth: AuthDep, history_ctx: HistoryServiceContextDep, socket_path: Fail2BanSocketDep, http_session: HttpSessionDep, fail2ban_metadata_service: Fail2BanMetadataServiceDep, range: TimeRange | None = Query( default=None, description="Optional time-range filter. Omit for all-time.", ), jail: str | None = Query( default=None, description="Restrict results to this jail name.", ), ip: str | None = Query( default=None, description="Restrict results to IPs matching this prefix.", ), origin: BanOrigin | None = Query( default=None, description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", ), source: Literal["fail2ban", "archive"] = Query( default="fail2ban", description="Data source: 'fail2ban' or 'archive'.", ), page: int = Query(default=1, ge=1, description="1-based page number."), page_size: int = Query( default=DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page (max 500).", ), ) -> HistoryListResponse: """Return a paginated list of historical bans with optional filters. Queries the fail2ban database for all ban records, applying the requested filters. Results are ordered newest-first and enriched with geolocation. Args: request: The incoming request (used to access ``app.state``). _auth: Validated session — enforces authentication. history_ctx: History service context containing db and repositories. socket_path: Path to fail2ban Unix domain socket. http_session: Shared HTTP session for geolocation. fail2ban_metadata_service: Fail2Ban metadata service. range: Optional time-range preset. ``None`` means all-time. jail: Optional jail name filter (exact match). ip: Optional IP prefix filter (prefix match). page: 1-based page number. page_size: Items per page (1–500). Returns: :class:`~app.models.history.HistoryListResponse` with paginated items and the total matching count. """ domain_result = await history_service.list_history( socket_path, range_=range, jail=jail, ip_filter=ip, origin=origin, source=source, page=page, page_size=page_size, http_session=http_session, db=history_ctx.db, fail2ban_metadata_service=fail2ban_metadata_service, ) return history_mappers.map_domain_history_list_to_response(domain_result) @router.get( "/archive", response_model=HistoryListResponse, summary="Return a paginated list of archived historical bans", ) async def get_history_archive( request: Request, _auth: AuthDep, history_ctx: HistoryServiceContextDep, socket_path: Fail2BanSocketDep, http_session: HttpSessionDep, fail2ban_metadata_service: Fail2BanMetadataServiceDep, range: TimeRange | None = Query( default=None, description="Optional time-range filter. Omit for all-time.", ), jail: str | None = Query(default=None, description="Restrict results to this jail name."), ip: str | None = Query(default=None, description="Restrict results to IPs matching this prefix."), page: int = Query(default=1, ge=1, description="1-based page number."), page_size: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page (max 500)."), ) -> HistoryListResponse: domain_result = await history_service.list_history( socket_path, range_=range, jail=jail, ip_filter=ip, source="archive", page=page, page_size=page_size, http_session=http_session, db=history_ctx.db, fail2ban_metadata_service=fail2ban_metadata_service, ) return history_mappers.map_domain_history_list_to_response(domain_result) @router.get( "/{ip}", response_model=IpDetailResponse, summary="Return the full ban history for a single IP address", ) async def get_ip_history( request: Request, _auth: AuthDep, ip: str, socket_path: Fail2BanSocketDep, http_session: HttpSessionDep, fail2ban_metadata_service: Fail2BanMetadataServiceDep, ) -> IpDetailResponse: """Return the complete historical record for a single IP address. Fetches all ban events for the given IP from the fail2ban database and aggregates them into a timeline. Returns ``404`` if the IP has no recorded history. Args: request: The incoming request. _auth: Validated session dependency. ip: The IP address to look up. Returns: :class:`~app.models.history.IpDetailResponse` with aggregated totals and a full ban timeline. Raises: HTTPException: 404 if the IP has no history in the database. """ domain_result = await history_service.get_ip_detail( socket_path, ip, http_session=http_session, fail2ban_metadata_service=fail2ban_metadata_service, ) if domain_result is None: raise HistoryNotFoundError(ip) return history_mappers.map_domain_ip_detail_to_response(domain_result)