"""Blocklist router. Manages external IP blocklist sources, triggers manual imports, and exposes the import schedule and log: * ``GET /api/blocklists`` — list all sources * ``POST /api/blocklists`` — add a source * ``GET /api/blocklists/import`` — (reserved; use POST) * ``POST /api/blocklists/import`` — trigger a manual import now * ``GET /api/blocklists/schedule`` — get current schedule + next run * ``PUT /api/blocklists/schedule`` — update schedule * ``GET /api/blocklists/log`` — paginated import log * ``GET /api/blocklists/{id}`` — get a single source * ``PUT /api/blocklists/{id}`` — edit a source * ``DELETE /api/blocklists/{id}`` — remove a source * ``GET /api/blocklists/{id}/preview`` — preview the blocklist contents Note: static path segments (``/import``, ``/schedule``, ``/log``) are registered *before* the ``/{id}`` routes so FastAPI resolves them correctly. """ from __future__ import annotations from typing import TYPE_CHECKING, Annotated import aiosqlite if TYPE_CHECKING: import aiohttp from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from app.dependencies import AuthDep, get_db from app.models.blocklist import ( BlocklistListResponse, BlocklistSource, BlocklistSourceCreate, BlocklistSourceUpdate, ImportLogListResponse, ImportRunResult, PreviewResponse, ScheduleConfig, ScheduleInfo, ) from app.repositories import import_log_repo from app.services import blocklist_service from app.tasks import blocklist_import as blocklist_import_task router: APIRouter = APIRouter(prefix="/api/blocklists", tags=["Blocklists"]) DbDep = Annotated[aiosqlite.Connection, Depends(get_db)] # --------------------------------------------------------------------------- # Source list + create # --------------------------------------------------------------------------- @router.get( "", response_model=BlocklistListResponse, summary="List all blocklist sources", ) async def list_blocklists( db: DbDep, _auth: AuthDep, ) -> BlocklistListResponse: """Return all configured blocklist source definitions. Args: db: Application database connection (injected). _auth: Validated session — enforces authentication. Returns: :class:`~app.models.blocklist.BlocklistListResponse` with all sources. """ sources = await blocklist_service.list_sources(db) return BlocklistListResponse(sources=sources) @router.post( "", response_model=BlocklistSource, status_code=status.HTTP_201_CREATED, summary="Add a new blocklist source", ) async def create_blocklist( payload: BlocklistSourceCreate, db: DbDep, _auth: AuthDep, ) -> BlocklistSource: """Create a new blocklist source definition. Args: payload: New source data (name, url, enabled). db: Application database connection (injected). _auth: Validated session — enforces authentication. Returns: The newly created :class:`~app.models.blocklist.BlocklistSource`. """ return await blocklist_service.create_source( db, payload.name, payload.url, enabled=payload.enabled ) # --------------------------------------------------------------------------- # Static sub-paths — must be declared BEFORE /{id} # --------------------------------------------------------------------------- @router.post( "/import", response_model=ImportRunResult, summary="Trigger a manual blocklist import", ) async def run_import_now( request: Request, db: DbDep, _auth: AuthDep, ) -> ImportRunResult: """Download and apply all enabled blocklist sources immediately. Args: request: Incoming request (used to access shared HTTP session). db: Application database connection (injected). _auth: Validated session — enforces authentication. Returns: :class:`~app.models.blocklist.ImportRunResult` with per-source results and aggregated counters. """ http_session: aiohttp.ClientSession = request.app.state.http_session socket_path: str = request.app.state.settings.fail2ban_socket return await blocklist_service.import_all(db, http_session, socket_path) @router.get( "/schedule", response_model=ScheduleInfo, summary="Get the current import schedule", ) async def get_schedule( request: Request, db: DbDep, _auth: AuthDep, ) -> ScheduleInfo: """Return the current schedule configuration and runtime metadata. The ``next_run_at`` field is read from APScheduler if the job is active. Args: request: Incoming request (used to query the scheduler). db: Application database connection (injected). _auth: Validated session — enforces authentication. Returns: :class:`~app.models.blocklist.ScheduleInfo` with config and run times. """ scheduler = request.app.state.scheduler job = scheduler.get_job(blocklist_import_task.JOB_ID) next_run_at: str | None = None if job is not None and job.next_run_time is not None: next_run_at = job.next_run_time.isoformat() return await blocklist_service.get_schedule_info(db, next_run_at) @router.put( "/schedule", response_model=ScheduleInfo, summary="Update the import schedule", ) async def update_schedule( payload: ScheduleConfig, request: Request, db: DbDep, _auth: AuthDep, ) -> ScheduleInfo: """Persist a new schedule configuration and reschedule the import job. Args: payload: New :class:`~app.models.blocklist.ScheduleConfig`. request: Incoming request (used to access the scheduler). db: Application database connection (injected). _auth: Validated session — enforces authentication. Returns: Updated :class:`~app.models.blocklist.ScheduleInfo`. """ await blocklist_service.set_schedule(db, payload) # Reschedule the background job immediately. blocklist_import_task.reschedule(request.app) job = request.app.state.scheduler.get_job(blocklist_import_task.JOB_ID) next_run_at: str | None = None if job is not None and job.next_run_time is not None: next_run_at = job.next_run_time.isoformat() return await blocklist_service.get_schedule_info(db, next_run_at) @router.get( "/log", response_model=ImportLogListResponse, summary="Get the paginated import log", ) async def get_import_log( db: DbDep, _auth: AuthDep, source_id: int | None = Query(default=None, description="Filter by source id"), page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=200), ) -> ImportLogListResponse: """Return a paginated log of all import runs. Args: db: Application database connection (injected). _auth: Validated session — enforces authentication. source_id: Optional filter — only show logs for this source. page: 1-based page number. page_size: Items per page. Returns: :class:`~app.models.blocklist.ImportLogListResponse`. """ items, total = await import_log_repo.list_logs( db, source_id=source_id, page=page, page_size=page_size ) total_pages = import_log_repo.compute_total_pages(total, page_size) from app.models.blocklist import ImportLogEntry # noqa: PLC0415 return ImportLogListResponse( items=[ImportLogEntry.model_validate(i) for i in items], total=total, page=page, page_size=page_size, total_pages=total_pages, ) # --------------------------------------------------------------------------- # Single source CRUD — parameterised routes AFTER static sub-paths # --------------------------------------------------------------------------- @router.get( "/{source_id}", response_model=BlocklistSource, summary="Get a single blocklist source", ) async def get_blocklist( source_id: int, db: DbDep, _auth: AuthDep, ) -> BlocklistSource: """Return a single blocklist source by id. Args: source_id: Primary key of the source. db: Application database connection (injected). _auth: Validated session — enforces authentication. Raises: HTTPException: 404 if the source does not exist. """ source = await blocklist_service.get_source(db, source_id) if source is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.") return source @router.put( "/{source_id}", response_model=BlocklistSource, summary="Update a blocklist source", ) async def update_blocklist( source_id: int, payload: BlocklistSourceUpdate, db: DbDep, _auth: AuthDep, ) -> BlocklistSource: """Update one or more fields on a blocklist source. Args: source_id: Primary key of the source to update. payload: Fields to update (all optional). db: Application database connection (injected). _auth: Validated session — enforces authentication. Raises: HTTPException: 404 if the source does not exist. """ updated = await blocklist_service.update_source( db, source_id, name=payload.name, url=payload.url, enabled=payload.enabled, ) if updated is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.") return updated @router.delete( "/{source_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a blocklist source", ) async def delete_blocklist( source_id: int, db: DbDep, _auth: AuthDep, ) -> None: """Delete a blocklist source by id. Args: source_id: Primary key of the source to remove. db: Application database connection (injected). _auth: Validated session — enforces authentication. Raises: HTTPException: 404 if the source does not exist. """ deleted = await blocklist_service.delete_source(db, source_id) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.") @router.get( "/{source_id}/preview", response_model=PreviewResponse, summary="Preview the contents of a blocklist source", ) async def preview_blocklist( source_id: int, request: Request, db: DbDep, _auth: AuthDep, ) -> PreviewResponse: """Download and preview a sample of a blocklist source. Returns the first :data:`~app.services.blocklist_service._PREVIEW_LINES` valid IP entries together with validation statistics. Args: source_id: Primary key of the source to preview. request: Incoming request (used to access the HTTP session). db: Application database connection (injected). _auth: Validated session — enforces authentication. Raises: HTTPException: 404 if the source does not exist. HTTPException: 502 if the URL cannot be reached. """ source = await blocklist_service.get_source(db, source_id) if source is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.") http_session: aiohttp.ClientSession = request.app.state.http_session try: return await blocklist_service.preview_source(source.url, http_session) except ValueError as exc: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Could not fetch blocklist: {exc}", ) from exc