"""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 fastapi import APIRouter, Query, status from app.dependencies import ( AuthDep, BlocklistServiceContextDep, Fail2BanSocketDep, GeoCacheDep, HttpSessionDep, SchedulerDep, SettingsDep, ) from app.exceptions import BadRequestError, BlocklistSourceNotFoundError from app.mappers import blocklist_mappers from app.models.blocklist import ( BlocklistListResponse, BlocklistSource, BlocklistSourceCreate, BlocklistSourceUpdate, ImportLogListResponse, ImportRunResult, PreviewResponse, ScheduleConfig, ScheduleInfo, ) from app.services import ban_service, blocklist_service from app.tasks.blocklist_import import run_import_with_resources from app.utils.constants import DEFAULT_PAGE_SIZE router: APIRouter = APIRouter(prefix="/api/blocklists", tags=["Blocklists"]) # --------------------------------------------------------------------------- # Source list + create # --------------------------------------------------------------------------- @router.get( "", response_model=BlocklistListResponse, summary="List all blocklist sources", ) async def list_blocklists( blocklist_ctx: BlocklistServiceContextDep, _auth: AuthDep, ) -> BlocklistListResponse: """Return all configured blocklist source definitions. Args: blocklist_ctx: Blocklist service context containing db and repositories. _auth: Validated session — enforces authentication. Returns: :class:`~app.models.blocklist.BlocklistListResponse` with all sources. """ sources = await blocklist_service.list_sources(blocklist_ctx.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, blocklist_ctx: BlocklistServiceContextDep, _auth: AuthDep, ) -> BlocklistSource: """Create a new blocklist source definition. Args: payload: New source data (name, url, enabled). blocklist_ctx: Blocklist service context containing db and repositories. _auth: Validated session — enforces authentication. Returns: The newly created :class:`~app.models.blocklist.BlocklistSource`. Raises: HTTPException: 400 if URL validation fails. """ try: return await blocklist_service.create_source( blocklist_ctx.db, payload.name, str(payload.url), enabled=payload.enabled ) except ValueError as exc: raise BadRequestError(str(exc)) from exc # --------------------------------------------------------------------------- # 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( http_session: HttpSessionDep, blocklist_ctx: BlocklistServiceContextDep, _auth: AuthDep, socket_path: Fail2BanSocketDep, geo_cache: GeoCacheDep, ) -> ImportRunResult: """Download and apply all enabled blocklist sources immediately. Args: http_session: Shared HTTP session (injected). blocklist_ctx: Blocklist service context containing db and repositories. _auth: Validated session — enforces authentication. socket_path: Path to fail2ban Unix domain socket. geo_cache: Geolocation cache instance. Returns: :class:`~app.models.blocklist.ImportRunResult` with per-source results and aggregated counters. """ return await blocklist_service.import_all( blocklist_ctx.db, http_session, socket_path, geo_is_cached=geo_cache.is_cached, geo_cache=geo_cache, ban_ip=ban_service.ban_ip, ) @router.get( "/schedule", response_model=ScheduleInfo, summary="Get the current import schedule", ) async def get_schedule( blocklist_ctx: BlocklistServiceContextDep, _auth: AuthDep, scheduler: SchedulerDep, ) -> ScheduleInfo: """Return the current schedule configuration and runtime metadata. The ``next_run_at`` field is read from APScheduler if the job is active. Args: blocklist_ctx: Blocklist service context containing db and repositories. _auth: Validated session — enforces authentication. scheduler: APScheduler instance. Returns: :class:`~app.models.blocklist.ScheduleInfo` with config and run times. """ return await blocklist_service.get_schedule_info_with_runtime(blocklist_ctx.db, scheduler) @router.put( "/schedule", response_model=ScheduleInfo, summary="Update the import schedule", ) async def update_schedule( payload: ScheduleConfig, blocklist_ctx: BlocklistServiceContextDep, _auth: AuthDep, scheduler: SchedulerDep, http_session: HttpSessionDep, settings: SettingsDep, ) -> ScheduleInfo: """Persist a new schedule configuration and reschedule the import job. Args: payload: New :class:`~app.models.blocklist.ScheduleConfig`. blocklist_ctx: Blocklist service context containing db and repositories. _auth: Validated session — enforces authentication. scheduler: Shared APScheduler instance (injected). http_session: Shared HTTP session used by the scheduler job. settings: Current application settings used by the scheduler job. Returns: Updated :class:`~app.models.blocklist.ScheduleInfo`. """ return await blocklist_service.update_schedule( blocklist_ctx.db, scheduler, http_session, settings, payload, run_import_with_resources, ) @router.get( "/log", response_model=ImportLogListResponse, summary="Get the paginated import log", ) async def get_import_log( blocklist_ctx: BlocklistServiceContextDep, _auth: AuthDep, source_id: int | None = Query(default=None, description="Filter by source id"), 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)." ), ) -> ImportLogListResponse: """Return a paginated log of all import runs. Args: blocklist_ctx: Blocklist service context containing db and repositories. _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`. """ return await blocklist_service.list_import_logs( blocklist_ctx.db, source_id=source_id, page=page, page_size=page_size ) # --------------------------------------------------------------------------- # 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, blocklist_ctx: BlocklistServiceContextDep, _auth: AuthDep, ) -> BlocklistSource: """Return a single blocklist source by id. Args: source_id: Primary key of the source. blocklist_ctx: Blocklist service context containing db and repositories. _auth: Validated session — enforces authentication. Raises: HTTPException: 404 if the source does not exist. """ source = await blocklist_service.get_source(blocklist_ctx.db, source_id) if source is None: raise BlocklistSourceNotFoundError(source_id) return source @router.put( "/{source_id}", response_model=BlocklistSource, summary="Update a blocklist source", ) async def update_blocklist( source_id: int, payload: BlocklistSourceUpdate, blocklist_ctx: BlocklistServiceContextDep, _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). blocklist_ctx: Blocklist service context containing db and repositories. _auth: Validated session — enforces authentication. Raises: HTTPException: 400 if URL validation fails. HTTPException: 404 if the source does not exist. """ try: updated = await blocklist_service.update_source( blocklist_ctx.db, source_id, name=payload.name, url=str(payload.url) if payload.url is not None else None, enabled=payload.enabled, ) except ValueError as exc: raise BadRequestError(str(exc)) from exc if updated is None: raise BlocklistSourceNotFoundError(source_id) 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, blocklist_ctx: BlocklistServiceContextDep, _auth: AuthDep, ) -> None: """Delete a blocklist source by id. Args: source_id: Primary key of the source to remove. blocklist_ctx: Blocklist service context containing db and repositories. _auth: Validated session — enforces authentication. Raises: HTTPException: 404 if the source does not exist. """ deleted = await blocklist_service.delete_source(blocklist_ctx.db, source_id) if not deleted: raise BlocklistSourceNotFoundError(source_id) @router.get( "/{source_id}/preview", response_model=PreviewResponse, summary="Preview the contents of a blocklist source", ) async def preview_blocklist( source_id: int, http_session: HttpSessionDep, blocklist_ctx: BlocklistServiceContextDep, _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. http_session: Shared HTTP session for downloading. blocklist_ctx: Blocklist service context containing db and repositories. _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(blocklist_ctx.db, source_id) if source is None: raise BlocklistSourceNotFoundError(source_id) try: domain_result = await blocklist_service.preview_source(source.url, http_session) return blocklist_mappers.map_domain_preview_result_to_response(domain_result) except ValueError as exc: raise BadRequestError(f"Could not fetch blocklist: {exc}") from exc