feat: implement API versioning /api/v1/

- 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>
This commit is contained in:
2026-05-02 21:29:30 +02:00
parent 0d5882b32f
commit cc6dbcf3f0
51 changed files with 1886 additions and 671 deletions

View File

@@ -311,3 +311,194 @@ async def get_archived_history_keyset(
return records, has_more
async def get_ip_ban_counts(
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]]:
"""Return ban event counts grouped by IP using SQL aggregation.
Uses SQL GROUP BY to aggregate in the database rather than loading
all rows into Python memory. Returns lightweight {ip, event_count} dicts
suitable for downstream aggregation.
Args:
db: Active aiosqlite connection.
since: If given, filter to events on or after this Unix timestamp.
jail: If given, filter to events for this jail.
ip_filter: If given, filter by IP (exact match list or LIKE prefix).
origin: If given, filter by ban origin ('blocklist' or 'selfblock').
action: If given, filter to this action type ('ban' or 'unban').
Returns:
List of {ip: str, event_count: int} dicts.
"""
if isinstance(ip_filter, list) and len(ip_filter) == 0:
return []
wheres: list[str] = []
params: list[object] = []
if since is not None:
wheres.append("timeofban >= ?")
params.append(since)
if jail is not None:
wheres.append("jail = ?")
params.append(jail)
if ip_filter is not None:
if isinstance(ip_filter, list):
placeholder = ", ".join("?" for _ in ip_filter)
wheres.append(f"ip IN ({placeholder})")
params.extend(ip_filter)
else:
wheres.append("ip LIKE ? ESCAPE '\\'")
params.append(f"{escape_like(ip_filter)}%")
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
async with db.execute(
"SELECT ip, COUNT(*) AS event_count "
"FROM history_archive "
f"{where_sql} "
"GROUP BY ip",
params,
) as cur:
rows = await cur.fetchall()
return [
{"ip": str(r[0]), "event_count": int(r[1])}
for r in rows
]
async def get_jail_ban_counts(
db: aiosqlite.Connection,
since: int | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
) -> tuple[int, list[dict[str, Any]]]:
"""Return per-jail ban counts and total using SQL aggregation.
Args:
db: Active aiosqlite connection.
since: If given, filter to events on or after this Unix timestamp.
origin: If given, filter by ban origin ('blocklist' or 'selfblock').
action: If given, filter to this action type ('ban' or 'unban').
Returns:
A 2-tuple (total_count, jail_counts) where jail_counts is a list
of {jail: str, event_count: int} dicts sorted descending by count.
"""
wheres: list[str] = []
params: list[object] = []
if since is not None:
wheres.append("timeofban >= ?")
params.append(since)
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
async with db.execute(
f"SELECT COUNT(*) FROM history_archive {where_sql}", params
) as cur:
row = await cur.fetchone()
total = int(row[0]) if row is not None and row[0] is not None else 0
async with db.execute(
"SELECT jail, COUNT(*) AS event_count "
"FROM history_archive "
f"{where_sql} "
"GROUP BY jail "
"ORDER BY event_count DESC",
params,
) as cur:
rows = await cur.fetchall()
return total, [
{"jail": str(r[0]), "event_count": int(r[1])}
for r in rows
]
async def get_ban_counts_by_bucket(
db: aiosqlite.Connection,
since: int,
bucket_secs: int,
num_buckets: int,
origin: BanOrigin | None = None,
action: str | None = None,
) -> list[int]:
"""Return ban counts bucketed by time using SQL aggregation.
Args:
db: Active aiosqlite connection.
since: Start of the time window (Unix timestamp).
bucket_secs: Width of each bucket in seconds.
num_buckets: Total number of buckets in the window.
origin: If given, filter by ban origin.
action: If given, filter to this action type ('ban' or 'unban').
Returns:
List of int counts, one per bucket, indexed by bucket index.
"""
wheres: list[str] = ["timeofban >= ?"]
params: list[object] = [since]
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
where_sql = "WHERE " + " AND ".join(wheres)
async with db.execute(
"SELECT CAST((timeofban - ?) / ? AS INTEGER) AS bucket_idx, "
"COUNT(*) AS cnt "
"FROM history_archive "
f"{where_sql} GROUP BY bucket_idx ORDER BY bucket_idx",
(since, bucket_secs, *params),
) as cur:
rows = await cur.fetchall()
counts: list[int] = [0] * num_buckets
for row in rows:
idx: int = int(row[0])
if 0 <= idx < num_buckets:
counts[idx] = int(row[1])
return counts

View File

@@ -301,6 +301,37 @@ class HistoryArchiveRepository(Protocol):
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: