Files
BanGUI/backend/app/routers/history.py
Lukas 100fd47c4b Refactor: Make model packages true leaf nodes - remove app-layer dependencies
Models in app/models/ are now pure data classes with no cross-layer dependencies.
This ensures the models layer remains a true leaf node in the dependency graph.

Changes:
- Create app/models/_common.py with shared types (TimeRange, bucket_count, constants)
- Move TimeRange and time-range constants from ban.py to _common.py
- Update history.py, routers, and services to import from _common.py
- Remove imports from app.config and app.utils from config.py models
- Move field validators from models to router layer:
  - Add log_target validation in config_misc router
  - Add log_path validation in jail_config router
- Update test_models.py to reflect validators moved to router layer
- Update documentation (Architekture.md, Backend-Development.md) with model layering rules
- Fix import ordering and type annotations in affected files

Model layering rule: Models may only import from:
✓ Standard library and third-party packages (Pydantic, typing)
✓ Other models in app/models/ (sibling models)
✓ app.models.response (response envelopes)
✗ app.services, app.config, app.utils, or any application layer

Validation requiring app-level state (settings, allowed directories) now happens
at the router or service layer, not in model validators.

Fixes: Models were not true leaf nodes due to circular imports and app-layer dependencies

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 19:31:11 +02:00

196 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.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/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 (1500).
Returns:
:class:`~app.models.history.HistoryListResponse` with paginated items
and the total matching count.
"""
return 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,
)
@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:
return 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,
)
@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.
"""
detail: IpDetailResponse | None = await history_service.get_ip_detail(
socket_path,
ip,
http_session=http_session,
fail2ban_metadata_service=fail2ban_metadata_service,
)
if detail is None:
raise HistoryNotFoundError(ip)
return detail