1 Commits

Author SHA1 Message Date
025c82a982 Merge pull request 'refactoring-backend' (#3) from refactoring-backend into main
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Import Boundary (push) Has been cancelled
CI / OpenAPI Breaking Changes (push) Has been cancelled
CI / OpenAPI Baseline Commit (push) Has been cancelled
Reviewed-on: #3
2026-05-20 20:23:46 +02:00
14 changed files with 49 additions and 10443 deletions

View File

@@ -18,7 +18,7 @@ WORKDIR /build
COPY frontend/package.json frontend/package-lock.json* /build/
RUN npm ci --ignore-scripts
# Copy source + local OpenAPI spec (avoids needing a running backend during build)
# Copy source and build
COPY frontend/ /build/
RUN npm run build

View File

@@ -1 +1 @@
v0.9.19-rc.4
v0.9.19

View File

@@ -6,7 +6,7 @@
# ./release.sh
#
# The current version is stored in VERSION (next to this script).
# You will be asked whether to bump major, minor, patch, or release candidate (rc).
# You will be asked whether to bump major, minor, or patch.
set -euo pipefail
@@ -24,60 +24,24 @@ CURRENT="$(cat "${VERSION_FILE}")"
# Strip leading 'v' for arithmetic
VERSION="${CURRENT#v}"
# Parse version: X.Y.Z or X.Y.Z-rc.N
if [[ "${VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-rc\.([0-9]+))?$ ]]; then
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
RC_SUFFIX="${BASH_REMATCH[4]:-}"
RC_NUM="${BASH_REMATCH[5]:-0}"
else
echo "Error: version '${VERSION}' does not match expected format X.Y.Z or X.Y.Z-rc.N" >&2
exit 1
fi
IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION}"
echo "============================================"
echo " BanGUI — Release"
if [[ -n "${RC_SUFFIX}" ]]; then
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM}"
else
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
fi
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
echo "============================================"
echo ""
echo "How would you like to bump the version?"
if [[ -n "${RC_SUFFIX}" ]]; then
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v${MAJOR}.${MINOR}.${PATCH})"
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v${MAJOR}.$((MINOR + 1)).0)"
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v$((MAJOR + 1)).0.0)"
echo " 4) rc (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v${MAJOR}.${MINOR}.${PATCH}-rc.$((RC_NUM + 1)))"
else
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
echo " 4) rc (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.${PATCH}-rc.1)"
fi
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
echo ""
read -rp "Enter choice [1/2/3/4]: " CHOICE
read -rp "Enter choice [1/2/3]: " CHOICE
case "${CHOICE}" in
1)
if [[ -n "${RC_SUFFIX}" ]]; then
# Release the RC: strip RC suffix
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}"
else
NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))"
fi
;;
1) NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
3) NEW_TAG="v$((MAJOR + 1)).0.0" ;;
4)
if [[ "${RC_NUM}" -gt 0 ]]; then
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}-rc.$((RC_NUM + 1))"
else
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}-rc.1"
fi
;;
*)
echo "Invalid choice. Aborting." >&2
exit 1
@@ -117,13 +81,7 @@ fi
# Push containers
# ---------------------------------------------------------------------------
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
# Push to "latest" or "latestRC" depending on whether this is a release candidate
if [[ "${NEW_TAG}" == *-rc* ]]; then
bash "${SCRIPT_DIR}/push.sh" "latestRC"
else
bash "${SCRIPT_DIR}/push.sh" "latest"
fi
bash "${SCRIPT_DIR}/push.sh"
# ---------------------------------------------------------------------------

View File

@@ -102,15 +102,10 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
"""
# Ordered list of DDL statements to execute on initialisation.
# NOTE: _CREATE_SESSIONS_TOKEN_INDEX is intentionally omitted here.
# The old 0.8.0 schema has a `sessions.token` column (not `token_hash`), so
# running CREATE INDEX … ON sessions (token_hash) in migration 1 would fail
# with "no such column: token_hash" on legacy databases. Migration 2 drops
# and recreates the sessions table with token_hash and also creates the index,
# so there is no need to create it in migration 1.
_SCHEMA_STATEMENTS: list[str] = [
_CREATE_SETTINGS,
_CREATE_SESSIONS,
_CREATE_SESSIONS_TOKEN_INDEX,
_CREATE_BLOCKLIST_SOURCES,
_CREATE_IMPORT_LOG,
_CREATE_GEO_CACHE,
@@ -138,24 +133,8 @@ CREATE UNIQUE INDEX idx_sessions_token_hash ON sessions (token_hash);
3: """
-- Migration 3: Add last_seen timestamp to geo_cache for retention policy.
-- Tracks when each IP was last referenced to enable purging of stale entries.
-- SQLite rejects ALTER TABLE ADD COLUMN with a non-constant NOT NULL default
-- when the table already contains rows, so we rebuild the table instead.
-- Existing rows receive last_seen = cached_at as a reasonable approximation
-- (the IP was at least seen when it was first cached).
DROP TABLE IF EXISTS geo_cache_new;
CREATE TABLE geo_cache_new (
ip TEXT PRIMARY KEY,
country_code TEXT,
country_name TEXT,
asn TEXT,
org TEXT,
cached_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
INSERT INTO geo_cache_new (ip, country_code, country_name, asn, org, cached_at, last_seen)
SELECT ip, country_code, country_name, asn, org, cached_at, cached_at FROM geo_cache;
DROP TABLE geo_cache;
ALTER TABLE geo_cache_new RENAME TO geo_cache;
-- Default to current timestamp for existing rows.
ALTER TABLE geo_cache ADD COLUMN last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
""",
4: """
-- Migration 4: Add scheduler_lock table for multi-worker safety.

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bangui-backend"
version = "0.9.19-rc.3"
version = "0.9.19"
description = "BanGUI backend — fail2ban web management interface"
requires-python = ">=3.12"
dependencies = [

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
{
"name": "bangui-frontend",
"private": true,
"version": "0.9.19-rc.4",
"version": "0.9.19",
"description": "BanGUI frontend — fail2ban web management interface",
"type": "module",
"scripts": {
"dev": "vite",
"generate:types": "openapi-typescript ./openapi.json -o src/types/generated.ts",
"generate:types": "openapi-typescript http://localhost:8000/api/openapi.json -o src/types/generated.ts",
"validate:types": "bash scripts/validate-types.sh",
"build": "npm run generate:types && tsc --noEmit && vite build",
"preview": "vite preview",

View File

@@ -17,23 +17,17 @@ GENERATED_FILE="${TYPES_DIR}/generated.ts"
TEMP_FILE=$(mktemp)
trap "rm -f $TEMP_FILE" EXIT
# Determine OpenAPI source: local file or backend URL
# Check if backend is accessible
BACKEND_URL="${BANGUI_BACKEND_URL:-http://localhost:8000}"
OPENAPI_SOURCE=""
if [[ -f "${FRONTEND_DIR}/openapi.json" ]]; then
OPENAPI_SOURCE="${FRONTEND_DIR}/openapi.json"
echo "📋 Validating OpenAPI schema types (local openapi.json)..."
elif curl -sf "${BACKEND_URL}/api/openapi.json" > /dev/null 2>&1; then
OPENAPI_SOURCE="${BACKEND_URL}/api/openapi.json"
echo "📋 Validating OpenAPI schema types (backend ${BACKEND_URL})..."
else
echo "❌ Backend not accessible at ${BACKEND_URL}/api/openapi.json and no local openapi.json found" >&2
if ! curl -sf "${BACKEND_URL}/api/openapi.json" > /dev/null 2>&1; then
echo "❌ Backend not accessible at ${BACKEND_URL}/api/openapi.json" >&2
exit 2
fi
echo "📋 Validating OpenAPI schema types..."
# Generate types to a temporary file
if ! npx openapi-typescript "${OPENAPI_SOURCE}" -o "$TEMP_FILE" 2>&1; then
if ! npx openapi-typescript "${BACKEND_URL}/api/openapi.json" -o "$TEMP_FILE" 2>&1; then
echo "❌ Failed to generate types from OpenAPI schema" >&2
exit 3
fi

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "../ErrorBoundary";
import * as telemetry from "../../utils/telemetry";
// Mock telemetry to verify it's called
vi.mock("../../utils/telemetry");

View File

@@ -468,10 +468,13 @@ describe("useFetchData", () => {
});
it("last subscriber abort cancels underlying request", async () => {
let resolveFirst: ((value: { value: string }) => void) | null = null;
const abortSignals: AbortSignal[] = [];
const fetcher = vi.fn().mockImplementation((signal: AbortSignal) => {
abortSignals.push(signal);
return new Promise(() => {});
return new Promise((resolve) => {
resolveFirst = resolve;
});
});
const selector = vi.fn((response: { value: string }) => response.value);

View File

@@ -10,7 +10,7 @@ describe("useJailBannedIps", () => {
const fetchMock = vi.mocked(api.fetchJailBannedIps);
const unbanMock = vi.mocked(api.unbanIp);
fetchMock.mockResolvedValue({ items: [{ ip: "1.2.3.4", jail: "sshd", banned_at: "2025-01-01T10:00:00+00:00", expires_at: "2025-01-01T10:10:00+00:00", ban_count: 1, country: "US" }], total: 1, page: 1, page_size: 25, total_pages: 1, pagination_mode: "offset" });
fetchMock.mockResolvedValue({ items: [{ ip: "1.2.3.4", jail: "sshd", banned_at: "2025-01-01T10:00:00+00:00", expires_at: "2025-01-01T10:10:00+00:00", ban_count: 1, country: "US" }], total: 1, page: 1, page_size: 25 });
unbanMock.mockResolvedValue({ message: "ok", jail: "sshd", success: true });
const { result } = renderHook(() => useJailBannedIps("sshd"));

View File

@@ -34,6 +34,8 @@ describe("usePolledData", () => {
vi.runAllTimersAsync();
});
const callCountAfterInitial = fetcher.mock.calls.length;
// Reset timer and advance to ensure no more polls
vi.clearAllTimers();
fetcher.mockClear();
@@ -64,6 +66,8 @@ describe("usePolledData", () => {
vi.advanceTimersByTime(100);
});
const initialCalls = fetcher.mock.calls.length;
// Clear for clean test
fetcher.mockClear();
@@ -131,6 +135,7 @@ describe("usePolledData", () => {
vi.advanceTimersByTime(100);
});
const initialCalls = fetcher.mock.calls.length;
fetcher.mockClear();
// Call refresh

View File

@@ -56,7 +56,7 @@ import React, {
} from "react";
import { useNavigate } from "react-router-dom";
import * as authApi from "../api/auth";
import { ApiError, setUnauthorizedHandler, resetLogoutState, clearSessionCorrelationId } from "../api/client";
import { setUnauthorizedHandler, resetLogoutState, clearSessionCorrelationId } from "../api/client";
import { setAuthErrorHandler, resetLogoutState as resetFetchErrorLogoutState } from "../utils/fetchError";
import { STORAGE_KEY_AUTHENTICATED } from "../utils/constants";
import { SessionValidationLoading } from "../components/SessionValidationLoading";
@@ -133,11 +133,6 @@ export function AuthProvider({
const handleValidationError = useCallback(
(error: Error): void => {
// Suppress noisy warning for 5xx gateway errors (e.g. 502 Bad Gateway)
// during startup — these are server-side issues, not network issues.
if (error instanceof ApiError && error.status >= 500) {
return;
}
// Network error — log but don't logout.
console.warn("Session validation network error:", error);
},

View File

@@ -177,6 +177,11 @@ export interface paths {
* On success the token is also set as an ``HttpOnly`` ``SameSite=Lax``
* cookie so the browser SPA benefits from automatic credential handling.
*
* Rate limiting: Exponential backoff on failed attempts. Each wrong password
* incurs an increasing delay (0.5s, 1s, 2s, 4s, 5s max per IP address).
* Requests during the penalty period return ``429 Too Many Requests`` with
* a ``Retry-After`` header.
*
* Cache invalidation: On successful login, any existing cached sessions for
* the same user are invalidated so that stale tokens (e.g., from a stolen
* device) cannot be reused beyond the cache TTL window.
@@ -187,6 +192,7 @@ export interface paths {
* request: The incoming HTTP request (used to extract client IP).
* session_ctx: Session service context containing db and repository.
* settings: Application settings (used for session duration and trusted proxies).
* rate_limiter: The login rate limiter (per IP).
* session_cache: Session cache for invalidating old sessions on login.
*
* Returns:
@@ -194,6 +200,7 @@ export interface paths {
*
* Raises:
* AuthenticationError: if the password is incorrect.
* RateLimitError: if the rate limit is exceeded.
*/
post: operations["login_api_v1_auth_login_post"];
delete?: never;
@@ -6267,6 +6274,13 @@ export interface operations {
};
content?: never;
};
/** @description Too many login attempts, retry after delay */
429: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Setup not complete */
503: {
headers: {