- All backend routers moved to /api/v1/ prefix
- Frontend BASE_URL updated to /api/v1
- Setup redirect middleware updated to redirect to /api/v1/setup
- Health router path fixed: prefix=/api/v1/health, @router.get('')
- conftest.py: set server_status=online for test fixture
- Created Docs/API_VERSIONING.md with deprecation policy
- Updated Docs/Backend-Development.md with versioning section
- Updated Instructions.md curl examples
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
395 lines
10 KiB
Python
395 lines
10 KiB
Python
"""Repository interface protocols for dependency injection.
|
|
|
|
Routers and services can depend on these abstractions instead of concrete
|
|
module implementations, making the backend easier to test and extend.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Sequence
|
|
from typing import Any, Protocol
|
|
|
|
import aiosqlite
|
|
|
|
from app.models.auth import Session
|
|
from app.models.ban import BanOrigin
|
|
from app.repositories.fail2ban_db_repo import BanIpCount, BanRecord, HistoryRecord, JailBanCount
|
|
from app.repositories.geo_cache_repo import GeoCacheRow
|
|
from app.repositories.import_log_repo import ImportLogRow
|
|
from app.models.blocklist import ImportRunEntry
|
|
|
|
|
|
|
|
class SessionRepository(Protocol):
|
|
"""Protocol for session persistence operations."""
|
|
|
|
async def create_session(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
token: str,
|
|
created_at: str,
|
|
expires_at: str,
|
|
) -> Session:
|
|
...
|
|
|
|
async def get_session(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
token: str,
|
|
) -> Session | None:
|
|
...
|
|
|
|
async def delete_session(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
token: str,
|
|
) -> None:
|
|
...
|
|
|
|
async def delete_expired_sessions(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
now_iso: str,
|
|
) -> int:
|
|
...
|
|
|
|
|
|
class SettingsRepository(Protocol):
|
|
"""Protocol for application settings persistence operations."""
|
|
|
|
async def get_setting(self, db: aiosqlite.Connection, key: str) -> str | None:
|
|
...
|
|
|
|
async def set_setting(self, db: aiosqlite.Connection, key: str, value: str) -> None:
|
|
...
|
|
|
|
async def delete_setting(self, db: aiosqlite.Connection, key: str) -> None:
|
|
...
|
|
|
|
async def get_all_settings(self, db: aiosqlite.Connection) -> dict[str, str]:
|
|
...
|
|
|
|
async def set_settings_batch(self, db: aiosqlite.Connection, settings: dict[str, str]) -> None:
|
|
...
|
|
|
|
|
|
class BlocklistRepository(Protocol):
|
|
async def create_source(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
name: str,
|
|
url: str,
|
|
*,
|
|
enabled: bool = True,
|
|
) -> int:
|
|
...
|
|
|
|
async def get_source(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
source_id: int,
|
|
) -> dict[str, Any] | None:
|
|
...
|
|
|
|
async def list_sources(self, db: aiosqlite.Connection) -> list[dict[str, Any]]:
|
|
...
|
|
|
|
async def list_enabled_sources(self, db: aiosqlite.Connection) -> list[dict[str, Any]]:
|
|
...
|
|
|
|
async def update_source(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
source_id: int,
|
|
*,
|
|
name: str | None = None,
|
|
url: str | None = None,
|
|
enabled: bool | None = None,
|
|
) -> bool:
|
|
...
|
|
|
|
async def delete_source(self, db: aiosqlite.Connection, source_id: int) -> bool:
|
|
...
|
|
|
|
|
|
class ImportLogRepository(Protocol):
|
|
async def add_log(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
*,
|
|
source_id: int | None,
|
|
source_url: str,
|
|
ips_imported: int,
|
|
ips_skipped: int,
|
|
errors: str | None,
|
|
) -> int:
|
|
...
|
|
|
|
async def list_logs(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
*,
|
|
source_id: int | None = None,
|
|
page: int = 1,
|
|
page_size: int = 50,
|
|
) -> tuple[list[ImportLogRow], int]:
|
|
...
|
|
|
|
async def get_last_log(self, db: aiosqlite.Connection) -> ImportLogRow | None:
|
|
...
|
|
|
|
def compute_total_pages(self, total: int, page_size: int) -> int:
|
|
...
|
|
|
|
|
|
class ImportRunRepository(Protocol):
|
|
"""Protocol for tracking blocklist import runs for idempotency."""
|
|
|
|
async def get_by_source_and_hash(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
source_id: int,
|
|
content_hash: str,
|
|
) -> ImportRunEntry | None:
|
|
"""Check if a specific import (by source and content hash) has been completed."""
|
|
...
|
|
|
|
async def create_pending(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
source_id: int,
|
|
content_hash: str,
|
|
) -> int:
|
|
"""Create a pending import run entry. Returns the id."""
|
|
...
|
|
|
|
async def mark_completed(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
run_id: int,
|
|
imported_count: int,
|
|
skipped_count: int,
|
|
) -> None:
|
|
"""Mark an import run as completed with final counts."""
|
|
...
|
|
|
|
async def mark_failed(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
run_id: int,
|
|
error_message: str,
|
|
) -> None:
|
|
"""Mark an import run as failed with error details."""
|
|
...
|
|
|
|
|
|
class GeoCacheRepository(Protocol):
|
|
async def load_all(self, db: aiosqlite.Connection) -> list[GeoCacheRow]:
|
|
...
|
|
|
|
async def get_unresolved_ips(self, db: aiosqlite.Connection) -> list[str]:
|
|
...
|
|
|
|
async def count_unresolved(self, db: aiosqlite.Connection) -> int:
|
|
...
|
|
|
|
async def upsert_entry(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
ip: str,
|
|
country_code: str | None,
|
|
country_name: str | None,
|
|
asn: str | None,
|
|
org: str | None,
|
|
) -> None:
|
|
...
|
|
|
|
async def upsert_entry_and_commit(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
ip: str,
|
|
country_code: str | None,
|
|
country_name: str | None,
|
|
asn: str | None,
|
|
org: str | None,
|
|
) -> None:
|
|
...
|
|
|
|
async def upsert_neg_entry(self, db: aiosqlite.Connection, ip: str) -> None:
|
|
...
|
|
|
|
async def upsert_neg_entry_and_commit(self, db: aiosqlite.Connection, ip: str) -> None:
|
|
...
|
|
|
|
async def bulk_upsert_entries(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
|
|
) -> int:
|
|
...
|
|
|
|
async def bulk_upsert_entries_and_commit(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
|
|
) -> int:
|
|
...
|
|
|
|
async def bulk_upsert_neg_entries(self, db: aiosqlite.Connection, ips: list[str]) -> int:
|
|
...
|
|
|
|
async def bulk_upsert_neg_entries_and_commit(self, db: aiosqlite.Connection, ips: list[str]) -> int:
|
|
...
|
|
|
|
async def bulk_upsert_entries_and_neg_entries_and_commit(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
|
|
ips: list[str],
|
|
) -> tuple[int, int]:
|
|
...
|
|
|
|
async def delete_stale_entries(self, db: aiosqlite.Connection, cutoff_iso: str) -> int:
|
|
...
|
|
|
|
|
|
class HistoryArchiveRepository(Protocol):
|
|
"""Protocol for archived ban history persistence operations."""
|
|
|
|
async def archive_ban_event(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
jail: str,
|
|
ip: str,
|
|
timeofban: int,
|
|
bancount: int,
|
|
data: str,
|
|
action: str = "ban",
|
|
) -> bool:
|
|
...
|
|
|
|
async def get_max_timeofban(self, db: aiosqlite.Connection) -> int | None:
|
|
...
|
|
|
|
async def get_archived_history(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
since: int | None = None,
|
|
jail: str | None = None,
|
|
ip_filter: str | list[str] | None = None,
|
|
origin: BanOrigin | None = None,
|
|
action: str | None = None,
|
|
page: int = 1,
|
|
page_size: int = 100,
|
|
) -> tuple[list[dict[str, Any]], int]:
|
|
...
|
|
|
|
async def get_all_archived_history(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
since: int | None = None,
|
|
jail: str | None = None,
|
|
ip_filter: str | list[str] | None = None,
|
|
origin: BanOrigin | None = None,
|
|
action: str | None = None,
|
|
page_size: int = 1000,
|
|
max_rows: int = 50_000,
|
|
last_ban_id: int | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
...
|
|
|
|
async def purge_archived_history(self, db: aiosqlite.Connection, age_seconds: int) -> int:
|
|
...
|
|
|
|
async def get_ip_ban_counts(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
since: int | None = None,
|
|
jail: str | None = None,
|
|
ip_filter: str | list[str] | None = None,
|
|
origin: BanOrigin | None = None,
|
|
action: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
...
|
|
|
|
async def get_jail_ban_counts(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
since: int | None = None,
|
|
origin: BanOrigin | None = None,
|
|
action: str | None = None,
|
|
) -> tuple[int, list[dict[str, Any]]]:
|
|
...
|
|
|
|
async def get_ban_counts_by_bucket(
|
|
self,
|
|
db: aiosqlite.Connection,
|
|
since: int,
|
|
bucket_secs: int,
|
|
num_buckets: int,
|
|
origin: BanOrigin | None = None,
|
|
action: str | None = None,
|
|
) -> list[int]:
|
|
...
|
|
|
|
|
|
class Fail2BanDbRepository(Protocol):
|
|
async def check_db_nonempty(self, db_path: str) -> bool:
|
|
...
|
|
|
|
async def get_currently_banned(
|
|
self,
|
|
db_path: str,
|
|
since: int,
|
|
origin: BanOrigin | None = None,
|
|
*,
|
|
ip_filter: list[str] | None = None,
|
|
limit: int | None = None,
|
|
offset: int | None = None,
|
|
) -> tuple[list[BanRecord], int]:
|
|
...
|
|
|
|
async def get_ban_counts_by_bucket(
|
|
self,
|
|
db_path: str,
|
|
since: int,
|
|
bucket_secs: int,
|
|
num_buckets: int,
|
|
origin: BanOrigin | None = None,
|
|
) -> list[int]:
|
|
...
|
|
|
|
async def get_ban_event_counts(
|
|
self,
|
|
db_path: str,
|
|
since: int,
|
|
origin: BanOrigin | None = None,
|
|
) -> list[BanIpCount]:
|
|
...
|
|
|
|
async def get_bans_by_jail(
|
|
self,
|
|
db_path: str,
|
|
since: int,
|
|
origin: BanOrigin | None = None,
|
|
) -> tuple[int, list[JailBanCount]]:
|
|
...
|
|
|
|
async def get_bans_table_summary(self, db_path: str) -> tuple[int, int | None, int | None]:
|
|
...
|
|
|
|
async def get_history_page(
|
|
self,
|
|
db_path: str,
|
|
since: int | None = None,
|
|
jail: str | None = None,
|
|
ip_filter: str | None = None,
|
|
origin: BanOrigin | None = None,
|
|
page: int = 1,
|
|
page_size: int = 100,
|
|
) -> tuple[list[HistoryRecord], int]:
|
|
...
|
|
|
|
async def get_history_for_ip(self, db_path: str, ip: str) -> list[HistoryRecord]:
|
|
...
|