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:
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user