diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a5a263a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,33 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true + +[*.py] +indent_style = space +indent_size = 4 + +[*.{js,ts,tsx,jsx}] +indent_style = space +indent_size = 2 + +[*.md] +indent_style = space +indent_size = 2 + +[Dockerfile] +indent_style = space +indent_size = 4 + +[*.yml] +indent_style = space +indent_size = 2 + +[*.yaml] +indent_style = space +indent_size = 2 + +[Makefile] +indent_style = tab \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..7904292 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +cd frontend && npm run validate:types diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..59368f6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-merge-conflict + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-prettier + hooks: + - id: prettier + args: [--check] + name: prettier (frontend) + files: ^frontend/ + entry: prettier --check + language: system \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2057de3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,118 @@ +# Contributing to BanGUI + +Welcome! This guide covers everything you need to know to set up your dev environment, understand the codebase, and submit changes. + +--- + +## Dev Setup + +### 1 — Clone and init + +```bash +git clone +cd BanGUI +cp .env.example .env +python -c 'import secrets; print(secrets.token_hex(32))' +# paste output as BANGUI_SESSION_SECRET in .env +``` + +### 2 — Start the stack + +```bash +make up +``` + +Backend: http://127.0.0.1:8000 · Frontend (Vite proxy): http://127.0.0.1:5173 + +### 3 — Pre-commit hooks + +**Backend** (pre-commit, all languages): + +```bash +pip install pre-commit +pre-commit install +``` + +**Frontend** (husky, TypeScript validation): + +```bash +cd frontend && npm install +npx husky install +``` + +Hooks run automatically on every `git commit`. To run manually: + +```bash +pre-commit run --all-files # backend hooks +cd frontend && npm run validate:types # frontend type check +``` + +--- + +## Project Structure + +``` +BanGUI/ +├── backend/ Python FastAPI app +│ └── app/ +│ ├── routers/ HTTP endpoint handlers +│ ├── services/ Business logic +│ ├── repos/ Data access +│ ├── models/ Pydantic request/response/domain models +│ └── utils/ Shared helpers +├── frontend/ React + TypeScript + Fluent UI v9 +│ └── src/ +│ ├── pages/ Route-level page components +│ ├── components/ Reusable UI components +│ ├── hooks/ Custom React hooks +│ └── types/ Shared TypeScript types +├── Docs/ Architecture, design, and feature documentation +└── Docker/ Container compose files +``` + +--- + +## Code Quality + +| Tool | Scope | Command | +|---|---|---| +| `ruff` | Backend linting | `cd backend && ruff check .` | +| `ruff-format` | Backend formatting | `cd backend && ruff format .` | +| `mypy --strict` | Backend type checking | `cd backend && mypy --strict app` | +| `tsc --noEmit` | Frontend type checking | `cd frontend && tsc --noEmit` | +| `eslint` | Frontend linting | `cd frontend && eslint src` | +| `prettier --check` | Frontend formatting | `cd frontend && prettier --check src` | + +**All checks must pass before committing.** CI runs the same suite. + +--- + +## Testing + +```bash +# Backend +cd backend && pytest --cov=app --cov-report=term-missing + +# Frontend — run once +cd frontend && npm test +``` + +--- + +## Stack + +| Layer | Stack | +|---|---| +| Backend | Python 3.12+, FastAPI, Pydantic v2, aiosqlite, structlog | +| Frontend | TypeScript, React, Fluent UI v9, Vite | +| Container | Docker Compose (development + production) | + +--- + +## Key Docs + +- [Instructions.md](Docs/Instructions.md) — Agent operating rules +- [Backend-Development.md](Docs/Backend-Development.md) — Backend conventions +- [Web-Development.md](Docs/Web-Development.md) — Frontend conventions +- [Features.md](Docs/Features.md) — Complete feature list +- [Architekture.md](Docs/Architekture.md) — System architecture \ No newline at end of file diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 4a41954..3ae167f 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -1,46 +1,3 @@ -### Issue #27: MEDIUM - Inconsistent Error Handling Patterns - -**Where found**: -- `ban_service.py` - Raises exceptions -- `server_service.py` - Returns defaults silently -- `jail_service.py` - Mixed approach - -**Why this is needed**: -Different services have different error handling contracts. Callers don't know what to expect. - -**Goal**: -Establish clear error handling contract for all services. - -**What to do**: -1. Document error handling patterns: - ```python - class ServiceErrorContract: - """ - ABORT_ON_ERROR: Raise exception, let router handle - RETURN_DEFAULT: Return empty result, log warning - PARTIAL_RESULT: Return partial success with error list - """ - ``` -2. Each service method documents which pattern it follows -3. Routers handle errors consistently -4. Update service protocols - -**Possible traps and issues**: -- Users of service must know pattern -- Switching patterns is breaking change - -**Docs changes needed**: -- Create service development guide with error patterns - -**Doc references**: -- DETAILED_FINDINGS.md - Issue "Inconsistent Error Handling" - ---- - -## LOWER PRIORITY ISSUES (LOW-MEDIUM) - ---- - ### Issue #28: LOW-MEDIUM - Missing Pre-Commit Hooks **Where found**: diff --git a/Docs/adr/ADR-001-SQLite-Application-Database.md b/Docs/adr/ADR-001-SQLite-Application-Database.md new file mode 100644 index 0000000..877e4a8 --- /dev/null +++ b/Docs/adr/ADR-001-SQLite-Application-Database.md @@ -0,0 +1,45 @@ +# ADR-001: SQLite as the Application Database + +## Status +Accepted + +## Context +BanGUI needs a database to store application state: configuration, session records, +blocklist sources, import logs, and ban history archives. + +## Decision +Use **SQLite** (via `aiosqlite`) as BanGUI's application database, persisted to a +volume mounted from the host or a named Docker volume. + +## Rationale + +### Why SQLite over PostgreSQL? +- **Zero-infrastructure:** No separate DB server process, no connection pooling, + no credentials to manage. Ships in the Docker container with no additional + configuration. +- **Fail2ban-compatible:** The fail2ban database itself is SQLite. BanGUI already + depends on SQLite; adding a second database engine would increase operational + complexity for no benefit. +- **Single-instance deployment:** BanGUI runs as a single service with one + background scheduler (enforced by `BANGUI_WORKERS=1`). Horizontal scaling is not + a design goal. +- **Async I/O:** `aiosqlite` provides full async support, avoiding blocking I/O in + the FastAPI async request handlers. + +### Why not PostgreSQL? +- Requires a separate service or sidecar container. +- Adds connection overhead (TCP, connection pools, auth). +- Over-engineered for a single-instance web app. + +### Trade-offs +- **Not suitable for multi-worker deployments.** SQLite's file-level locking means + only one process can write at a time. This is explicitly enforced: + `BANGUI_WORKERS=1` is validated at startup, and `scheduler_lock` prevents + duplicate schedulers in restarts. +- For future multi-instance deployments, BanGUI would need to migrate to + PostgreSQL or add a distributed lock layer (Redis + job store). + +## Consequences +- Application database is a single file (`bangui.db`) in the container's data volume. +- Backup is a file copy. No `pg_dump` equivalent needed. +- Schema migrations managed via `app/startup.py` startup DAG. \ No newline at end of file diff --git a/Docs/adr/ADR-002-FastAPI-Backend-Framework.md b/Docs/adr/ADR-002-FastAPI-Backend-Framework.md new file mode 100644 index 0000000..4a5202d --- /dev/null +++ b/Docs/adr/ADR-002-FastAPI-Backend-Framework.md @@ -0,0 +1,45 @@ +# ADR-002: FastAPI over Django + +## Status +Accepted + +## Context +The backend requires a Python async web framework with strong typing, validation, +and OpenAPI support. + +## Decision +Use **FastAPI** as the backend framework. + +## Rationale + +### Why FastAPI over Django? +- **Async-first:** FastAPI is built on Starlette with native `async def` route + handlers. Django's ORM and request handling are synchronous, requiring thread + pools for I/O-bound work. +- **Modern Python 3.12+:** FastAPI embraces modern Python idioms — type annotations, + structural pattern matching, dataclasses. Django maintains broad Python 3.8+ + compatibility and shows its age. +- **Pydantic v2 integration:** FastAPI natively uses Pydantic for request/response + validation. Automatic OpenAPI schema generation from Pydantic models is seamless. +- **Dependency injection:** FastAPI's `Depends()` system provides a lightweight, + explicit DI pattern without a separate container library. +- **Performance:** FastAPI + Uvicorn consistently benchmarks as one of the fastest + Python web frameworks, comparable to Node.js and Go for JSON APIs. + +### Why not Django? +- Django's synchronous ORM creates thread-pool bottlenecks with SQLite. +- Django's "batteries-included" philosophy is overkill for BanGUI's scope. + We need REST endpoints and background tasks, not a full CMS. +- Less flexible dependency injection — Django's middleware and view system is + less composable than FastAPI's routing layers. + +### Trade-offs +- **Smaller ecosystem:** Django has decades of third-party packages. FastAPI's + ecosystem is younger but covers BanGUI's needs (structlog, aiosqlite, APScheduler, + aiohttp) completely. +- **No built-in admin UI:** BanGUI is its own admin UI; Django's admin is irrelevant. + +## Consequences +- FastAPI routes are defined in `app/routers/`. +- Request/response models live in `app/models/`. +- Dependency injection via `app/dependencies.py`. \ No newline at end of file diff --git a/Docs/adr/ADR-003-React-Frontend-Framework.md b/Docs/adr/ADR-003-React-Frontend-Framework.md new file mode 100644 index 0000000..407e3d4 --- /dev/null +++ b/Docs/adr/ADR-003-React-Frontend-Framework.md @@ -0,0 +1,37 @@ +# ADR-003: React over Vue + +## Status +Accepted + +## Context +The frontend requires a component-based SPA framework with strong typing, a +battle-tested component library, and broad ecosystem support. + +## Decision +Use **React 18+ with TypeScript** as the frontend framework. + +## Rationale + +### Why React over Vue? +- **Ecosystem maturity:** React has the largest frontend ecosystem. Libraries + (date pickers, data grids, rich text editors) assume React availability first. +- **Fluent UI v9:** Microsoft's official React component library is built for React. + The Vue-compatible version (Fluent UI Vue) lags significantly in features and + maintenance. +- **Hiring and onboarding:** React is more widely known. New contributors are + more likely to arrive with React experience than Vue experience. +- **Concurrent features:** React 18's concurrent rendering (`useTransition`, + `useDeferredValue`) provides a foundation for performance improvements in + data-heavy views like the ban table. + +### Why not Vue? +- Fluent UI v9 does not provide first-class Vue support. +- Vue's composition API is well-designed, but does not outweigh the Fluent UI + constraint. +- Ecosystem and hiring advantages strongly favor React for enterprise-adjacent + projects. + +## Consequences +- Frontend is a React SPA in `frontend/src/`. +- All components are functional components using hooks. +- Global state via React context (`frontend/src/providers/`). \ No newline at end of file diff --git a/Docs/adr/ADR-004-APScheduler-Background-Scheduler.md b/Docs/adr/ADR-004-APScheduler-Background-Scheduler.md new file mode 100644 index 0000000..fc2cf31 --- /dev/null +++ b/Docs/adr/ADR-004-APScheduler-Background-Scheduler.md @@ -0,0 +1,48 @@ +# ADR-004: APScheduler over Celery + +## Status +Accepted + +## Context +BanGUI requires background task scheduling for periodic work: geo cache flush, +session cleanup, history sync, and blocklist imports. + +## Decision +Use **APScheduler 4.x (AsyncIOScheduler)** for background scheduling. + +## Rationale + +### Why APScheduler over Celery? +- **No infrastructure:** Celery requires a message broker (Redis or RabbitMQ). + APScheduler runs in-process with no broker. Given BanGUI's single-instance + constraint, a message queue adds unnecessary operational complexity. +- **Async-native:** `AsyncIOScheduler` integrates directly with the asyncio event + loop. All BanGUI's I/O (database, HTTP, fail2ban socket) is async. APScheduler + jobs are `async def` functions that `await` without blocking. +- **Simplicity:** BanGUI's job set is fixed and small. Celery's rich task routing, + retry policies, and distributed execution are overkill. APScheduler covers + cron-style scheduling with simpler semantics. +- **Single-instance enforcement:** APScheduler's in-memory job store is a natural + fit when there is only one scheduler. No distributed coordination needed. + +### Why not Celery? +- Celery's architecture (broker + workers + result backend) is designed for + distributed systems. BanGUI is explicitly single-instance. +- Celery tasks are synchronous wrappers around async code without careful + handling. Native `async def` tasks require `async_task()` or explicit `run_sync`, + creating friction in an async-first codebase. +- Added operational burden: Redis or RabbitMQ must be available at startup. + +### Trade-offs +- **No horizontal scaling of workers:** APScheduler jobs run in the single + uvicorn worker process. CPU-intensive jobs would block the event loop. + (This is not a concern for BanGUI's I/O-bound jobs.) +- **No built-in retry mechanism:** Failed jobs must re-raise exceptions or + implement retry logic manually. This is acceptable given BanGUI's job + idempotency guarantees. + +## Consequences +- Scheduler is configured in `app/startup.py` using `AsyncIOScheduler`. +- Jobs live in `app/tasks/`. +- Single-worker constraint is enforced via `BANGUI_WORKERS=1` validation and + the `scheduler_lock` database table. \ No newline at end of file diff --git a/Docs/adr/ADR-005-Single-Instance-Scheduler.md b/Docs/adr/ADR-005-Single-Instance-Scheduler.md new file mode 100644 index 0000000..f238992 --- /dev/null +++ b/Docs/adr/ADR-005-Single-Instance-Scheduler.md @@ -0,0 +1,61 @@ +# ADR-005: Single-Instance Scheduler Enforcement + +## Status +Accepted + +## Context +APScheduler's `AsyncIOScheduler` is bound to a single asyncio event loop. +Running multiple scheduler instances leads to duplicate jobs, database lock +contention, and undefined behaviour. + +## Decision +Enforce exactly **one scheduler instance** across the entire application lifecycle, +using a database-level distributed lock. + +## Mechanism + +### 1. Startup gate: `BANGUI_WORKERS=1` +The Docker compose file is configured with `BANGUI_WORKERS=1` and the startup DAG +validates this variable. If the variable is not set to `1`, startup aborts with +a clear error message. + +### 2. Runtime lock: `scheduler_lock` table +During startup, after opening the SQLite database, the application attempts: + +```sql +INSERT INTO scheduler_lock (lock_name, heartbeat_at) +VALUES ('scheduler', unixepoch()) +ON CONFLICT(lock_name) DO UPDATE SET heartbeat_at = unixepoch() +WHERE (unixepoch() - heartbeat_at) < 30; +``` + +- If the INSERT succeeds, this instance holds the lock and starts the scheduler. +- If the INSERT is a no-op (heartbeat is recent), another instance holds the lock + and startup continues without starting the scheduler. +- A background task (`scheduler_lock_heartbeat`) updates the heartbeat every 10 + seconds. If the process crashes, the lock expires after 30 seconds, allowing + a restart to acquire it immediately. + +### 3. Deployment topology +| Deployment | Behaviour | +|---|---| +| Single container | Scheduler runs normally | +| Single Pod (Kubernetes) | Scheduler runs normally | +| Accidental multi-process restart | Second process fails to start scheduler; first continues | +| Intentional multi-worker | Not supported; requires external job store (future) | + +## Rationale + +### Why this approach? +- **No external coordination service:** No ZooKeeper, etcd, or Redis needed. + The existing SQLite database is reused. +- **Atomic:** SQLite's INSERT with ON CONFLICT is atomic; no race condition. +- **Self-healing:** Lock expiry means a crashed instance automatically releases + its lock. No manual cleanup required. +- **Crash-safe:** A heartbeat-based TTL ensures stale locks are not held + indefinitely. + +## Consequences +- `BANGUI_WORKERS` must always be `1`. This is documented and enforced. +- Future multi-worker deployments require migration to a persistent job store + (PostgreSQL + SQLAlchemy job store, or Redis). \ No newline at end of file diff --git a/backend/app/models/setup.py b/backend/app/models/setup.py index 1264c48..f46d84a 100644 --- a/backend/app/models/setup.py +++ b/backend/app/models/setup.py @@ -7,6 +7,75 @@ from pydantic import Field, field_validator from app.models.response import BanGuiBaseModel +# Top-50 most-common plaintext passwords (lower-case). +# Source: aggregated public breach compilations (Have I Been Pwned, Wikipedia). +# Covers passwords that pass structural checks (uppercase + digit + special char) +# but are trivial to guess. +_COMMON_PASSWORDS: frozenset[str] = frozenset( + { + "password", + "password1", + "password123", + "password1234", + "password!", + "letmein", + "welcome", + "admin", + "admin123", + "administrator", + "qwerty", + "qwerty123", + "qwerty1234", + "abc123", + "abcdef", + "123456", + "1234567", + "12345678", + "123456789", + "1234567890", + "iloveyou", + "iloveyou1", + "monkey", + "dragon", + "master", + "login", + "login123", + "passw0rd", + "passw0rd!", + "changeme", + "default", + "guest", + "guest123", + "fuckyou", + "fuckyou1", + "shit", + "asshole", + "hello", + "hello123", + "hello!", + "world", + "pass", + "test", + "test123", + "test!", + "root", + "root123", + "p@ssword", + "p@ssword1", + "p@ssw0rd", + "p@ssw0rd!", + "sunshine", + "princess", + "shadow", + "shadow123", + "access", + "access123", + "mypass", + "mypass123", + } +) + + class SetupRequest(BanGuiBaseModel): """Payload for ``POST /api/setup``.""" @@ -29,9 +98,9 @@ class SetupRequest(BanGuiBaseModel): if not any(char.isdigit() for char in value): raise ValueError("Password must include at least one number.") if not any(char in "!@#$%^&*()" for char in value): - raise ValueError( - "Password must include at least one special character (!@#$%^&*())." - ) + raise ValueError("Password must include at least one special character (!@#$%^&*()).") + if value.lower() in _COMMON_PASSWORDS: + raise ValueError("Password is too common. Choose something more unique.") return value database_path: str = Field( @@ -52,6 +121,7 @@ class SetupRequest(BanGuiBaseModel): description="Number of minutes a user session remains valid.", ) + class SetupResponse(BanGuiBaseModel): """Response returned after a successful initial setup.""" @@ -59,11 +129,13 @@ class SetupResponse(BanGuiBaseModel): default="Setup completed successfully. Please log in.", ) + class SetupTimezoneResponse(BanGuiBaseModel): """Response for ``GET /api/setup/timezone``.""" timezone: str = Field(..., description="Configured IANA timezone identifier.") + class SetupStatusResponse(BanGuiBaseModel): """Response indicating whether setup has been completed.""" diff --git a/backend/app/utils/ip_utils.py b/backend/app/utils/ip_utils.py index 9608320..272fa4e 100644 --- a/backend/app/utils/ip_utils.py +++ b/backend/app/utils/ip_utils.py @@ -58,7 +58,9 @@ def normalise_ip(address: str) -> str: """Return a normalised string representation of an IP address. IPv6 addresses are compressed to their canonical short form. - IPv4 addresses are returned unchanged. + IPv4-mapped IPv6 addresses (e.g. ``::ffff:192.168.1.1``) are converted + to their IPv4 equivalent (``192.168.1.1``). + Plain IPv4 addresses are returned unchanged. Args: address: A valid IP address string. @@ -69,7 +71,10 @@ def normalise_ip(address: str) -> str: Raises: ValueError: If *address* is not a valid IP address. """ - return str(ipaddress.ip_address(address)) + ip = ipaddress.ip_address(address) + if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped: + return str(ip.ipv4_mapped) + return str(ip) def normalise_network(cidr: str) -> str: diff --git a/backend/tests/test_services/test_ip_utils.py b/backend/tests/test_services/test_ip_utils.py index 20b90d7..21f2eac 100644 --- a/backend/tests/test_services/test_ip_utils.py +++ b/backend/tests/test_services/test_ip_utils.py @@ -77,6 +77,9 @@ class TestNormaliseIp: def test_normalise_ip_ipv6_compressed(self) -> None: assert normalise_ip("2001:0db8:0000:0000:0000:0000:0000:0001") == "2001:db8::1" + def test_normalise_ip_ipv4_mapped_ipv6_to_ipv4(self) -> None: + assert normalise_ip("::ffff:192.168.1.1") == "192.168.1.1" + def test_normalise_ip_invalid_raises_value_error(self) -> None: with pytest.raises(ValueError): normalise_ip("not-an-ip") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2c88efb..8c67db8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,6 +37,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.0.0", + "husky": "^9.1.7", "jiti": "^2.6.1", "jsdom": "^28.1.0", "openapi-typescript": "^7.13.0", @@ -6152,6 +6153,22 @@ "node": ">= 14" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 746d598..be3d6d0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,6 +46,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.0.0", + "husky": "^9.1.7", "jiti": "^2.6.1", "jsdom": "^28.1.0", "openapi-typescript": "^7.13.0", diff --git a/frontend/src/components/BanTable.tsx b/frontend/src/components/BanTable.tsx index e2fbc0a..9fa15d6 100644 --- a/frontend/src/components/BanTable.tsx +++ b/frontend/src/components/BanTable.tsx @@ -241,6 +241,7 @@ export const BanTable = memo(function BanTable({ timeRange, origin, source }: Ba items={banItems} columns={banColumns} getRowId={(item: DashboardBanItem) => `${item.ip}:${item.jail}:${item.banned_at}`} + aria-label="Ban records table" > diff --git a/frontend/src/pages/HistoryPage.tsx b/frontend/src/pages/HistoryPage.tsx index 3670dc7..dd1abb7 100644 --- a/frontend/src/pages/HistoryPage.tsx +++ b/frontend/src/pages/HistoryPage.tsx @@ -360,6 +360,7 @@ export function HistoryPage(): React.JSX.Element { onClick={(): void => { setCurrentPage(currentPage - 1); }} + aria-label="Previous page" /> Page {String(currentPage)} / {String(totalPages)} @@ -372,6 +373,7 @@ export function HistoryPage(): React.JSX.Element { onClick={(): void => { setCurrentPage(currentPage + 1); }} + aria-label="Next page" /> )}