"""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, Depends, Query, Request, status from app.dependencies import ( AuthDep, BlocklistServiceContextDep, Fail2BanSocketDep, GeoCacheDep, GlobalRateLimiterDep, 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, RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS router: APIRouter = APIRouter(prefix="/api/v1/blocklists", tags=["Blocklists"]) # Rate limit bucket constants _BLOCKLIST_IMPORT_BUCKET = "blocklist:import" # 3600 seconds per hour _HOUR = 3600 def _check_blocklist_import_rate_limit( request: Request, rate_limiter: GlobalRateLimiterDep, ) -> None: """Check rate limit for blocklist import operations.""" from app.utils.client_ip import get_client_ip settings = request.app.state.settings client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) is_allowed, retry_after = rate_limiter.check_allowed_for_bucket( _BLOCKLIST_IMPORT_BUCKET, client_ip, RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS, _HOUR ) if not is_allowed: from app.exceptions import RateLimitError import structlog log = structlog.get_logger() log.warning( "blocklist_import_rate_limit_exceeded", client_ip=client_ip, path=request.url.path, retry_after=retry_after, ) raise RateLimitError( "Rate limit exceeded for blocklist import. Please try again later.", retry_after_seconds=retry_after, ) # --------------------------------------------------------------------------- # Source list + create # --------------------------------------------------------------------------- @router.get( "", response_model=BlocklistListResponse, summary="List all blocklist sources", responses={ 200: {"description": "Blocklist sources returned", "model": BlocklistListResponse}, 401: {"description": "Session missing, expired, or invalid"}, }, ) 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", responses={ 201: {"description": "Blocklist source created", "model": BlocklistSource}, 400: {"description": "URL validation failed"}, 401: {"description": "Session missing, expired, or invalid"}, }, ) 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", dependencies=[Depends(_check_blocklist_import_rate_limit)], responses={ 200: {"description": "Import completed", "model": ImportRunResult}, 401: {"description": "Session missing, expired, or invalid"}, 429: {"description": "Rate limit exceeded for 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", responses={ 200: {"description": "Schedule info returned", "model": ScheduleInfo}, 401: {"description": "Session missing, expired, or invalid"}, }, ) 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", responses={ 200: {"description": "Schedule updated", "model": ScheduleInfo}, 401: {"description": "Session missing, expired, or invalid"}, }, ) 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", responses={ 200: {"description": "Import log returned", "model": ImportLogListResponse}, 401: {"description": "Session missing, expired, or invalid"}, }, ) 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", responses={ 200: {"description": "Blocklist source returned", "model": BlocklistSource}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "Blocklist source not found"}, }, ) 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", responses={ 200: {"description": "Blocklist source updated", "model": BlocklistSource}, 400: {"description": "URL validation failed"}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "Blocklist source not found"}, }, ) 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", responses={ 204: {"description": "Blocklist source deleted successfully"}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "Blocklist source not found"}, }, ) 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", responses={ 200: {"description": "Blocklist preview returned", "model": PreviewResponse}, 401: {"description": "Session missing, expired, or invalid"}, 404: {"description": "Blocklist source not found"}, 502: {"description": "URL could not be reached"}, }, ) async def preview_blocklist( source_id: int, http_session: HttpSessionDep, blocklist_ctx: BlocklistServiceContextDep, settings: SettingsDep, _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, sample_lines=settings.blocklist_preview_max_lines ) 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