Compare commits
16
Commits
363dc56cd1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c3d81920e | ||
|
|
108c6ce03d | ||
|
|
6bffc53779 | ||
|
|
3d828a61ab | ||
|
|
7f07c111ac | ||
|
|
8218039c64 | ||
|
|
f705c67fc7 | ||
|
|
c8b3054527 | ||
|
|
8ec3df541e | ||
|
|
8ee144745a | ||
|
|
64ad9baf39 | ||
|
|
704e33638f | ||
|
|
eddb7edea4 | ||
|
|
1c9ddcd48a | ||
|
|
30ff970c38 | ||
|
|
f99980052b |
No files matched your search
@@ -78,73 +78,6 @@ CLOUDFLARE_AUTO_BLOCK_ENABLED=true
|
||||
# Override for tests/staging (production uses the public endpoint by default).
|
||||
CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
|
||||
|
||||
# --- CROWDSEC API (community reputation auto-block, optional) ---
|
||||
# Free CTI API key: https://app.crowdsec.net/ → Settings → CTI API Keys.
|
||||
# When set, the anti-DDoS gate checks the community reputation of repeat
|
||||
# offenders (CTI GET /smoke/{ip}) and immediately hard-blocks known-bad IPs.
|
||||
# Lookups only happen for IPs that already tripped a rate bucket and are
|
||||
# cached in Redis for 1h, so quota usage stays minimal.
|
||||
CROWDSEC_API_KEY=
|
||||
# Runtime toggle for reputation-based auto-blocking (also overridable live
|
||||
# from the admin panel). Requires CROWDSEC_API_KEY.
|
||||
CROWDSEC_AUTO_BLOCK_ENABLED=true
|
||||
# Minimum malevolence score 0-5 (CrowdSec scale; 4-5 = "malicious") before an
|
||||
# IP is treated as known-bad. IPs with false-positive tags are never blocked.
|
||||
CROWDSEC_BLOCK_SCORE=4
|
||||
# How long a CrowdSec-confirmed bad IP stays blocked (seconds).
|
||||
CROWDSEC_BLOCK_TTL_SECONDS=86400
|
||||
# Endpoint — override only for tests/staging.
|
||||
CROWDSEC_CTI_BASE_URL=https://cti.api.crowdsec.net/v2
|
||||
# Daily enrichment-call ceiling (freemium plan ≈ 10k/day). Once today's
|
||||
# counter reaches it, reputation lookups pause until tomorrow so a spread
|
||||
# DDoS cannot silently burn the whole quota. 0 = unlimited.
|
||||
CROWDSEC_CTI_DAILY_QUOTA=10000
|
||||
# How many new community-reputation blocks within a 5-minute window justify an
|
||||
# ops alert (quota/backoff/report alerts all use HEALTH_ALERT_COOLDOWN_MIN).
|
||||
CROWDSEC_ALERT_BLOCK_BURST=10
|
||||
|
||||
# --- CROWDSEC SIGNAL PUSH (share our blocks back, optional) ---
|
||||
# Opt-in: pushes blocked IPs + behaviors to the CrowdSec Central API (CAPI) so
|
||||
# the community blocklist protects other members too. Set to "true" to enable.
|
||||
# Requires watcher credentials — either set both CROWDSEC_REPORT_MACHINE_ID
|
||||
# (48 chars, [A-Za-z0-9]) and CROWDSEC_REPORT_PASSWORD now, or leave them
|
||||
# unset and let the app generate a stable pair persisted in Redis automatically.
|
||||
CROWDSEC_REPORT_ENABLED=false
|
||||
CROWDSEC_REPORT_MACHINE_ID=
|
||||
CROWDSEC_REPORT_PASSWORD=
|
||||
# Optional: attachment key from https://app.crowdsec.net → Console settings —
|
||||
# links our watcher to your account so pushed signals show up there.
|
||||
CROWDSEC_REPORT_ENROLL_KEY=
|
||||
# Central API base — override only for tests/staging.
|
||||
CROWDSEC_CAPI_BASE_URL=https://api.crowdsec.net/v3
|
||||
|
||||
# --- CROWDSEC LOCAL (opt-in engine on this Docker host, no proxy changes) ---
|
||||
# App-layer LAPI bouncer: the anti-DDoS gate asks the local engine per client
|
||||
# IP (short-cached) and blocks ban/captcha decisions before its own buckets.
|
||||
# Start everything with `bash cms security`; it writes the key below into .env
|
||||
# and starts the CrowdSec engine bound to 127.0.0.1. Set to "true" to load the
|
||||
# bouncer without the local engine (not recommended).
|
||||
CROWDSEC_LOCAL_ENABLED=false
|
||||
# Host access-log directory mounted into the engine for detection (Nginx only).
|
||||
CROWDSEC_NGINX_LOG_DIR=/var/log/nginx
|
||||
# Change LAPI port AND LAPI URL together when 18080 is already taken.
|
||||
CROWDSEC_LAPI_PORT=18080
|
||||
CROWDSEC_LAPI_URL=http://127.0.0.1:18080
|
||||
# Generated by `bash cms security`; keep in .env, never commit a value.
|
||||
CROWDSEC_LAPI_API_KEY=
|
||||
# IP blocklist sync (`bash cms security blocklists`): space-separated URLs, by
|
||||
# default Spamhaus DROP/EDROP, DShield, CINS, Greensnow, StopForumSpam,
|
||||
# blocklist.de, Emerging Threats, abuse.ch Feodo/SSLBL/URLhaus, IPsum,
|
||||
# Firehol ipsets and Tor exit nodes. Requires internet to fetch; detection and
|
||||
# blocking stay local.
|
||||
#CROWDSEC_BLOCKLIST_SOURCES=https://www.spamhaus.org/drop/drop.txt https://example.org/list.txt
|
||||
# Expiration for each blocklist decision (re-synced keeps them fresh).
|
||||
#CROWDSEC_BLOCKLIST_DURATION=24h
|
||||
# Combined cap per sync (safety valve against excessive decisions).
|
||||
#CROWDSEC_BLOCKLIST_MAX_DECISIONS=1000000
|
||||
# Comma-separated IPs/CIDRs that a sync must always skip (allowlist).
|
||||
#CROWDSEC_BLOCKLIST_ALLOW=1.2.3.4,10.0.0.0/8
|
||||
|
||||
# --- PATHS ---
|
||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||
|
||||
@@ -33,6 +33,12 @@ jobs:
|
||||
- name: Toolchain check
|
||||
run: node scripts/check-node-toolchain.mjs
|
||||
|
||||
# Port selection decides which blue/green slot stays live. Getting it
|
||||
# wrong starts the candidate on an occupied port, so the regression that
|
||||
# caused a failed deploy is covered here, before any image is built.
|
||||
- name: Deploy port-selection tests
|
||||
run: bash scripts/ci-deploy-ports.test.sh
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
+23
-26
@@ -1,40 +1,39 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Pin the runtime to the supported engine; update both stages deliberately.
|
||||
FROM node:26.10.0-alpine AS migrations
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Keep the bootstrap aligned with package.json packageManager.
|
||||
# The apk cache is persisted in a BuildKit cache mount so git is not
|
||||
# re-downloaded on every build.
|
||||
|
||||
# Installeer git en pnpm v12
|
||||
RUN --mount=type=cache,target=/var/cache/apk \
|
||||
apk add --no-cache git \
|
||||
&& npm install -g pnpm@11.25.0
|
||||
# The pnpm store is kept in a BuildKit cache mount that persists across builds
|
||||
# on the builder. This is what stops disk usage from growing unbounded: the
|
||||
# downloaded dependency store is shared and reused instead of being copied into
|
||||
# a fresh image layer on every build. Unlike an image layer it is also prunable
|
||||
# independently, so a hard cap (see ci-deploy.sh) keeps it bounded.
|
||||
ENV PNPM_HOME=/pnpm PNPM_STORE=/pnpm/store
|
||||
# pnpm-workspace.yaml + .npmrc must be present too: the lockfile records the
|
||||
# overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build
|
||||
# where the workspace config is absent (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH).
|
||||
&& npm install -g pnpm@12.8.1
|
||||
|
||||
# Stel het PATH zo in dat Alpine pnpm gegarandeerd overal herkent
|
||||
ENV PNPM_HOME="/usr/local/share/pnpm"
|
||||
ENV PATH="$PNPM_HOME:/usr/local/bin:$PATH"
|
||||
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
||||
# pnpm fetch: download all deps into the shared cache-mounted store.
|
||||
RUN --mount=type=cache,target=/pnpm \
|
||||
pnpm fetch --ignore-scripts
|
||||
# Install offline from the cache-mounted store; the store itself stays in the
|
||||
# build cache between builds.
|
||||
RUN --mount=type=cache,target=/pnpm \
|
||||
pnpm install --frozen-lockfile --ignore-scripts --offline
|
||||
|
||||
# Voer de installatie uit met de pnpm v12 store cache-mount
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
COPY . .
|
||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
||||
|
||||
FROM migrations AS builder
|
||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
|
||||
# Fixture values exist only for this build command; production secrets are runtime-only.
|
||||
# Cache Next.js build output and webpack caches so rebuilds only redo the
|
||||
# changed parts.
|
||||
|
||||
# The build runs the webpack builder (see the `build` script in package.json).
|
||||
# Turbopack's compiler is a single native process that grows past 12GB RSS on
|
||||
# this 329-route app and gets OOM-killed; webpack peaks around 5GB. A
|
||||
# --max-old-space-size cap does NOT help, because that memory is native
|
||||
# Turbopack memory rather than the V8 heap.
|
||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
# Bouw de Next.js applicatie met caching
|
||||
RUN --mount=type=cache,target=/app/.next/cache \
|
||||
DATABASE_URL="mysql://build:[email protected]:9/build" \
|
||||
HOTEL_NAME="Build fixture" APP_URL="http://localhost:3002" \
|
||||
@@ -62,8 +61,6 @@ COPY --from=builder --chown=nextjs:nextjs /app/drizzle/migrations ./drizzle/migr
|
||||
COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs
|
||||
USER nextjs
|
||||
EXPOSE 3002
|
||||
# Self-contained healthcheck so `docker run` (ci-deploy) also gets Docker-level
|
||||
# health; docker-compose overrides this with its own probe if needed.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
|
||||
@@ -838,132 +838,6 @@ Open **DevOps → Anti-DDoS protection**
|
||||
|
||||
---
|
||||
|
||||
## Local CrowdSec Engine (opt-in)
|
||||
|
||||
The repository ships a self-contained CrowdSec engine that runs on the same
|
||||
Docker host. It runs `crowdsecurity/crowdsec:v1.8.1` in its own Compose project
|
||||
and exposes **LAPI only** on `127.0.0.1:18080`. When enabled, the app-layer
|
||||
anti-DDoS gate (`src/lib/crowdsec-local.ts`) asks the local LAPI per client IP
|
||||
(short-cached) and blocks `ban` / `captcha` decisions before its own rate
|
||||
buckets run. No reverse-proxy, Traefik, Cloudflare or firewall configuration is
|
||||
changed.
|
||||
|
||||
The engine boots in **LAPI-only mode** (`DISABLE_AGENT=true`): it does not
|
||||
consume the host Nginx access log and needs no outbound access to
|
||||
`crowdsec.net`, which is often blocked on hardened hosts. Combined with
|
||||
`DISABLE_ONLINE_API=true` (no CrowdSec Central API) the engine needs no account
|
||||
and no inbound internet — blocking comes from the imported blocklists
|
||||
(Step 4) and the app's own rate buckets. Re-enable the agent only on a host
|
||||
with outbound internet by removing `DISABLE_AGENT: "true"` from
|
||||
`deployment/crowdsec/compose.crowdsec.yml`.
|
||||
|
||||
### Step 1 — Enable the engine and register the bouncer
|
||||
|
||||
```bash
|
||||
bash cms security
|
||||
```
|
||||
|
||||
This generates `CROWDSEC_LAPI_API_KEY` (random 64 hex chars), writes the
|
||||
CrowdSec flags into `.env`, starts the engine and registers the `cms` bouncer
|
||||
against the local LAPI. The engine does **not** enroll into the CrowdSec
|
||||
Central API (`DISABLE_ONLINE_API=true`) and runs without the agent
|
||||
(`DISABLE_AGENT=true`), so it never phones home.
|
||||
|
||||
### Step 2 — Restart the CMS so it loads the bouncer credentials
|
||||
|
||||
A CI-managed `epicnext-cms-app` picks the new `.env` values up on its next
|
||||
deployment. For a clone running via the updater:
|
||||
|
||||
```bash
|
||||
bash cms update --skip-pull
|
||||
```
|
||||
|
||||
or restart the container directly (`docker compose restart cms`). Without the
|
||||
restart the gate has not loaded the LAPI URL/key yet.
|
||||
|
||||
### Step 3 — Verify
|
||||
|
||||
```bash
|
||||
bash cms security status
|
||||
```
|
||||
|
||||
Expect `CROWDSEC_LOCAL_ENABLED=yes` and `LAPI health: OK (127.0.0.1:18080)`.
|
||||
In the admin panel, **DevOps → Anti-DDoS protection** shows live block
|
||||
statistics split per origin (`community` vs `local`).
|
||||
|
||||
### Step 4 — Stop the engine again (optional)
|
||||
|
||||
```bash
|
||||
bash cms security disable
|
||||
```
|
||||
|
||||
Stops the container and sets `CROWDSEC_LOCAL_ENABLED=false`. Volumes and the
|
||||
`.env` key are kept.
|
||||
|
||||
### Step 5 — Load external IP blocklists (optional)
|
||||
|
||||
The engine has no built-in lists, so provide your own via a one-shot sync
|
||||
(fetches the sources, replaces every previous `cscli-import` decision):
|
||||
|
||||
```bash
|
||||
bash cms security blocklists
|
||||
```
|
||||
|
||||
Defaults: Spamhaus DROP/EDROP, DShield, CINS, Greensnow, StopForumSpam,
|
||||
Binary Defense, blocklist.de, Emerging Threats, BruteForceBlocker, abuse.ch
|
||||
Feodo/SSLBL, Darklist, Botvrij, IPsum, Firehol ipsets and Tor exit nodes
|
||||
(26 sources). URLhaus was removed because its `text_online` feed lists URLs,
|
||||
not IPs; a malformed token in it could otherwise expand into a bogus
|
||||
huge CIDR. The validator only accepts whole-line bare IPs or proper CIDRs,
|
||||
enforces sane prefix bounds and drops reserved/private/loopback space, so a
|
||||
bad source entry can never block the origin or internal traffic. The largest
|
||||
commercial/crowdsourced lists (AbuseIPDB, MaxMind, Cisco Talos, AlienVault
|
||||
OTX) are not included because they require an account or API key; IPsum
|
||||
already aggregates ~30 additional feeds. No account is needed, but internet
|
||||
access is — only for fetching; detection and blocking remain local. Sync
|
||||
hourly as a cron job:
|
||||
|
||||
```bash
|
||||
bash cms security blocklists-install-cron
|
||||
```
|
||||
|
||||
Removal: `bash cms security blocklists-uninstall-cron`. Dry-run without
|
||||
touching LAPI: `bash cms security blocklists --dry-run`. Override the sources,
|
||||
duration, a combined cap or an allowlist in `.env`
|
||||
(`CROWDSEC_BLOCKLIST_SOURCES`, `CROWDSEC_BLOCKLIST_DURATION`,
|
||||
`CROWDSEC_BLOCKLIST_MAX_DECISIONS`, `CROWDSEC_BLOCKLIST_ALLOW`). Existing
|
||||
`cscli-import` decisions are replaced on every sync, so removed entries
|
||||
expire.
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| ---------------------------- | ----------------------------- | ------------------------------------ |
|
||||
| `CROWDSEC_LOCAL_ENABLED` | `false` | Master switch for the local stack |
|
||||
| `CROWDSEC_LAPI_URL` | `http://127.0.0.1:18080` | LAPI endpoint (loopback only) |
|
||||
| `CROWDSEC_LAPI_PORT` | `18080` | Host port the engine maps to LAPI |
|
||||
| `CROWDSEC_LAPI_API_KEY` | — | Bouncer key; required when enabled |
|
||||
| `CROWDSEC_LAPI_TIMEOUT_MS` | `500` | Per-decision request timeout |
|
||||
| `CROWDSEC_LAPI_RETRY_MS` | `500` | Backoff before retrying LAPI |
|
||||
| `CROWDSEC_NGINX_LOG_DIR` | `/var/log/nginx` | Access-log directory for the engine |
|
||||
|
||||
### Notes and limitations
|
||||
|
||||
- Changing the port means updating `CROWDSEC_LAPI_PORT` **and**
|
||||
`CROWDSEC_LAPI_URL` together, then re-running `bash cms security`.
|
||||
- Rotate the key by editing `CROWDSEC_LAPI_API_KEY` in `.env`, running
|
||||
`bash cms security` again (re-registers the bouncer) and restarting the CMS.
|
||||
- The gate is **fail-closed at startup** when the feature is enabled without a
|
||||
key (startup aborts with a clear message). At runtime a LAPI network error
|
||||
**fails open** (traffic is allowed, decisions paused); a 403 from LAPI
|
||||
pauses local decisions for 5 minutes.
|
||||
- This bouncer is **application-layer**: it sheds known-bad IPs at the CMS
|
||||
process only. It does not drop traffic before the origin, does not protect
|
||||
other host ports/services, and depends on the client IP being trustworthy at
|
||||
the ingress. Keep the upstream protections (Cloudflare IP rules, proxy rate
|
||||
limits) for defense before the origin.
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment (blue/green)
|
||||
|
||||
|
||||
+24
@@ -38,6 +38,30 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"src/components/admin/catalog-manager/sortable-tree.tsx",
|
||||
"src/components/admin/studio/organize-imports-dialog/mall-helpers.tsx",
|
||||
"src/app/admin/import/furni/nitro-editor-dialog.tsx"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noArrayIndexKey": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["src/components/admin/catalog-manager/sortable-tree.tsx"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"css": {
|
||||
|
||||
@@ -4,7 +4,6 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
case "${1:-help}" in
|
||||
install) shift; exec bash "$DIR/scripts/docker-install.sh" "$@" ;;
|
||||
update) shift; exec bash "$DIR/scripts/docker-update.sh" "$@" ;;
|
||||
security) shift; exec bash "$DIR/scripts/crowdsec-setup.sh" "$@" ;;
|
||||
help|--help|-h) printf '%s\n' 'bash cms install Configure and install on a Linux Docker host' 'bash cms update Update using saved settings; --skip-pull uses checked-out release' 'bash cms security Configure the opt-in local CrowdSec stack (enable|status|disable|blocklists)' ;;
|
||||
*) echo "Unknown command. Use: bash cms install | update | security" >&2; exit 1 ;;
|
||||
help|--help|-h) printf '%s\n' 'bash cms install Configure and install on a Linux Docker host' 'bash cms update Update using saved settings; --skip-pull uses checked-out release' ;;
|
||||
*) echo "Unknown command. Use: bash cms install | update" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -1,4 +0,0 @@
|
||||
filenames:
|
||||
- /var/log/nginx/access.log
|
||||
labels:
|
||||
type: nginx
|
||||
@@ -1,41 +0,0 @@
|
||||
services:
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:${CROWDSEC_VERSION:-v1.8.1}
|
||||
container_name: epicnext-crowdsec
|
||||
restart: unless-stopped
|
||||
profiles: ["security"]
|
||||
environment:
|
||||
DISABLE_AGENT: "true"
|
||||
BOUNCER_KEY_cms: ${CROWDSEC_LAPI_API_KEY:?CROWDSEC_LAPI_API_KEY must be set}
|
||||
DISABLE_ONLINE_API: "true"
|
||||
GID: "${CROWDSEC_GID:-0}"
|
||||
TZ: "${TZ:-UTC}"
|
||||
ports:
|
||||
- "${CROWDSEC_LAPI_BIND_HOST:-127.0.0.1}:${CROWDSEC_LAPI_PORT:-18080}:8080"
|
||||
volumes:
|
||||
- ./acquis.d:/etc/crowdsec/acquis.d:ro
|
||||
- ${CROWDSEC_NGINX_LOG_DIR:-/var/log/nginx}:/var/log/nginx:ro
|
||||
- crowdsec-config:/etc/crowdsec
|
||||
- crowdsec-data:/var/lib/crowdsec/data
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
pids_limit: 256
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget -q -O - http://127.0.0.1:8080/health >/dev/null 2>&1",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
crowdsec-config:
|
||||
crowdsec-data:
|
||||
@@ -162,6 +162,22 @@ server {
|
||||
ssl_early_data on;
|
||||
add_header Alt-Svc 'h3=":9443"; ma=86400' always;
|
||||
|
||||
# Resuming a session skips the full handshake, which is most of the cost of
|
||||
# a TLS connection. Without this nginx performs no session resumption at all:
|
||||
# every visitor paid a full handshake on every request. 50M shared sessions
|
||||
# is roughly 1GB at the default 20-byte key id plus overhead.
|
||||
ssl_session_cache shared:CMS_TLS:50m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# `index index.html` without a `root` left nginx resolving every
|
||||
# try_files/$uri against the compiled-in default /etc/nginx/html. The
|
||||
# /robots.txt and /favicon.ico probes then stat() a path the worker cannot
|
||||
# traverse, and because a failed stat is logged at crit the error log filled
|
||||
# with 149 crit lines per scan. Pointing root at the CMS document root makes
|
||||
# the same probe a plain 404, which log_not_found already suppresses.
|
||||
root /var/www/html;
|
||||
|
||||
index index.html;
|
||||
|
||||
# ─── Security Headers ───
|
||||
@@ -366,8 +382,21 @@ server {
|
||||
add_header Cache-Tag "cms-camera";
|
||||
}
|
||||
|
||||
# robots.txt is generated by the CMS (src/app/robots.ts, force-dynamic
|
||||
# because it needs APP_URL) and sitemap.xml points crawlers at it. This
|
||||
# location used to answer from disk with try_files, which made it a
|
||||
# guaranteed 404: the file does not exist in public/, so crawlers were told
|
||||
# to obey a robots.txt they could never read. Proxy it like the route it
|
||||
# actually is. favicon.ico below stays on disk — log_not_found already
|
||||
# keeps its miss quiet.
|
||||
location = /robots.txt {
|
||||
access_log off;
|
||||
proxy_pass http://cms_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; }
|
||||
location = /robots.txt { expires 1d; access_log off; log_not_found off; try_files $uri =404; }
|
||||
|
||||
# ─── Static Next.js Assets ───
|
||||
location /_next/static/ {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
[Unit]
|
||||
# The scheduled-job worker (scheduled articles, catalog export, backups, disk
|
||||
# and health probes). This is NOT optional: a web process alone does not
|
||||
# establish that scheduled work runs. The CMS reports it as a failed
|
||||
# diagnostic row when the Redis heartbeat at cms:jobs-worker:heartbeat is
|
||||
# missing, which is exactly what happened while nothing supervised this.
|
||||
#
|
||||
# It runs on the host rather than in a container on purpose: the schedule
|
||||
# shells out to mysqldump, df and docker, none of which exist in the CMS image,
|
||||
# and it must survive CMS deploys (a container is replaced on every release).
|
||||
Description=AtomNext CMS scheduled-job worker
|
||||
Documentation=https://gitlab.epicnabbo.nl/remco/EpicNext-Cms
|
||||
After=network-online.target docker.service mariadb.service
|
||||
Wants=network-online.target
|
||||
# Start ordering only; the worker tolerates the database being briefly absent
|
||||
# and retries, so do not make it hard-fail when mariadb is slow to boot.
|
||||
Wants=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/var/www/atom-nexst
|
||||
Environment=NODE_ENV=production
|
||||
ExecStart=/usr/bin/node --conditions=react-server --import tsx scripts/jobs-worker.ts
|
||||
# The worker's own catch-all logs and exits 1 on a fatal error, so a restart is
|
||||
# always wanted. 10s backoff stops a persistent misconfiguration (missing .env,
|
||||
# bad DATABASE_URL) from spinning.
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
# Give a crashed job time to finish its DB transaction before the next start,
|
||||
# otherwise a mid-transaction kill can loop on the same failure.
|
||||
TimeoutStopSec=30
|
||||
KillSignal=SIGTERM
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=cms-jobs-worker
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,19 @@
|
||||
[Service]
|
||||
# systemd's default is 1024:524288, i.e. a *soft* LimitNOFILE of 1024. nginx
|
||||
# inherits that soft limit, so worker_connections 2048 could not actually be
|
||||
# reached and every start logged:
|
||||
# "2048 worker_connections exceed open file resource limit: 1024"
|
||||
# Raise both soft and hard to 65536 so the master's rlimit covers
|
||||
# worker_connections before nginx is even started.
|
||||
LimitNOFILE=65536
|
||||
|
||||
# The packaged unit ships Restart=no, so a crashed or OOM-killed nginx stayed
|
||||
# down until someone noticed. nginx is the only thing serving the site, so it
|
||||
# must come back on its own. `on-failure` restarts only abnormal exits, which
|
||||
# keeps an operator-initiated `systemctl stop` from being undone.
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
# Give in-flight requests time to drain on stop/reload instead of severing
|
||||
# keepalive connections and long-polling SSE streams mid-response.
|
||||
TimeoutStopSec=30
|
||||
+3
-40
@@ -1,18 +1,5 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Next.js CMS — blue/green
|
||||
#
|
||||
# De app draait met `network_mode: host`, dus een replica neemt een host-poort in
|
||||
# plaats van een gedeelde docker-poort. Daarom twee expliciete services in plaats
|
||||
# van `docker compose up --scale cms=2`: die zou op poort 3002 botsen.
|
||||
#
|
||||
# `deploy.sh` start een release op de vrije poort, wacht op /api/health, schrijft
|
||||
# daarna /etc/nginx/snippets/cms_upstream_servers.conf en herlaadt nginx. Pas dan
|
||||
# wordt de oude replica gestopt. De hele release is dus zero-downtime: faalt de
|
||||
# nieuwe replica, dan blijft de oude gewoon draaien.
|
||||
#
|
||||
# De YAML-anchor houdt beide replicas identiek. Wil je ze bewust uit elkaar
|
||||
# halen (bv. één release canary-en), verwijder dan `<<: *cms` en vul de
|
||||
# afwijkende velden opnieuw in.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
x-cms: &cms
|
||||
image: epicnext-cms:${CMS_RELEASE:-local}
|
||||
@@ -23,8 +10,6 @@ x-cms: &cms
|
||||
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
|
||||
network: host
|
||||
network_mode: host
|
||||
# 15s: Next moet een lopend request nog netjes kunnen afronden voordat SIGKILL
|
||||
# volgt. Met 10s werden streams en imports afgekapt.
|
||||
stop_grace_period: 15s
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
@@ -36,20 +21,11 @@ x-cms: &cms
|
||||
- /var/www/Gamedata:/var/www/Gamedata
|
||||
|
||||
# ── Resource limits ──
|
||||
# De limieten waren eerder weggehaald ("Next mag onbeperkt presteren"). Op een
|
||||
# gedeelde host is juist dat gevaarlijk: één geheugenlek vult dan de hele
|
||||
# machine en MariaDB + nginx + Traefik gaan er allemaal onderuit. 4 GiB met
|
||||
# 1 GiB swap geeft de V8-heap ruimte om zich te organiseren voor hij hard wordt
|
||||
# afgesneden, maar houdt de schade begrensd. 2 CPU laat drie keer zoveel
|
||||
# achtergrondwerk toe als de cores, zodat de 6 cores van deze host niet
|
||||
# volledig door twee replicas worden opgeëist.
|
||||
mem_limit: 4g
|
||||
memswap_limit: 5g
|
||||
mem_limit: 6g
|
||||
memswap_limit: 7g
|
||||
cpus: 2.0
|
||||
pids_limit: 512
|
||||
|
||||
# Leest de poort uit de eigen omgeving, dus dezelfde healthcheck werkt voor
|
||||
# 3002 én 3003 zonder dat deze tweemaal in de compose hoeft te staan.
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
|
||||
interval: 15s
|
||||
@@ -58,7 +34,6 @@ x-cms: &cms
|
||||
start_period: 40s
|
||||
|
||||
services:
|
||||
# Blauwe replica: host-poort 3002.
|
||||
cms:
|
||||
<<: *cms
|
||||
container_name: epicnext-cms
|
||||
@@ -66,8 +41,6 @@ services:
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PORT=3002
|
||||
|
||||
# Groene replica: host-poort 3003. Meestal uitgeschakeld; alleen tijdens een
|
||||
# release gestart, totdat nginx hem in de upstream-lijst heeft overgenomen.
|
||||
cms-green:
|
||||
<<: *cms
|
||||
container_name: epicnext-cms-green
|
||||
@@ -76,7 +49,6 @@ services:
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PORT=3003
|
||||
|
||||
# ── Byparr (Cloudflare bypass for clone sources) ──
|
||||
byparr:
|
||||
image: ghcr.io/thephaseless/byparr:latest
|
||||
container_name: byparr
|
||||
@@ -84,19 +56,10 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- LOG_LEVEL=INFO
|
||||
|
||||
# Resource limits verwijderd: Headless Chrome heeft bij zware pagina-scrapes
|
||||
# soms tijdelijk meer dan 1 GB RAM nodig. Nu krijgt hij alle ruimte.
|
||||
pids_limit: 256
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "http://localhost:8191/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# De database draait niet meer in Docker. `mariadb-turbo` is verwijderd: de
|
||||
# service is nooit gestart, de volume bestond niet, en de echte MariaDB draait
|
||||
# al als host-proces op 127.0.0.1:3306. De optimalisatie-vlaggen daar stonden
|
||||
# dus al langer niets meer in beheer.
|
||||
start_period: 30s
|
||||
@@ -85,24 +85,6 @@ The direct template intentionally records the CDN/edge socket address when place
|
||||
|
||||
`pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall.
|
||||
|
||||
## Opt-in: CrowdSec on the same Docker host
|
||||
|
||||
A self-contained CrowdSec engine ships in `deployment/crowdsec`. It runs `crowdsecurity/crowdsec:v1.8.1` in its own Compose project in **LAPI-only mode** (`DISABLE_AGENT=true`) and exposes LAPI only on `127.0.0.1:18080`. No reverse-proxy, Traefik, Cloudflare or firewall configuration is changed.
|
||||
|
||||
```sh
|
||||
bash cms security
|
||||
```
|
||||
|
||||
The command generates `CROWDSEC_LAPI_API_KEY`, writes the CrowdSec flags into `.env`, starts the engine and registers the `cms` bouncer. The anti-DDoS gate then consults the local LAPI per client IP (short-cached) and blocks `ban`/`captcha` decisions before its own rate buckets. `bash cms security status` reports engine state and `bash cms security disable` stops the engine and flips the toggle off.
|
||||
|
||||
The engine does not enroll into the CrowdSec Central API (`DISABLE_ONLINE_API=true`) and runs without the agent (`DISABLE_AGENT=true`), so it needs no account and no outbound access to `crowdsec.net` (often blocked on hardened hosts). Blocking comes from the imported blocklists plus the app's own rate buckets; it does not parse the host Nginx log. The app still has its separate opt-in traffic-sharing channel via `CROWDSEC_REPORT_ENABLED`. Change `CROWDSEC_LAPI_PORT` and `CROWDSEC_LAPI_URL` together when `18080` is already in use. `CROWDSEC_NGINX_LOG_DIR` is honored for when the agent is re-enabled.
|
||||
|
||||
This bouncer is application-layer: it sheds known-bad IPs at the CMS process and only for traffic that reaches the Next.js proxy. It does not drop traffic before the origin, does not protect other host ports/services, and depends on the client IP being trustworthy at the ingress. Keep the upstream protections (Cloudflare IP rules, proxy rate limits) for defense before the origin.
|
||||
|
||||
The running CMS loads the new env values on its next restart or deployment. For a CI-managed `epicnext-cms-app`, the next deploy (which sources `.env`) applies them; for a clone, `bash cms update --skip-pull` restarts it. `.env` now holds the LAPI key — keep its permissions restrictive.
|
||||
|
||||
External IP blocklists (Spamhaus, DShield, CINS, blocklist.de, abuse.ch, IPsum, Firehol, Tor exit nodes, …) can be synced into the local LAPI with `bash cms security blocklists`, and hourly with `bash cms security blocklists-install-cron` (no account, but internet to fetch). Configure via `CROWDSEC_BLOCKLIST_*`.
|
||||
|
||||
## Routine and selected-release updates
|
||||
|
||||
```sh
|
||||
|
||||
@@ -156,7 +156,14 @@ beforeAll(async () => {
|
||||
process.env.REDIS_URL = `redis://:${redisPassword}@${redisContainer.getHost()}:${redisContainer.getMappedPort(6379)}/0`;
|
||||
delete process.env.SKIP_ENV_VALIDATION;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
Object.assign(process.env, { NODE_ENV: "test" });
|
||||
// Deliberately NOT "test": cache.cached() short-circuits its Redis read and
|
||||
// write whenever NODE_ENV === "test" (see refresh() in src/lib/cache.ts).
|
||||
// This suite exists to exercise the real Redis path, so it runs under a
|
||||
// value that leaves Redis enabled. "development" is used because it is the
|
||||
// only non-production value src/env.ts accepts. Vitest's own environment is
|
||||
// still configured via vitest.integration.config.ts. Object.assign is used
|
||||
// because process.env.NODE_ENV is typed read-only.
|
||||
Object.assign(process.env, { NODE_ENV: "development" });
|
||||
process.env.HOTEL_NAME = "Integration";
|
||||
|
||||
await connection.query(
|
||||
@@ -380,7 +387,8 @@ describe("Redis application cache", () => {
|
||||
let fetches = 0;
|
||||
const fetch = async () => ({ revision: ++fetches });
|
||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||
expect(await appRedis?.get(key)).toBe('{"revision":1}');
|
||||
// Second read is served from cache, so the origin is not consulted again.
|
||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||
expect(await appRedis?.ttl(key)).toBeGreaterThan(0);
|
||||
cache.invalidateMemory(key);
|
||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||
@@ -401,6 +409,9 @@ describe("Redis application cache", () => {
|
||||
expect(await cache.cached(first, 60_000, async () => "updated")).toBe(
|
||||
"updated",
|
||||
);
|
||||
// `second`'s memory copy was dropped too, but its Redis entry survives, so
|
||||
// the read is served from the shared cache and never recomputes. This is
|
||||
// what makes the two entries independent.
|
||||
expect(await cache.cached(second, 60_000, async () => "wrong")).toBe(
|
||||
"second",
|
||||
);
|
||||
@@ -618,6 +629,9 @@ describe("real news publication, scheduling and cache delivery", () => {
|
||||
expect(existing.status).toBe("draft");
|
||||
expect(existing.publishedAt).toBeNull();
|
||||
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
||||
// A draft has no public article, so this read is a negative result that
|
||||
// gets cached. Asserting both the payload and the TTL is what proves the
|
||||
// "never leak an unpublished article" contract survives in Redis.
|
||||
const negativeRevision = await appRedis?.get(NEWS_REVISION_KEY);
|
||||
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
||||
expect(await appRedis?.get(negativeKey)).toBe("null");
|
||||
@@ -837,6 +851,7 @@ describe("real news publication, scheduling and cache delivery", () => {
|
||||
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
||||
const negativeRevision = await redis.get(NEWS_REVISION_KEY);
|
||||
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
||||
// Cached negative results are stored as the JSON encoding of null.
|
||||
expect(await redis.get(negativeKey)).toBe("null");
|
||||
const publish = articleForm({
|
||||
id: String(existing.id),
|
||||
|
||||
+17
-17
@@ -5,10 +5,10 @@
|
||||
"engines": {
|
||||
"node": ">=26.10.0 <27"
|
||||
},
|
||||
"packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec",
|
||||
"packageManager": "pnpm@12.8.1",
|
||||
"scripts": {
|
||||
"dev": "pnpm assets:editor && next dev",
|
||||
"build": "pnpm assets:editor && next build",
|
||||
"build": "pnpm assets:editor && next build --webpack",
|
||||
"start": "next start",
|
||||
"toolchain:check": "node scripts/check-node-toolchain.mjs",
|
||||
"lint": "biome check .",
|
||||
@@ -50,7 +50,7 @@
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.20",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@tanstack/react-query": "5.104.0",
|
||||
"@tanstack/react-query": "5.104.1",
|
||||
"@tanstack/react-virtual": "3.14.13",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
@@ -63,20 +63,20 @@
|
||||
"jpeg-js": "0.4.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"jszip": "3.10.2",
|
||||
"lucide-react": "1.48.0",
|
||||
"lucide-react": "1.50.0",
|
||||
"lzma-wasm": "1.0.7",
|
||||
"motion": "13.4.4",
|
||||
"motion": "14.0.0",
|
||||
"music-metadata": "11.16.1",
|
||||
"mysql2": "3.24.4",
|
||||
"next": "16.3.6",
|
||||
"mysql2": "3.24.5",
|
||||
"next": "16.3.8",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "4.14.7",
|
||||
"next-intl": "4.14.9",
|
||||
"otplib": "13.5.0",
|
||||
"pino": "10.3.1",
|
||||
"pino": "10.4.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-hook-form": "7.89.0",
|
||||
"resend": "6.30.0",
|
||||
"resend": "6.32.0",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "^0.35.5",
|
||||
"sonner": "2.0.8",
|
||||
@@ -87,25 +87,25 @@
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@babel/parser": "7.29.9",
|
||||
"@biomejs/biome": "2.5.14",
|
||||
"@biomejs/biome": "2.5.15",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@types/node": "26.6.3",
|
||||
"@types/node": "26.6.4",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitest/coverage-v8": "5.0.2",
|
||||
"@vitest/coverage-v8": "5.0.3",
|
||||
"drizzle-kit": "0.31.11",
|
||||
"esbuild": "0.28.2",
|
||||
"msw": "2.15.0",
|
||||
"msw": "3.0.1",
|
||||
"pino-pretty": "13.1.3",
|
||||
"postcss": "8.5.28",
|
||||
"tailwindcss": "4.3.3",
|
||||
"testcontainers": "12.1.0",
|
||||
"testcontainers": "12.2.0",
|
||||
"tsx": "4.23.15",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.3.1",
|
||||
"vitest": "5.0.2"
|
||||
"vite": "8.3.2",
|
||||
"vitest": "5.0.3"
|
||||
}
|
||||
}
|
||||
Generated
+444
-359
File diff suppressed because it is too large.
Load diff
Binary file not shown.
@@ -1,258 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$DIR"
|
||||
ENV_FILE="$DIR/.env"
|
||||
COMPOSE_FILE="deployment/crowdsec/compose.crowdsec.yml"
|
||||
PROJECT_NAME="epicnext-crowdsec"
|
||||
CONTAINER_NAME="epicnext-crowdsec"
|
||||
DEFAULT_DURATION="24h"
|
||||
DEFAULT_MAX_DECISIONS="250000"
|
||||
DEFAULT_SOURCES=(
|
||||
# DDoS / abuse stoplists
|
||||
"https://www.spamhaus.org/drop/drop.txt"
|
||||
"https://www.spamhaus.org/drop/edrop.txt"
|
||||
"https://www.dshield.org/block.txt"
|
||||
"https://cinsscore.com/list/ci-badguys.txt"
|
||||
"https://blocklist.greensnow.co/greensnow.txt"
|
||||
"https://www.stopforumspam.com/downloads/toxic_ip_cidr.txt"
|
||||
"https://www.binarydefense.com/banlist.txt"
|
||||
# Brute force / credential stuffing
|
||||
"https://lists.blocklist.de/lists/all.txt"
|
||||
"https://lists.blocklist.de/lists/ssh.txt"
|
||||
"https://lists.blocklist.de/lists/apache.txt"
|
||||
"https://rules.emergingthreats.net/blockrules/compromised-ips.txt"
|
||||
"https://danger.rulez.sk/projects/bruteforceblocker/blist.php"
|
||||
# Malware C2 / botnets
|
||||
"https://feodotracker.abuse.ch/downloads/ipblocklist.txt"
|
||||
"https://sslbl.abuse.ch/blacklist/sslipblacklist.txt"
|
||||
"https://www.botvrij.eu/data/ioclist.ip-dst.raw"
|
||||
# Live SSH/spam attackers (last 48h), bare IPs only
|
||||
"https://www.darklist.de/raw.php"
|
||||
# Aggregated threat intel
|
||||
"https://raw.githubusercontent.com/stamparm/ipsum/master/levels/3.txt"
|
||||
"https://raw.githubusercontent.com/stamparm/ipsum/master/levels/2.txt"
|
||||
# Firehol ipsets (security scanners, abusers, proxies, anonymous)
|
||||
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset"
|
||||
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level2.netset"
|
||||
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_abusers_1d.netset"
|
||||
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_abusers_30d.netset"
|
||||
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_proxies.netset"
|
||||
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_anonymous.netset"
|
||||
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level3.netset"
|
||||
# Tor exit nodes
|
||||
"https://check.torproject.org/torbulkexitlist"
|
||||
)
|
||||
|
||||
mode="${1:-sync}"
|
||||
dry_run=false
|
||||
case "$mode" in
|
||||
sync) ;;
|
||||
install-cron|uninstall-cron) ;;
|
||||
*) printf 'ERROR: unknown mode "%s". Modes: sync [--dry-run] | install-cron | uninstall-cron\n' "$mode" >&2; exit 1 ;;
|
||||
esac
|
||||
[[ "${2:-}" = --dry-run ]] && dry_run=true
|
||||
|
||||
umask 077
|
||||
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
for command in docker curl flock; do command -v "$command" >/dev/null || fail "Required command: $command"; done
|
||||
docker compose version >/dev/null 2>&1 || fail "Docker Compose plugin required."
|
||||
exec 9>"$DIR/.deploy.lock"
|
||||
flock -w 30 9 || fail "Another installation, update or sync is running."
|
||||
[[ -f "$ENV_FILE" ]] || fail "Create .env first (bash cms install)."
|
||||
|
||||
env_get() {
|
||||
local key="$1" line
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
case "$line" in
|
||||
"$key="*) line="${line#*=}"; line="${line%\"}"; line="${line#\"}"; printf '%s' "$line"; return 0 ;;
|
||||
esac
|
||||
done < "$ENV_FILE"
|
||||
return 1
|
||||
}
|
||||
|
||||
compose_cmd() {
|
||||
docker compose --project-name "$PROJECT_NAME" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" --profile security "$@"
|
||||
}
|
||||
|
||||
container_running() {
|
||||
[[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" = true ]]
|
||||
}
|
||||
|
||||
cscli_exec() {
|
||||
compose_cmd exec -T crowdsec cscli "$@"
|
||||
}
|
||||
|
||||
fetch_sources() {
|
||||
local target="$1"
|
||||
local sources=( "${DEFAULT_SOURCES[@]}" )
|
||||
IFS=' ' read -r -a parsed <<< "${CROWDSEC_BLOCKLIST_SOURCES:-}"
|
||||
[[ "${#parsed[@]}" -gt 0 ]] && sources=( "${parsed[@]}" )
|
||||
local index=0 url
|
||||
for url in "${sources[@]}"; do
|
||||
[[ -n "$url" ]] || continue
|
||||
index=$((index + 1))
|
||||
if ! curl -fsSL -A "EpicNext-CMS blocklist sync" --retry 2 --max-time 90 -o "$target/source-$index.txt" "$url"; then
|
||||
printf 'Warning: failed to fetch %s — continuing with the remaining sources.\n' "$url"
|
||||
else
|
||||
printf 'Fetched %s\n' "$url"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
install_cron() {
|
||||
mkdir -p "$DIR/logs"
|
||||
local cron_line="0 * * * * /usr/bin/env bash $DIR/scripts/blocklists-sync.sh >> $DIR/logs/blocklists-sync.log 2>&1"
|
||||
if crontab -l 2>/dev/null | grep -Fq "$DIR/scripts/blocklists-sync.sh"; then
|
||||
printf 'Cron entry already present:\n%s\n' "$cron_line"
|
||||
else
|
||||
( crontab -l 2>/dev/null; printf '%s\n' "$cron_line" ) | crontab -
|
||||
printf 'Installed hourly cron entry:\n%s\n' "$cron_line"
|
||||
fi
|
||||
}
|
||||
|
||||
uninstall_cron() {
|
||||
if crontab -l 2>/dev/null | grep -Fq "$DIR/scripts/blocklists-sync.sh"; then
|
||||
crontab -l 2>/dev/null | grep -Fv "$DIR/scripts/blocklists-sync.sh" | crontab -
|
||||
printf 'Removed cron entry matching %s.\n' "$DIR/scripts/blocklists-sync.sh"
|
||||
else
|
||||
printf 'No cron entry to remove.\n'
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "$mode" = install-cron ]]; then
|
||||
install_cron
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$mode" = uninstall-cron ]]; then
|
||||
uninstall_cron
|
||||
exit 0
|
||||
fi
|
||||
|
||||
duration="$(env_get CROWDSEC_BLOCKLIST_DURATION 2>/dev/null || true)"
|
||||
[[ -n "$duration" ]] || duration="$DEFAULT_DURATION"
|
||||
max_decisions="$(env_get CROWDSEC_BLOCKLIST_MAX_DECISIONS 2>/dev/null || true)"
|
||||
[[ -n "$max_decisions" ]] || max_decisions="$DEFAULT_MAX_DECISIONS"
|
||||
[[ "$max_decisions" =~ ^[0-9]+$ ]] || fail "CROWDSEC_BLOCKLIST_MAX_DECISIONS must be a number."
|
||||
allowlist="${CROWDSEC_BLOCKLIST_ALLOW:-$(env_get CROWDSEC_BLOCKLIST_ALLOW 2>/dev/null || true)}"
|
||||
|
||||
work="$(mktemp -d "$DIR/.blocklists.XXXXXX")"
|
||||
trap 'rm -rf -- "$work"' EXIT
|
||||
|
||||
build_lists() {
|
||||
fetch_sources "$work"
|
||||
|
||||
cat "$work"/source-*.txt 2>/dev/null | awk '{print $1}' \
|
||||
| grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$|^([0-9a-fA-F]{1,4}:){2,}[0-9a-fA-F:]*[0-9a-fA-F](/[0-9]{1,3})?$' \
|
||||
| awk '
|
||||
# Only globally routable attacker space may become a decision. Reserved,
|
||||
# private, loopback, link-local, CGNAT, test and multicast ranges never
|
||||
# represent an external attacker and must not be imported (they could
|
||||
# otherwise block the origin itself or internal traffic).
|
||||
function isReserved4(prefix, a, b, c) {
|
||||
if (a == 0 || a == 127 || a >= 224) return 1
|
||||
if (a == 10) return 1
|
||||
if (a == 100 && (prefix < 10 || (prefix >= 10 && b >= 64 && b <= 127))) return 1
|
||||
if (a == 169 && (prefix < 16 || (prefix >= 16 && b == 254))) return 1
|
||||
if (a == 172 && (prefix < 12 || (prefix >= 12 && b >= 16 && b <= 31))) return 1
|
||||
if (a == 192 && b == 168) return 1
|
||||
if (a == 192 && b == 0) return 1
|
||||
if ((a == 198 && (b == 18 || b == 19)) || (a == 198 && b == 51 && c == 100)) return 1
|
||||
if (a == 203 && b == 0 && c == 113) return 1
|
||||
return 0
|
||||
}
|
||||
function isReserved6(line, prefix, first, h) {
|
||||
if (prefix < 32) return 1
|
||||
if (line ~ /^::/) return 1
|
||||
first = tolower(line); sub(/^::?/, "", first); sub(/:.*/, "", first)
|
||||
h = "0x" substr(first, 1, 2)
|
||||
if (h >= 252) return 1 # ULA fc00::/7, link-local fe80::/10, multicast ff00::/8
|
||||
return 0
|
||||
}
|
||||
{
|
||||
if (index($0, "/") > 0) {
|
||||
n = split($0, seg, "/")
|
||||
if (n != 2 || seg[2] !~ /^[0-9]+$/) next
|
||||
if (index(seg[1], ":") > 0) {
|
||||
pref = seg[2] + 0
|
||||
if (pref < 32 || pref > 128) next
|
||||
if (isReserved6(seg[1], pref)) next
|
||||
print
|
||||
next
|
||||
}
|
||||
pref = seg[2] + 0
|
||||
if (pref < 8 || pref > 32) next
|
||||
split(seg[1], oct, ".")
|
||||
ok = 1
|
||||
for (i = 1; i <= 4; i++) {
|
||||
if (oct[i] !~ /^[0-9]+$/ || oct[i] + 0 > 255) { ok = 0; break }
|
||||
if (length(oct[i]) > 1 && oct[i] ~ /^0/) { ok = 0; break }
|
||||
}
|
||||
if (!ok) next
|
||||
if (isReserved4(pref, oct[1] + 0, oct[2] + 0, oct[3] + 0)) next
|
||||
print
|
||||
next
|
||||
}
|
||||
if (index($0, ":") > 0) {
|
||||
if (isReserved6($0, 128)) next
|
||||
print
|
||||
next
|
||||
}
|
||||
n = split($0, part, ".")
|
||||
if (n != 4) next
|
||||
ok = 1
|
||||
for (i = 1; i <= 4; i++) {
|
||||
if (part[i] !~ /^[0-9]+$/ || part[i] + 0 > 255) { ok = 0; break }
|
||||
if (length(part[i]) > 1 && part[i] ~ /^0/) { ok = 0; break }
|
||||
}
|
||||
if (!ok) next
|
||||
if (isReserved4(32, part[1] + 0, part[2] + 0, part[3] + 0)) next
|
||||
print
|
||||
}' \
|
||||
| sort -u > "$work/candidates.txt"
|
||||
|
||||
if [[ -n "$allowlist" ]]; then
|
||||
printf '%s\n' "$allowlist" | tr ',' '\n' | while IFS= read -r line; do printf '%s\n' "$line"; done | sort -u > "$work/allow.txt"
|
||||
comm -23 "$work/candidates.txt" "$work/allow.txt" > "$work/final.txt"
|
||||
else
|
||||
cp "$work/candidates.txt" "$work/final.txt"
|
||||
fi
|
||||
|
||||
if [[ "$(wc -l < "$work/final.txt" | tr -d ' ')" -gt "$max_decisions" ]]; then
|
||||
sort -u "$work/final.txt" | head -n "$max_decisions" > "$work/final.limited.txt" || true
|
||||
mv "$work/final.limited.txt" "$work/final.txt"
|
||||
printf 'Note: capped the combined list at %s decisions (CROWDSEC_BLOCKLIST_MAX_DECISIONS).\n' "$max_decisions"
|
||||
fi
|
||||
|
||||
count_total=$(wc -l < "$work/final.txt" | tr -d ' ')
|
||||
count_ip=$(grep -cv '/' "$work/final.txt" || true)
|
||||
count_range=$(grep -c '/' "$work/final.txt" || true)
|
||||
if [[ "$count_total" -lt 1 ]]; then
|
||||
fail "No valid addresses could be parsed from the configured sources. Configure CROWDSEC_BLOCKLIST_SOURCES."
|
||||
fi
|
||||
}
|
||||
|
||||
build_lists
|
||||
|
||||
printf 'Parsed %s targets (%s IPs, %s ranges).\n' "$count_total" "$count_ip" "$count_range"
|
||||
|
||||
if $dry_run; then
|
||||
printf 'Dry run: would replace the cscli-import decisions with these %s targets.\n' "$count_total"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
container_running || fail "The CrowdSec engine is not running. Start it first with: bash cms security"
|
||||
|
||||
printf 'Removing previous cscli-import decisions...\n'
|
||||
cscli_exec decisions delete --origin cscli-import >/dev/null 2>&1 || true
|
||||
|
||||
{
|
||||
printf 'duration,scope,value\n'
|
||||
awk -v d="$duration" '{ if (index($0, "/") > 0) printf "%s,range,%s\n", d, $0; else printf "%s,ip,%s\n", d, $0 }' "$work/final.txt"
|
||||
} > "$work/import.csv"
|
||||
|
||||
printf 'Importing %s decisions into the local LAPI (duration %s)...\n' "$count_total" "$duration"
|
||||
cscli_exec decisions import -i - --format csv --batch 1000 < "$work/import.csv"
|
||||
|
||||
printf 'Done. The app bouncer picks these up within a few seconds.\n'
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for the port-selection and port-conflict logic in scripts/ci-deploy.sh.
|
||||
#
|
||||
# Background: on a host where both blue/green slots answer /api/health, the
|
||||
# original read_active_port() counted healthy slots and only consulted the nginx
|
||||
# upstream when the count was not exactly 1. With two healthy slots it fell back
|
||||
# to the upstream file, but an operator `docker compose up` can leave an extra
|
||||
# replica behind, after which the fallback picked slot A regardless of which slot
|
||||
# was really live. The candidate then tried to start on an occupied port, and the
|
||||
# health probe answered from the pre-existing container on that port instead of
|
||||
# the candidate — producing 30 failed "expected release never became healthy"
|
||||
# attempts against a release that was never serving.
|
||||
#
|
||||
# The functions are extracted from ci-deploy.sh rather than copied so this test
|
||||
# cannot drift from the script it protects.
|
||||
set -Eeuo pipefail
|
||||
|
||||
deploy_script="$(dirname "$0")/ci-deploy.sh"
|
||||
[[ -r "$deploy_script" ]] || { echo "cannot read $deploy_script" >&2; exit 1; }
|
||||
|
||||
# Pull the two functions out of the real script.
|
||||
extract() {
|
||||
sed -n "/^$1() {/,/^}/p" "$deploy_script"
|
||||
}
|
||||
|
||||
read_active_port_fn="$(extract read_active_port)"
|
||||
assert_port_free_fn="$(extract assert_port_free)"
|
||||
answers_health_fn="$(extract answers_health)"
|
||||
|
||||
if [ -z "$read_active_port_fn" ] || [ -z "$assert_port_free_fn" ] || [ -z "$answers_health_fn" ]; then
|
||||
echo "could not extract functions from $deploy_script" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
slot_a_port=3002
|
||||
slot_b_port=3003
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
# ── read_active_port ──────────────────────────────────────────────────────────
|
||||
# $1 = upstream body ("none" for a missing file), $2..$3 = ports that answer.
|
||||
run_read_active_port() {
|
||||
local body="$1" a="$2" b="$3" tmp
|
||||
tmp="$(mktemp)"
|
||||
if [ "$body" = "none" ]; then
|
||||
tmp=/tmp/ci-deploy-test-nonexistent-upstream-$$
|
||||
rm -f "$tmp"
|
||||
else
|
||||
printf '%s\n' "$body" >"$tmp"
|
||||
fi
|
||||
CMS_UPSTREAM_FILE="$tmp" \
|
||||
PORT_A_HEALTHY="$a" PORT_B_HEALTHY="$b" \
|
||||
bash -c "
|
||||
slot_a_port=$slot_a_port
|
||||
slot_b_port=$slot_b_port
|
||||
upstream_file=\"\$CMS_UPSTREAM_FILE\"
|
||||
$read_active_port_fn
|
||||
# Defined after the extracted function on purpose: answers_health is a
|
||||
# collaborator here, and the test substitutes a deterministic stub for it.
|
||||
answers_health() {
|
||||
local p=\$1 want
|
||||
case \$p in
|
||||
$slot_a_port) want=\"\$PORT_A_HEALTHY\" ;;
|
||||
$slot_b_port) want=\"\$PORT_B_HEALTHY\" ;;
|
||||
*) want='' ;;
|
||||
esac
|
||||
[ \"\$want\" = yes ]
|
||||
}
|
||||
read_active_port
|
||||
echo
|
||||
" 2>/dev/null
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
# nginx points at slot B and both answer -> trust the upstream file.
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' yes yes)"
|
||||
[ "$got" = "$slot_b_port" ] || fail "nginx->3003 with both healthy: got '$got', want 3003"
|
||||
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3002 max_fails=2;' yes yes)"
|
||||
[ "$got" = "$slot_a_port" ] || fail "nginx->3002 with both healthy: got '$got', want 3002"
|
||||
|
||||
# The regression: both healthy, nginx points at B, but slot A is an unrelated
|
||||
# leftover replica. The upstream file is the only thing that knows which slot is
|
||||
# live, so it must win.
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' yes yes)"
|
||||
[ "$got" != "$slot_a_port" ] || fail "both healthy: fell back to slot A while nginx serves 3003"
|
||||
|
||||
# Upstream names a dead slot: fall back to a slot that actually answers, never to
|
||||
# the dead port itself.
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3002 max_fails=2;' no yes)"
|
||||
[ "$got" = "$slot_b_port" ] || fail "nginx->3002 unhealthy, B healthy: got '$got', want 3003"
|
||||
|
||||
# Nothing answers at all: read_active_port still has to name a slot, otherwise the
|
||||
# rollback path has no target.
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' no no)"
|
||||
[ "$got" = "$slot_b_port" ] || fail "nothing healthy: got '$got', want the upstream port 3003"
|
||||
|
||||
# No upstream file at all: pick a slot that answers.
|
||||
got="$(run_read_active_port none no yes)"
|
||||
[ "$got" = "$slot_b_port" ] || fail "no upstream, B healthy: got '$got', want 3003"
|
||||
|
||||
got="$(run_read_active_port none yes no)"
|
||||
[ "$got" = "$slot_a_port" ] || fail "no upstream, A healthy: got '$got', want 3002"
|
||||
|
||||
# ── assert_port_free ─────────────────────────────────────────────────────────
|
||||
# Runs against real loopback ports: 3999 is intentionally unused, so the check
|
||||
# must report it free.
|
||||
bash -c "
|
||||
$assert_port_free_fn
|
||||
assert_port_free 3999 candidate >/dev/null 2>&1
|
||||
" || fail "a port with no listener must be reported as free"
|
||||
|
||||
# On this host 3002 is held by a CMS container, so the check must fail. Skip when
|
||||
# it genuinely is free, otherwise the assertion would be meaningless.
|
||||
if ss -ltn 2>/dev/null | grep -qE '127\.0\.0\.1:3002|0\.0\.0\.0:3002'; then
|
||||
if bash -c "
|
||||
$assert_port_free_fn
|
||||
assert_port_free 3002 candidate >/dev/null 2>&1
|
||||
"; then
|
||||
fail "an occupied port must be rejected, but assert_port_free returned success"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo 'Deploy port-selection tests passed'
|
||||
+141
-26
@@ -76,6 +76,13 @@ healthy() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Zelfde check als `healthy`, maar zonder retries. Voor het bepalen van de
|
||||
# actieve poort willen we geen 90 seconden per slot wachten: daar gaat het om
|
||||
# een al draaiend proces dat nu of nooit antwoordt.
|
||||
answers_health() {
|
||||
curl -sf --max-time 5 "http://127.0.0.1:$1/api/health" | grep -q '"database":true'
|
||||
}
|
||||
|
||||
# Staat er een blue/green-upstream? Zonder die bestanden blijft dit script op de
|
||||
# oude, in-place cutover vallen, zodat een host met een andere nginx-indeling
|
||||
# niet stilvalt op een upgrade.
|
||||
@@ -85,29 +92,123 @@ detect_blue_green() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Welke poort is op dit moment ÉCHT live? Kijk niet naar het upstream-bestand
|
||||
# (dat kan door een losse `docker compose up` zijn ingehaald en naar een dood
|
||||
# slot wijzen), maar test welk slot werkelijk antwoordt op /api/health. Alleen
|
||||
# in een dubbelzinnige situatie (geen óf beide slots gezond) valt het script
|
||||
# terug op de huidige nginx-pointer; onbekend = slot A (eerste release op 3002).
|
||||
# Welke poort is op dit moment ÉCHT live?
|
||||
#
|
||||
# Volgorde van vertrouwen:
|
||||
# 1. Het nginx-upstream-bestand. Dat is de enige bron die aangeeft wáár het
|
||||
# publieke verkeer daadwerkelijk binnenkomt; alles daaronder is gevolg.
|
||||
# 2. Een gezond slot dat overeenkomt met die aanwijzing.
|
||||
# 3. Precies één gezond slot (een verse host met geen upstream-bestand).
|
||||
#
|
||||
# De eerdere versie telde gezonde slots en gebruikte de fallback pas als er 0 of
|
||||
# 2+ waren. Op een host waar beide slots tegelijk gezond zijn — bijvoorbeeld
|
||||
# doordat een losse `docker compose up` een extra replica heeft achtergelaten —
|
||||
# gaf dat een willekeurige keuze, en dan kon de kandidaat op een bezette poort
|
||||
# starten (EADDRINUSE) terwijl de health-check de reeds draaiende container op
|
||||
# die poort beantwoordde. De release-vergelijking faalde dan 30 keer op een
|
||||
# container die toevallig een andere release draaide.
|
||||
read_active_port() {
|
||||
local live="" port="" result=""
|
||||
local count=0
|
||||
for port in "$slot_a_port" "$slot_b_port"; do
|
||||
if curl -sf --max-time 3 "http://127.0.0.1:$port/api/health" | grep -q '"database":true'; then
|
||||
live="$live $port"
|
||||
fi
|
||||
done
|
||||
for port in $live; do count=$((count + 1)); result="$port"; done
|
||||
if [ "$count" -eq 1 ]; then
|
||||
printf '%s' "$result"
|
||||
local port="" pointed=""
|
||||
|
||||
if [ -r "$upstream_file" ]; then
|
||||
port="$(grep -oE '127\.0\.0\.1:(3002|3003)' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)"
|
||||
fi
|
||||
|
||||
if [ -n "$port" ] && answers_health "$port"; then
|
||||
printf '%s' "$port"
|
||||
return 0
|
||||
fi
|
||||
port="$(grep -oE '127\.0\.0\.1:[0-9]+' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)"
|
||||
case "$port" in
|
||||
"$slot_b_port") printf '%s' "$slot_b_port" ;;
|
||||
*) printf '%s' "$slot_a_port" ;;
|
||||
|
||||
# Het upstream-bestand wijst naar een slot dat niet antwoordt. Kies dan het
|
||||
# enige andere gezonde slot, anders is er niets om op te bouwen.
|
||||
for candidate in "$slot_a_port" "$slot_b_port"; do
|
||||
[ "$candidate" = "$port" ] && continue
|
||||
if answers_health "$candidate"; then
|
||||
echo "nginx points at ${port:-unknown}, which is unhealthy; ${candidate} answers instead" >&2
|
||||
printf '%s' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# Geen enkel slot antwoordt. Vertrouw dan op het bestand, zodat een
|
||||
# rollback-poging toch het vorige slot kan starten.
|
||||
if [ -n "$port" ]; then
|
||||
printf '%s' "$port"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%s' "$slot_a_port"
|
||||
}
|
||||
|
||||
# Poort-bezetting controleren vóór het starten van de kandidaat.
|
||||
#
|
||||
# Zonder deze check zorgt `docker run` er stilzwijgend voor dat de kandidaat
|
||||
# dood gaat op EADDRINUSE, terwijl de health-check ondertussen de reeds draaiende
|
||||
# container op diezelfde poort beantwoordt. Dat levert een misleidende
|
||||
# "expected release never became healthy" op in plaats van de echte oorzaak.
|
||||
# Elke listener wordt hierboven concreet genoemd, inclusief de container die
|
||||
# hem vasthoudt.
|
||||
assert_port_free() {
|
||||
local port="$1" name="$2"
|
||||
local holders=""
|
||||
# `type`, niet `command -v`: de deploy-simulatietests leveren `ss` als
|
||||
# shell-functie via BASH_ENV, en `command -v` herkent die wel op Bash maar de
|
||||
# functie is niet geëxporteerd naar de subshell van start_candidate. Met `type`
|
||||
# blijft de stub ook daar zichtbaar, zodat de test geen echte hostpoorten
|
||||
# hoeft te zien.
|
||||
if type ss >/dev/null 2>&1; then
|
||||
# `ss` drukt altijd een kolomkop af, ook als er geen listener is. Filter op
|
||||
# LISTEN, anders zou elke vrije poort als bezet gemeld worden.
|
||||
holders="$(ss -ltnp "sport = :$port" 2>/dev/null | grep -F 'LISTEN' || true)"
|
||||
fi
|
||||
[ -z "$holders" ] && return 0
|
||||
echo "Port $port is already in use, cannot start candidate $name" >&2
|
||||
printf '%s\n' "$holders" >&2
|
||||
|
||||
# Noem exact het container dat de poort vasthoudt.
|
||||
#
|
||||
# `docker ps --filter publish=` werkt niet: de app draait met --net=host en
|
||||
# publiceert dus geen poorten, dus die filter levert altijd niets op. In plaats
|
||||
# daarvan volgen we de luisterende PID uit `ss` terug naar de container via
|
||||
# /proc/<pid>/cgroup. Een eerdere versie noemde álle draaiende containers als
|
||||
# belkenners, wat de echte boosdochter (epicnext-cms) onder een zee van
|
||||
# onschuldige containers begraven.
|
||||
local squatter="squatter_pids"
|
||||
squatter_pids="$(printf '%s\n' "$holders" | grep -oP 'pid=\K[0-9]+' | sort -u || true)"
|
||||
if [ -n "$squatter_pids" ]; then
|
||||
local pid cid owner=""
|
||||
for pid in $squatter_pids; do
|
||||
cid="$(sed -n 's#.*docker-\([0-9a-f]\{64\}\)\.scope#\1#p' "/proc/$pid/cgroup" 2>/dev/null | head -1)"
|
||||
[ -n "$cid" ] || continue
|
||||
owner="$(docker inspect --format '{{.Name}} ({{.Config.Image}})' "$cid" 2>/dev/null || true)"
|
||||
[ -n "$owner" ] && printf 'Held by container: %s\n' "${owner#/}" >&2
|
||||
done
|
||||
fi
|
||||
echo "" >&2
|
||||
# Blauwe/groene releases beheren hun eigen slots. Een container met een andere
|
||||
# naam die toevallig op een van deze poorten draait — meestal een
|
||||
# `docker compose up`-replica — staat los van de pipeline en blokkeert de
|
||||
# release. Live verkeer loopt via het nginx-upstream over het andere slot en is
|
||||
# dus niet geraakt.
|
||||
case "$owner" in
|
||||
*"/$name"*|*"/$slot_b_container"*)
|
||||
echo "Note: the holder looks like a managed slot container; re-check the port mapping above." >&2 ;;
|
||||
*)
|
||||
cat >&2 <<EOF
|
||||
This port is held by a container that is not a blue/green slot, so the deploy
|
||||
cannot start the candidate. Live traffic is unaffected: nginx keeps serving
|
||||
the other slot until cutover.
|
||||
|
||||
Remove the stray container and re-run the deploy:
|
||||
|
||||
docker rm -f $(printf '%s' "$owner" | sed -n 's#.*/\([^ ]*\).*#\1#p')
|
||||
|
||||
If it comes back after a reboot, it is started by docker-compose.yml rather
|
||||
than by this script; delete or disable that service.
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Zet de nginx-upstream op de nieuwe poort en herlaadt graceful.
|
||||
@@ -153,6 +254,7 @@ switch_upstream() {
|
||||
# gelden.
|
||||
start_candidate() {
|
||||
local port="$1" name="$2"
|
||||
assert_port_free "$port" "$name"
|
||||
(
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
@@ -183,7 +285,7 @@ finish() {
|
||||
if [ "$cutover_started" -eq 0 ]; then
|
||||
# De live release draait nog ongestoord; alleen de kandidaat opruimen.
|
||||
echo "Deployment failed before cutover; the live release was never stopped" >&2
|
||||
if [ "$candidate_attempted" -eq 1 ] && [ -n "$new_container" ]; then
|
||||
if [ "$candidate_attempted" -eq 1 ] && [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then
|
||||
docker logs "$new_container" --tail 50 >&2 || true
|
||||
docker rm -f "$new_container" || true
|
||||
fi
|
||||
@@ -191,9 +293,9 @@ finish() {
|
||||
# nginx wijst nu naar de kandidaat. Eerst het verkeer terug, dan pas de
|
||||
# kandidaat weghalen, anders zou de site 502-en terwijl we terugdraaien.
|
||||
echo "Deployment failed after cutover; rolling back to port $old_port" >&2
|
||||
if [ -n "$new_container" ]; then docker logs "$new_container" --tail 50 >&2 || true; fi
|
||||
if [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then docker logs "$new_container" --tail 50 >&2 || true; fi
|
||||
if [ -n "$old_port" ]; then switch_upstream "$old_port" || true; fi
|
||||
if [ -n "$new_container" ]; then docker rm -f "$new_container" || true; fi
|
||||
if [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then docker rm -f "$new_container" || true; fi
|
||||
if [ -n "$old_container" ] && docker start "$old_container" >/dev/null 2>&1; then
|
||||
if healthy "$old_port"; then
|
||||
echo "Rollback verified on port $old_port"
|
||||
@@ -338,15 +440,28 @@ if docker inspect "$backup_name" >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The avatar/badge disk cache lives on the host bind and is written by uid 33
|
||||
# inside the container. Root-owned directories make every cache write fail
|
||||
# silently, which turns each avatar into a fresh live render.
|
||||
# The application writes everything under storage/ as uid 33, but storage is a
|
||||
# host bind so the image's own ownership is irrelevant. Any path that is not
|
||||
# uid 33 makes the write fail with EACCES, and because most of these writes are
|
||||
# inside a try/catch the failure is silent: the avatar cache just never fills
|
||||
# (each avatar becomes a fresh live render) and the catalog export reports
|
||||
# "delivery failed" while the emulator never receives the update. The old code
|
||||
# only repaired storage/imaging, so storage/catalog-git/hotel-status.json kept
|
||||
# coming back root:root and /api/admin/catalog/status kept throwing EACCES.
|
||||
for owned_dir in imaging catalog-git cms-errors furniture-imports logs media \
|
||||
nitro-cleanup config-backups nitro-scale32-backups; do
|
||||
target="$deploy_dir/storage/$owned_dir"
|
||||
[ -e "$target" ] || mkdir -p "$target" 2>/dev/null || true
|
||||
[ -d "$target" ] || continue
|
||||
chown -R 33:33 "$target" 2>/dev/null || true
|
||||
done
|
||||
# The avatar/badge cache needs its leaf directories to exist before first use;
|
||||
# the cache misses (and re-renders live) rather than erroring when they do not.
|
||||
for cache_dir in avatars badges; do
|
||||
if ! install -d -o 33 -g 33 -m 0750 "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null; then
|
||||
mkdir -p "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
chown -R 33:33 "$deploy_dir/storage/imaging" 2>/dev/null || true
|
||||
|
||||
if [ "$blue_green" -eq 1 ]; then
|
||||
# 1. Maak de doel-poort vrij. Alles wat daar draait is per definitie niet live,
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$DIR"
|
||||
ENV_FILE="$DIR/.env"
|
||||
COMPOSE_FILE="deployment/crowdsec/compose.crowdsec.yml"
|
||||
PROJECT_NAME="epicnext-crowdsec"
|
||||
CONTAINER_NAME="epicnext-crowdsec"
|
||||
DEFAULT_PORT="18080"
|
||||
|
||||
mode="${1:-enable}"
|
||||
case "$mode" in
|
||||
enable|--enable) ;;
|
||||
status|--status) ;;
|
||||
disable|--disable) ;;
|
||||
blocklists|blocklists-install-cron|blocklists-uninstall-cron) ;;
|
||||
*) echo "Usage: bash cms security [enable|status|disable|blocklists|blocklists-install-cron|blocklists-uninstall-cron]" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
umask 077
|
||||
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
for command in docker flock; do command -v "$command" >/dev/null || fail "Required command: $command"; done
|
||||
docker info >/dev/null 2>&1 || fail "Docker is not reachable."
|
||||
docker compose version >/dev/null 2>&1 || fail "Docker Compose plugin required."
|
||||
exec 9>"$DIR/.deploy.lock"
|
||||
flock -w 30 9 || fail "Another installation or update is running."
|
||||
[[ -f "$ENV_FILE" ]] || fail "Create .env first (bash cms install)."
|
||||
|
||||
case "$mode" in
|
||||
blocklists) exec bash "$DIR/scripts/blocklists-sync.sh" sync "${2:-}" ;;
|
||||
blocklists-install-cron) exec bash "$DIR/scripts/blocklists-sync.sh" install-cron ;;
|
||||
blocklists-uninstall-cron) exec bash "$DIR/scripts/blocklists-sync.sh" uninstall-cron ;;
|
||||
esac
|
||||
|
||||
env_get() {
|
||||
local key="$1" line
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
case "$line" in
|
||||
"$key="*) line="${line#*=}"; line="${line%\"}"; line="${line#\"}"; printf '%s' "$line"; return 0 ;;
|
||||
esac
|
||||
done < "$ENV_FILE"
|
||||
return 1
|
||||
}
|
||||
|
||||
env_set() {
|
||||
local key="$1" value="$2" tmp
|
||||
tmp="$(mktemp "$DIR/.env.crowdsec.XXXXXX")"
|
||||
if awk -v k="$key" -v v="$value" 'BEGIN{FS=OFS="=";done=0} { if ($1==k) { print k "=" v; done=1 } else print } END { if (!done) print k "=" v }' "$ENV_FILE" > "$tmp"; then
|
||||
chmod 600 "$tmp"
|
||||
mv -f -- "$tmp" "$ENV_FILE"
|
||||
else
|
||||
rm -f -- "$tmp"
|
||||
fail "Could not update .env"
|
||||
fi
|
||||
}
|
||||
|
||||
compose_cmd() {
|
||||
docker compose --project-name "$PROJECT_NAME" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" --profile security "$@"
|
||||
}
|
||||
|
||||
health_probe() {
|
||||
local url="$1"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsS --max-time 3 "$url" >/dev/null 2>&1
|
||||
else
|
||||
compose_cmd exec -T crowdsec wget -q -O - "$url" >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
container_running() {
|
||||
[[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" = true ]]
|
||||
}
|
||||
|
||||
if [[ "$mode" = disable || "$mode" = --disable ]]; then
|
||||
set +e
|
||||
compose_cmd stop crowdsec
|
||||
rc=$?
|
||||
set -e
|
||||
[[ $rc -eq 0 ]] || printf 'CrowdSec engine was not running or could not be stopped.\n'
|
||||
env_set CROWDSEC_LOCAL_ENABLED false
|
||||
printf 'CrowdSec local stack disabled. The engine container is stopped; volumes and .env key were kept.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$mode" = status || "$mode" = --status ]]; then
|
||||
enabled=no
|
||||
[[ "$(env_get CROWDSEC_LOCAL_ENABLED 2>/dev/null || true)" = true ]] && enabled=yes
|
||||
port="$(env_get CROWDSEC_LAPI_PORT 2>/dev/null || true)"
|
||||
[[ -z "$port" ]] && port="$DEFAULT_PORT"
|
||||
printf 'CROWDSEC_LOCAL_ENABLED=%s\n' "$enabled"
|
||||
if container_running; then
|
||||
printf 'Engine: running\n'
|
||||
if health_probe "http://127.0.0.1:$port/health"; then
|
||||
printf 'LAPI health: OK (127.0.0.1:%s)\n' "$port"
|
||||
else
|
||||
printf 'LAPI health: UNREACHABLE (127.0.0.1:%s)\n' "$port"
|
||||
fi
|
||||
else
|
||||
printf 'Engine: not running\n'
|
||||
printf 'Start with: bash cms security\n'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
port="$(env_get CROWDSEC_LAPI_PORT 2>/dev/null || true)"
|
||||
[[ -n "$port" ]] || port="$DEFAULT_PORT"
|
||||
[[ "$port" =~ ^[0-9]{1,5}$ ]] || fail "CROWDSEC_LAPI_PORT must be a port number."
|
||||
if (( port < 1024 || port > 65535 )); then
|
||||
fail "CROWDSEC_LAPI_PORT must be within 1024-65535."
|
||||
fi
|
||||
key="$(env_get CROWDSEC_LAPI_API_KEY 2>/dev/null || true)"
|
||||
[[ -n "$key" ]] || key="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')"
|
||||
url="$(env_get CROWDSEC_LAPI_URL 2>/dev/null || true)"
|
||||
[[ -n "$url" ]] || url="http://127.0.0.1:$port"
|
||||
log_dir="${CROWDSEC_NGINX_LOG_DIR:-$(env_get CROWDSEC_NGINX_LOG_DIR 2>/dev/null || true)}"
|
||||
[[ -n "$log_dir" ]] || log_dir="/var/log/nginx"
|
||||
|
||||
if ! container_running && command -v ss >/dev/null 2>&1; then
|
||||
if ss -ltn "( sport = :$port )" 2>/dev/null | grep -q LISTEN; then
|
||||
fail "Port $port is already in use. Set CROWDSEC_LAPI_PORT (and CROWDSEC_LAPI_URL) in .env to a free port and re-run."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -r "$log_dir/access.log" ]]; then
|
||||
printf 'Warning: %s/access.log is not readable. The engine will run but has no detections until an access log is available.\n' "$log_dir"
|
||||
fi
|
||||
|
||||
env_set CROWDSEC_LOCAL_ENABLED true
|
||||
env_set CROWDSEC_LAPI_URL "$url"
|
||||
env_set CROWDSEC_LAPI_PORT "$port"
|
||||
env_set CROWDSEC_LAPI_API_KEY "$key"
|
||||
env_set CROWDSEC_NGINX_LOG_DIR "$log_dir"
|
||||
|
||||
compose_cmd config --quiet || fail "CrowdSec Compose configuration is invalid; fix CROWDSEC_* settings in .env."
|
||||
set +e
|
||||
compose_cmd up -d --wait crowdsec
|
||||
rc=$?
|
||||
set -e
|
||||
if [[ $rc -ne 0 ]]; then
|
||||
compose_cmd up -d crowdsec
|
||||
fi
|
||||
|
||||
attempt=0
|
||||
while ! health_probe "http://127.0.0.1:$port/health"; do
|
||||
attempt=$((attempt + 1))
|
||||
[[ $attempt -lt 30 ]] || fail "CrowdSec LAPI did not become healthy on port $port."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
printf 'CrowdSec engine running in LAPI-only mode on 127.0.0.1:%s (container %s).\n' "$port" "$CONTAINER_NAME"
|
||||
compose_cmd exec -T crowdsec cscli bouncers list >/dev/null 2>&1 \
|
||||
&& printf 'Bouncer "cms" was registered against the local LAPI.\n' \
|
||||
|| printf 'Warning: could not list bouncers. Diagnose with: docker compose exec -T %s cscli bouncers list\n' "$CONTAINER_NAME"
|
||||
printf 'Restart the CMS container (or run your next deployment) so it loads the new bouncer env. For a clone: bash cms update --skip-pull\n'
|
||||
+94
-2
@@ -3,15 +3,21 @@
|
||||
#
|
||||
# Modes:
|
||||
# (default) — post-deploy cleanup (safe, fast):
|
||||
# - Build cache older than 72h, capped at 4 GB max used space.
|
||||
# - Build cache capped at 4 GB max used space (CMS_BUILD_CACHE_MAX), evicting
|
||||
# least-recently-used entries. This cap is the actual bound.
|
||||
# - Unreferenced images older than 7 days (keeps rollback images around).
|
||||
# - Stopped containers older than 24h.
|
||||
# - Dangling images, which are always unreferenced.
|
||||
# - Orphaned Firefox profiles in byparr's writable layer (BYPARR_CONTAINERS).
|
||||
# --force — emergency mode ("never let the disk max out"): drops everything
|
||||
# with no age windows:
|
||||
# - ALL unreferenced build cache,
|
||||
# - ALL unreferenced images (no age grace),
|
||||
# - ALL stopped containers.
|
||||
#
|
||||
# The default mode escalates to --force on its own when / drops below 8 GB free,
|
||||
# so the bound holds even if this stops running on schedule.
|
||||
#
|
||||
# Volumes are NEVER pruned in either mode: mariadb-turbo-data is a database.
|
||||
# Idempotent; exits 0 when Docker is unavailable.
|
||||
set -Eeuo pipefail
|
||||
@@ -34,15 +40,101 @@ command -v docker >/dev/null 2>&1 || {
|
||||
printf '\n[%s] === docker prune start%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
|
||||
docker system df >>"$LOG_FILE" 2>&1 || true
|
||||
|
||||
# A hard ceiling on the root filesystem is what actually bounds the growth, so
|
||||
# the emergency path is reached on disk pressure rather than only on a timer.
|
||||
# The image/container passes stay age-gated: a rollback image and a stopped
|
||||
# container are cheap to keep for a week and expensive to lose.
|
||||
FREE_KB=$(df -Pk / | awk 'NR==2 {print $4}')
|
||||
# 8 GB free is comfortable for a database plus a release swap.
|
||||
if (( FREE_KB < 8 * 1024 * 1024 )); then
|
||||
FORCE=1
|
||||
printf '[%s] only %s KB free on /; switching to FORCE prune\n' \
|
||||
"$(now)" "$FREE_KB" >>"$LOG_FILE"
|
||||
fi
|
||||
|
||||
if (( FORCE )); then
|
||||
docker builder prune -af >>"$LOG_FILE" 2>&1 || true
|
||||
docker image prune -af >>"$LOG_FILE" 2>&1 || true
|
||||
docker container prune -f >>"$LOG_FILE" 2>&1 || true
|
||||
else
|
||||
docker builder prune -af --filter "until=72h" --max-used-space=4g >>"$LOG_FILE" 2>&1 || true
|
||||
# --max-used-space and --filter are mutually exclusive in buildx: passing
|
||||
# both makes the cap a no-op and the cache grows without bound. The cap alone
|
||||
# is the bound, and it evicts least-recently-used entries to get there.
|
||||
docker builder prune -af --max-used-space="${CMS_BUILD_CACHE_MAX:-4g}" >>"$LOG_FILE" 2>&1 || true
|
||||
docker image prune -af --filter "until=168h" >>"$LOG_FILE" 2>&1 || true
|
||||
docker container prune -f --filter "until=24h" >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
# Dangling images have no tag and no container, so nothing can reference them.
|
||||
# They are what repeated local builds leave behind.
|
||||
docker image prune -f >>"$LOG_FILE" 2>&1 || true
|
||||
|
||||
# ── Interrupted git gc leftovers ─────────────────────────────────
|
||||
# A `git gc` that gets OOM-killed mid-repack leaves its tmp_pack behind, and
|
||||
# nothing reclaims it: git only clears those on the next successful gc. One such
|
||||
# file held 7.7 GB here while the whole object store was 83 MB. Only files older
|
||||
# than a day are considered, so a gc running right now is never touched.
|
||||
repo_dir="${CMS_REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
if [[ -d "$repo_dir/.git/objects/pack" ]]; then
|
||||
while IFS= read -r -d '' tmp; do
|
||||
size=$(du -h "$tmp" | cut -f1)
|
||||
rm -f "$tmp"
|
||||
printf '[%s] removed leftover tmp_pack %s (%s) from an interrupted git gc\n' \
|
||||
"$(now)" "$tmp" "$size" >>"$LOG_FILE"
|
||||
done < <(find "$repo_dir/.git/objects/pack" -maxdepth 1 -name 'tmp_*' -mmin +1440 -print0 2>/dev/null)
|
||||
fi
|
||||
|
||||
# ── Orphaned browser profiles ─────────────────────────────────────
|
||||
# byparr launches a real Firefox per request, and each launch leaves a
|
||||
# ~10-140 MB profile behind in the container's writable layer. Nothing ever
|
||||
# removes them, so the layer grows without bound: 716 profiles / 6.8 GB after two
|
||||
# days on this host, ~1.7 GB/day.
|
||||
#
|
||||
# Deleting a profile out from under a running browser kills that job, so live
|
||||
# ones are identified the only way that is reliable rather than by age: a
|
||||
# browser keeps its profile open, which shows up as a /proc/<pid>/fd symlink
|
||||
# pointing into the directory. Anything not referenced that way, and untouched
|
||||
# for BYPARR_PROFILE_MIN_AGE_MIN minutes, is an orphan.
|
||||
#
|
||||
# Age alone is not a safe signal here: browsers stay warm for ~27 hours, so an
|
||||
# age window that is safe for the leak is far too wide for the disk.
|
||||
BYPARR_TMP_MIN_AGE_MIN="${BYPARR_TMP_MIN_AGE_MIN:-30}"
|
||||
for container in ${BYPARR_CONTAINERS:-byparr}; do
|
||||
docker inspect -f '{{.State.Running}}' "$container" >/dev/null 2>&1 || continue
|
||||
[[ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" == "true" ]] || continue
|
||||
|
||||
removed=$(
|
||||
docker exec -e BYPARR_TMP_MIN_AGE_MIN="$BYPARR_TMP_MIN_AGE_MIN" "$container" sh -c '
|
||||
set -u
|
||||
min_age="${BYPARR_TMP_MIN_AGE_MIN:-30}"
|
||||
base="${1:-/tmp}"
|
||||
live_file=$(mktemp)
|
||||
# Live profiles are the ones a running process still holds open.
|
||||
for p in $(ps -eo pid= 2>/dev/null); do
|
||||
ls -l "/proc/$p/fd" 2>/dev/null
|
||||
done | grep -o "$base/playwright_firefoxdev_profile-[A-Za-z0-9]*" | sort -u >"$live_file"
|
||||
|
||||
count=0
|
||||
for dir in "$base"/playwright_firefoxdev_profile-*; do
|
||||
[ -d "$dir" ] || continue
|
||||
# Never touch something a process is still using.
|
||||
grep -Fxq "$dir" "$live_file" && continue
|
||||
# A profile a browser is still writing to is not an orphan
|
||||
# yet, even if the directory itself looks old.
|
||||
if find "$dir" -newermt "-${min_age} minutes" -print -quit 2>/dev/null | grep -q .; then
|
||||
continue
|
||||
fi
|
||||
rm -rf "$dir" 2>/dev/null && count=$((count + 1))
|
||||
done
|
||||
rm -f "$live_file"
|
||||
printf "%s" "$count"
|
||||
' sh /tmp 2>/dev/null || printf '0'
|
||||
)
|
||||
if [[ "${removed:-0}" -gt 0 ]]; then
|
||||
printf '[%s] removed %s orphaned browser profiles from %s\n' \
|
||||
"$(now)" "$removed" "$container" >>"$LOG_FILE"
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n[%s] === docker prune complete%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
|
||||
docker system df >>"$LOG_FILE" 2>&1 || true
|
||||
@@ -1,7 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
detectMemoryLimitMb,
|
||||
heapLimitMb,
|
||||
runtimeNodeOptions,
|
||||
} from "./docker-start.mjs";
|
||||
|
||||
it("imports runtime validation without starting the CMS", () => {
|
||||
const result = spawnSync(
|
||||
@@ -16,28 +22,88 @@ it("imports runtime validation without starting the CMS", () => {
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
it("rejects the local CrowdSec bouncer without a key", () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
"import {validateRuntime} from './scripts/docker-start.mjs';try{validateRuntime({HOTEL_NAME:'x',AUTH_SECRET:'01234567890123456789012345678901',DATABASE_URL:'mysql://u:p@h/db',APP_URL:'http://h',CROWDSEC_LOCAL_ENABLED:'true'});process.exit(1)}catch(error){if(!String(error.message).includes('CROWDSEC_LAPI_API_KEY'))throw error}",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
describe("heap limit", () => {
|
||||
it("leaves headroom for the memory V8 does not account for", () => {
|
||||
// 4 GB cgroup limit -> a 2867 MB heap, well under the ceiling.
|
||||
expect(heapLimitMb(4 * 1024 ** 3)).toBe(2867);
|
||||
expect(heapLimitMb(6 * 1024 ** 3)).toBe(4300);
|
||||
});
|
||||
|
||||
it("clamps to a floor and a ceiling", () => {
|
||||
// Too small to run a Next.js server at all: floor wins.
|
||||
expect(heapLimitMb(256 * 1024 ** 2)).toBe(512);
|
||||
// A huge or absent limit must not turn into a 100 GB heap.
|
||||
expect(heapLimitMb(64 * 1024 ** 3)).toBe(8192);
|
||||
expect(heapLimitMb(Number.NaN)).toBe(8192);
|
||||
expect(heapLimitMb(0)).toBe(8192);
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a complete local CrowdSec configuration", () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
"import {validateRuntime} from './scripts/docker-start.mjs';validateRuntime({HOTEL_NAME:'x',AUTH_SECRET:'01234567890123456789012345678901',DATABASE_URL:'mysql://u:p@h/db',APP_URL:'http://h',CROWDSEC_LOCAL_ENABLED:'true',CROWDSEC_LAPI_API_KEY:'fixture-key',CROWDSEC_LAPI_URL:'http://127.0.0.1:18080'})",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
describe("cgroup detection", () => {
|
||||
const asReader = (contents) => (path) => {
|
||||
if (!(path in contents)) throw new Error(`ENOENT: ${path}`);
|
||||
return contents[path];
|
||||
};
|
||||
|
||||
it("reads the cgroup v2 limit", () => {
|
||||
expect(
|
||||
detectMemoryLimitMb(
|
||||
asReader({ "/sys/fs/cgroup/memory.max": "4294967296" }),
|
||||
),
|
||||
).toBe(2867);
|
||||
});
|
||||
|
||||
it("falls back to cgroup v1 when v2 is absent", () => {
|
||||
expect(
|
||||
detectMemoryLimitMb(
|
||||
asReader({
|
||||
"/sys/fs/cgroup/memory.max": "",
|
||||
"/sys/fs/cgroup/memory/memory.limit_in_bytes": "6442450944",
|
||||
}),
|
||||
),
|
||||
).toBe(4300);
|
||||
});
|
||||
|
||||
it("treats an unlimited cgroup as no limit at all", () => {
|
||||
// cgroup v1 reports "max"; a bare sentinel means the same thing.
|
||||
expect(
|
||||
detectMemoryLimitMb(asReader({ "/sys/fs/cgroup/memory.max": "max" })),
|
||||
).toBe(8192);
|
||||
expect(
|
||||
detectMemoryLimitMb(
|
||||
asReader({
|
||||
"/sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854771712",
|
||||
}),
|
||||
),
|
||||
).toBe(8192);
|
||||
});
|
||||
|
||||
it("falls back when neither cgroup file is readable", () => {
|
||||
expect(
|
||||
detectMemoryLimitMb(() => {
|
||||
throw new Error("ENOENT");
|
||||
}),
|
||||
).toBe(8192);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NODE_OPTIONS", () => {
|
||||
it("adds the cap when none is set", () => {
|
||||
expect(runtimeNodeOptions("", 2867)).toBe("--max-old-space-size=2867");
|
||||
expect(runtimeNodeOptions(undefined, 2867)).toBe(
|
||||
"--max-old-space-size=2867",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps unrelated options already present", () => {
|
||||
expect(runtimeNodeOptions("--no-warnings", 2867)).toBe(
|
||||
"--no-warnings --max-old-space-size=2867",
|
||||
);
|
||||
});
|
||||
|
||||
it("never overrides an explicit operator choice", () => {
|
||||
expect(runtimeNodeOptions("--max-old-space-size=8192", 2867)).toBe(
|
||||
"--max-old-space-size=8192",
|
||||
);
|
||||
});
|
||||
});
|
||||
+70
-17
@@ -1,7 +1,70 @@
|
||||
// Fail before listening if an installation has no valid runtime configuration.
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/** Fraction of the container memory limit V8 is allowed to use for its heap.
|
||||
* The rest has to cover native allocations the JS heap cannot account for:
|
||||
* the mysql2 pool buffers, sharp's image pipeline, and zlib during a burst of
|
||||
* RSC rendering. */
|
||||
const HEAP_FRACTION = 0.7;
|
||||
const MIN_HEAP_MB = 512;
|
||||
/** Backstop only. The fraction is the real policy: on a 6 GB container it asks
|
||||
* for 4300 MB, and a backstop at or below that would silently turn the fraction
|
||||
* into a fixed number and make the two limits disagree. This exists purely so a
|
||||
* nonsensical cgroup reading cannot ask for an unbounded heap. */
|
||||
const MAX_HEAP_MB = 8192;
|
||||
|
||||
export function heapLimitMb(cgroupLimitBytes) {
|
||||
if (!Number.isFinite(cgroupLimitBytes) || cgroupLimitBytes <= 0)
|
||||
return MAX_HEAP_MB;
|
||||
const mb = Math.floor((cgroupLimitBytes * HEAP_FRACTION) / (1024 * 1024));
|
||||
return Math.min(MAX_HEAP_MB, Math.max(MIN_HEAP_MB, mb));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read this container's memory ceiling from cgroup v2, falling back to v1.
|
||||
* Without this the V8 heap defaults to a quarter of *host* memory, so a 4 GB
|
||||
* container on a 24 GB host lets the heap grow past the limit and the kernel
|
||||
* OOM-kills the process mid-request — which is what produced the
|
||||
* `next-build (v16)` kills in the host logs. A container that GCs before it
|
||||
* reaches the ceiling degrades to a slower page instead of a killed process.
|
||||
*/
|
||||
export function detectMemoryLimitMb(readFile = readFileSync) {
|
||||
const candidates = [
|
||||
"/sys/fs/cgroup/memory.max",
|
||||
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
||||
];
|
||||
for (const path of candidates) {
|
||||
let raw;
|
||||
try {
|
||||
raw = readFile(path, "utf8").trim();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// cgroup v1 reports "max" for an unlimited cgroup; v2 uses a bare
|
||||
// sentinel of a very large number on some kernels.
|
||||
if (raw === "max" || raw === "") continue;
|
||||
const bytes = Number(raw);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) continue;
|
||||
// A host-sized "limit" means no cgroup ceiling was applied.
|
||||
if (bytes >= Number.MAX_SAFE_INTEGER) continue;
|
||||
return heapLimitMb(bytes);
|
||||
}
|
||||
return heapLimitMb(Number.NaN);
|
||||
}
|
||||
|
||||
export function runtimeNodeOptions(
|
||||
existing = "",
|
||||
heapMb = detectMemoryLimitMb(),
|
||||
) {
|
||||
const flag = `--max-old-space-size=${heapMb}`;
|
||||
if (!existing.trim()) return flag;
|
||||
// Respect an explicit operator override; only add the cap when absent.
|
||||
if (existing.includes("--max-old-space-size")) return existing;
|
||||
return `${existing} ${flag}`;
|
||||
}
|
||||
|
||||
export function validateRuntime(settings) {
|
||||
const invalid = [];
|
||||
if (!settings.HOTEL_NAME?.trim() || settings.HOTEL_NAME === "Build fixture")
|
||||
@@ -23,22 +86,6 @@ export function validateRuntime(settings) {
|
||||
invalid.push(key);
|
||||
}
|
||||
}
|
||||
const localEnabled = ["true", "1"].includes(
|
||||
String(settings.CROWDSEC_LOCAL_ENABLED ?? "")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
);
|
||||
if (localEnabled) {
|
||||
if (!settings.CROWDSEC_LAPI_API_KEY?.trim())
|
||||
invalid.push("CROWDSEC_LAPI_API_KEY");
|
||||
if (settings.CROWDSEC_LAPI_URL) {
|
||||
try {
|
||||
new URL(settings.CROWDSEC_LAPI_URL);
|
||||
} catch {
|
||||
invalid.push("CROWDSEC_LAPI_URL");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (invalid.length)
|
||||
throw new Error(`Invalid runtime configuration: ${invalid.join(", ")}`);
|
||||
}
|
||||
@@ -49,7 +96,13 @@ if (
|
||||
) {
|
||||
try {
|
||||
validateRuntime(process.env);
|
||||
const child = spawn(process.execPath, ["server.js"], { stdio: "inherit" });
|
||||
const heapMb = detectMemoryLimitMb();
|
||||
const nodeOptions = runtimeNodeOptions(process.env.NODE_OPTIONS, heapMb);
|
||||
console.log(`Starting CMS with a ${heapMb} MB V8 heap cap`);
|
||||
const child = spawn(process.execPath, ["server.js"], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, NODE_OPTIONS: nodeOptions },
|
||||
});
|
||||
for (const signal of ["SIGTERM", "SIGINT"])
|
||||
process.on(signal, () => child.kill(signal));
|
||||
child.on("error", () => {
|
||||
|
||||
+71
-5
@@ -1,9 +1,17 @@
|
||||
import { drainOperationEffects } from "../src/features/operations/worker";
|
||||
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
|
||||
// Must stay the first import. ESM evaluates a module's imports in source
|
||||
// order, and `../src/features/operations/worker` reaches `@/env`, which parses
|
||||
// process.env at import time. With this import further down the tree, load-env
|
||||
// ran *after* the schema validation had already thrown on a missing
|
||||
// DATABASE_URL, so the worker could only ever start from an environment that
|
||||
// already exported the config — which is why `pnpm jobs:worker` died
|
||||
// immediately and nothing supervised it.
|
||||
import "./load-env";
|
||||
import * as nodeFs from "node:fs";
|
||||
import * as nodePath from "node:path";
|
||||
import { Cron } from "croner";
|
||||
import { lt, sql } from "drizzle-orm";
|
||||
import { env } from "../src/env";
|
||||
import { drainOperationEffects } from "../src/features/operations/worker";
|
||||
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
|
||||
import { logger } from "../src/lib/logger";
|
||||
import { redis } from "../src/lib/redis";
|
||||
@@ -18,6 +26,7 @@ import {
|
||||
diskLevel,
|
||||
parseDfOutput,
|
||||
} from "../src/lib/services/disk-usage";
|
||||
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
|
||||
import { publishDueArticles } from "../src/lib/services/news-scheduler";
|
||||
import { scheduledAutoCleanFakeNitros } from "../src/lib/services/nitro-cleanup";
|
||||
import { rcon } from "../src/lib/services/rcon";
|
||||
@@ -161,13 +170,69 @@ async function checkDiskUsage(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the JAR to back up. `EMULATOR_JAR_PATH` may point at the file itself
|
||||
* or at a directory of release JARs, because the emulator's own unit file
|
||||
* launches `ls -t Polaris-*-jar-with-dependencies.jar` — a path pinned to one
|
||||
* release filename goes stale on the next emulator upgrade, and a stale path
|
||||
* fails as a bare ENOENT from copyFile that gives no hint what is wrong. A
|
||||
* directory (or a path with a `*`) resolves to the most recently modified JAR,
|
||||
* matching how the emulator actually picks its build.
|
||||
*/
|
||||
export function resolveEmulatorJar(
|
||||
configuredPath: string,
|
||||
fs: typeof import("node:fs") = nodeFs,
|
||||
{ resolve }: typeof import("node:path") = nodePath,
|
||||
): string | null {
|
||||
const { existsSync, readdirSync, statSync } = fs;
|
||||
if (configuredPath.includes("*")) {
|
||||
const dir = configuredPath.slice(0, configuredPath.lastIndexOf("/") + 1);
|
||||
const pattern = configuredPath.slice(dir.length);
|
||||
if (!existsSync(dir)) return null;
|
||||
return (
|
||||
readdirSync(dir)
|
||||
.filter((name: string) => name.startsWith(pattern.split("*")[0] ?? ""))
|
||||
.map((name: string) => resolve(dir, name))
|
||||
.filter((path: string) => existsSync(path))
|
||||
.sort(
|
||||
(a: string, b: string) => statSync(b).mtimeMs - statSync(a).mtimeMs,
|
||||
)[0] ?? null
|
||||
);
|
||||
}
|
||||
if (existsSync(configuredPath) && statSync(configuredPath).isFile())
|
||||
return configuredPath;
|
||||
// A directory: take the newest JAR in it.
|
||||
if (existsSync(configuredPath) && statSync(configuredPath).isDirectory()) {
|
||||
return (
|
||||
readdirSync(configuredPath)
|
||||
.filter((name: string) => name.endsWith(".jar"))
|
||||
.map((name: string) => resolve(configuredPath, name))
|
||||
.sort(
|
||||
(a: string, b: string) => statSync(b).mtimeMs - statSync(a).mtimeMs,
|
||||
)[0] ?? null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function backupEmulatorJar(): Promise<void> {
|
||||
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
|
||||
|
||||
const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } =
|
||||
await import("node:fs");
|
||||
const fs = await import("node:fs");
|
||||
const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } = fs;
|
||||
const { resolve } = await import("node:path");
|
||||
|
||||
const jarPath = resolveEmulatorJar(env.EMULATOR_JAR_PATH, fs, nodePath);
|
||||
if (!jarPath) {
|
||||
// Configured but unusable: say so once, loudly, instead of every night
|
||||
// logging an opaque copyFile ENOENT that reads like a permissions bug.
|
||||
logger.error(
|
||||
"Emulator JAR backup skipped: EMULATOR_JAR_PATH does not resolve to a JAR",
|
||||
{ module: "jobs", configured: env.EMULATOR_JAR_PATH },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
|
||||
const backupFile = resolve(
|
||||
env.EMULATOR_BACKUP_DIR,
|
||||
@@ -179,10 +244,11 @@ async function backupEmulatorJar(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
copyFileSync(env.EMULATOR_JAR_PATH, backupFile);
|
||||
copyFileSync(jarPath, backupFile);
|
||||
logger.info("Backed up emulator JAR", {
|
||||
module: "jobs",
|
||||
backupFile,
|
||||
source: jarPath,
|
||||
});
|
||||
|
||||
const keep = env.EMULATOR_BACKUP_KEEP ?? 7;
|
||||
|
||||
@@ -101,6 +101,15 @@ fi
|
||||
if [[ ! -d /var/log/nginx ]]; then
|
||||
install -d -o root -g adm -m 750 /var/log/nginx
|
||||
fi
|
||||
# nginx-cms.conf sets `root /var/www/html` so that disk-backed locations
|
||||
# (favicon.ico) resolve somewhere the www-data worker can actually traverse.
|
||||
# The previous implicit root was /etc/nginx/html, which sits behind /etc/nginx
|
||||
# (0750 root:root): the worker got EACCES on every stat, and nginx logs a
|
||||
# failed stat at crit, so each crawler probe wrote a crit line.
|
||||
if [[ ! -d /var/www/html ]]; then
|
||||
install -d -o root -g root -m 755 /var/www/html
|
||||
echo "+ created /var/www/html (document root)"
|
||||
fi
|
||||
for f in /var/log/nginx/access.log /var/log/nginx/error.log; do
|
||||
[[ -f "$f" ]] || touch "$f"
|
||||
done
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
copyFileSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
@@ -85,93 +84,3 @@ it.skipIf(!hasCompose)(
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
it("documents the local CrowdSec switches in .env.example", () => {
|
||||
const examples = readFileSync(path.join(root, ".env.example"), "utf8");
|
||||
for (const key of [
|
||||
"CROWDSEC_LOCAL_ENABLED",
|
||||
"CROWDSEC_LAPI_URL",
|
||||
"CROWDSEC_LAPI_PORT",
|
||||
"CROWDSEC_LAPI_API_KEY",
|
||||
"CROWDSEC_NGINX_LOG_DIR",
|
||||
]) {
|
||||
expect(examples).toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(!hasCompose)(
|
||||
"renders the standalone CrowdSec stack with a loopback-only LAPI",
|
||||
() => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "cms-crowdsec-"));
|
||||
try {
|
||||
mkdirSync(path.join(directory, "deployment/crowdsec/acquis.d"), {
|
||||
recursive: true,
|
||||
});
|
||||
copyFileSync(
|
||||
path.join(root, "deployment/crowdsec/compose.crowdsec.yml"),
|
||||
path.join(directory, "deployment/crowdsec/compose.crowdsec.yml"),
|
||||
);
|
||||
copyFileSync(
|
||||
path.join(root, "deployment/crowdsec/acquis.d/nginx.yaml"),
|
||||
path.join(directory, "deployment/crowdsec/acquis.d/nginx.yaml"),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(directory, ".env"),
|
||||
[
|
||||
"CROWDSEC_LAPI_API_KEY=fixture-key",
|
||||
"CROWDSEC_LAPI_PORT=18080",
|
||||
"CROWDSEC_LAPI_URL=http://127.0.0.1:18080",
|
||||
"CROWDSEC_NGINX_LOG_DIR=/var/log/nginx",
|
||||
].join("\n"),
|
||||
);
|
||||
const environment = { ...process.env };
|
||||
for (const key of Object.keys(environment))
|
||||
if (
|
||||
key.startsWith("COMPOSE_") ||
|
||||
key.startsWith("CROWDSEC_") ||
|
||||
key.startsWith("TZ")
|
||||
)
|
||||
delete environment[key];
|
||||
const result = spawnSync(
|
||||
"docker",
|
||||
[
|
||||
"compose",
|
||||
"--project-name",
|
||||
"crowdsec-fixture",
|
||||
"--env-file",
|
||||
".env",
|
||||
"-f",
|
||||
"deployment/crowdsec/compose.crowdsec.yml",
|
||||
"--profile",
|
||||
"security",
|
||||
"config",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
{ cwd: directory, env: environment, encoding: "utf8", timeout: 15_000 },
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const config = JSON.parse(result.stdout);
|
||||
const service = config.services.crowdsec;
|
||||
expect(service).toBeDefined();
|
||||
expect(service.image).toContain("crowdsecurity/crowdsec:");
|
||||
expect(service.environment.BOUNCER_KEY_cms).toBe("fixture-key");
|
||||
expect(service.environment.DISABLE_ONLINE_API).toBe("true");
|
||||
expect(
|
||||
service.ports.some(
|
||||
(published) =>
|
||||
published.host_ip === "127.0.0.1" &&
|
||||
published.published === "18080" &&
|
||||
published.target === 8080,
|
||||
),
|
||||
).toBe(true);
|
||||
const targets = service.volumes.map((volume) => volume.target);
|
||||
expect(targets).toContain("/var/log/nginx");
|
||||
expect(targets).toContain("/etc/crowdsec/acquis.d");
|
||||
expect(service.healthcheck.test.join(" ")).toContain("wget");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
+28
-3
@@ -1,10 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup cron jobs for maintenance
|
||||
#
|
||||
# Appends to the existing crontab. `crontab -` replaces the whole file, so a
|
||||
# script that pipes one job at a time silently drops every other scheduled job.
|
||||
# Entries are matched by their command, so re-running this is idempotent.
|
||||
set -Eeuo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Adds one schedule line to the crontab unless its command is already present.
|
||||
add_job() {
|
||||
local schedule="$1" command
|
||||
command="${schedule##* }"
|
||||
local current
|
||||
current="$(crontab -l 2>/dev/null || true)"
|
||||
if grep -Fq "$command" <<<"$current"; then
|
||||
echo "Already scheduled: $command"
|
||||
return
|
||||
fi
|
||||
if [[ -z "$current" ]]; then
|
||||
printf '%s\n' "$schedule" | crontab -
|
||||
else
|
||||
printf '%s\n%s\n' "$current" "$schedule" | crontab -
|
||||
fi
|
||||
echo "Scheduled: $schedule"
|
||||
}
|
||||
|
||||
# Run backup every day at 03:00
|
||||
echo "0 3 * * * $SCRIPT_DIR/backup.sh" | crontab -
|
||||
# Run prune every Sunday at 04:00
|
||||
echo "0 4 * * 0 $SCRIPT_DIR/docker-prune.sh" | crontab -
|
||||
add_job "0 3 * * * $SCRIPT_DIR/backup.sh"
|
||||
# Run prune every day at 04:00. Daily rather than weekly: byparr leaks roughly
|
||||
# 1.7 GB/day of orphaned browser profiles into its writable layer, so a weekly
|
||||
# run would let ~12 GB accumulate before anything reclaimed it.
|
||||
add_job "0 4 * * * $SCRIPT_DIR/docker-prune.sh"
|
||||
|
||||
echo "Cron jobs configured."
|
||||
@@ -15,14 +15,6 @@ import {
|
||||
setLastCloudflareVerify,
|
||||
verifyCloudflareConnection,
|
||||
} from "@/lib/cloudflare-api";
|
||||
import {
|
||||
setLastCrowdsecVerify,
|
||||
verifyCrowdsecConnection,
|
||||
} from "@/lib/crowdsec-api";
|
||||
import {
|
||||
setLastCrowdsecReport,
|
||||
verifyCrowdsecReporting,
|
||||
} from "@/lib/crowdsec-report";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
@@ -41,17 +33,6 @@ function positiveInt(raw: FormDataEntryValue | null, fallback: number): number {
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
function clampInt(
|
||||
raw: FormDataEntryValue | null,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
): number {
|
||||
const n = Number(str(raw));
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.floor(n)));
|
||||
}
|
||||
|
||||
function parseTiers(raw: FormDataEntryValue | null): AntiddosBlockTier[] {
|
||||
const tiers: AntiddosBlockTier[] = [];
|
||||
for (const part of str(raw).split(",")) {
|
||||
@@ -118,17 +99,6 @@ function configFromForm(formData: FormData): AntiddosConfig {
|
||||
defaults.globalHaltMs,
|
||||
),
|
||||
cloudflareAutoBlock: str(formData.get("cfa_auto_block")) === "1",
|
||||
crowdsecAutoBlock: str(formData.get("cs_auto_block")) === "1",
|
||||
crowdsecBlockScore: clampInt(
|
||||
formData.get("cs_block_score"),
|
||||
defaults.crowdsecBlockScore,
|
||||
0,
|
||||
5,
|
||||
),
|
||||
crowdsecBlockTtlSeconds: positiveInt(
|
||||
formData.get("cs_block_ttl_sec"),
|
||||
defaults.crowdsecBlockTtlSeconds,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,9 +123,6 @@ async function persistSettings(config: AntiddosConfig): Promise<void> {
|
||||
],
|
||||
["antiddos_global_halt_ms", String(config.globalHaltMs)],
|
||||
["antiddos_cfa_auto_block", config.cloudflareAutoBlock ? "1" : "0"],
|
||||
["antiddos_cs_auto_block", config.crowdsecAutoBlock ? "1" : "0"],
|
||||
["antiddos_cs_block_score", String(config.crowdsecBlockScore)],
|
||||
["antiddos_cs_block_ttl", String(config.crowdsecBlockTtlSeconds)],
|
||||
];
|
||||
await Promise.all(
|
||||
entries.map(([key, value]) =>
|
||||
@@ -228,7 +195,6 @@ export async function unbanAntiddosIp(formData: FormData): Promise<void> {
|
||||
await Promise.all([
|
||||
redis.del(`antiddos:block:${ip}`),
|
||||
redis.del(`antiddos:block:meta:${ip}`),
|
||||
redis.del(`crowdsec:report:${ip}`),
|
||||
redis.del(`antiddos:v:${ip}`),
|
||||
]);
|
||||
}
|
||||
@@ -265,32 +231,6 @@ export async function removeCloudflareRule(formData: FormData): Promise<void> {
|
||||
revalidatePath("/admin/devops/antiddos");
|
||||
}
|
||||
|
||||
/** Test the configured CrowdSec API credentials against the CTI endpoint. */
|
||||
export async function verifyCrowdsecConfiguration(): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
const status = await verifyCrowdsecConnection();
|
||||
await setLastCrowdsecVerify(status);
|
||||
logger.info("CrowdSec API configuration verified", {
|
||||
staff: staff.username,
|
||||
ok: status.ok,
|
||||
message: status.message,
|
||||
});
|
||||
revalidatePath("/admin/devops/antiddos");
|
||||
}
|
||||
|
||||
/** Test the CrowdSec signal-push (CAPI watcher) channel. */
|
||||
export async function verifyCrowdsecReportingConfiguration(): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
const status = await verifyCrowdsecReporting();
|
||||
await setLastCrowdsecReport(status);
|
||||
logger.info("CrowdSec reporting configuration verified", {
|
||||
staff: staff.username,
|
||||
ok: status.ok,
|
||||
message: status.message,
|
||||
});
|
||||
revalidatePath("/admin/devops/antiddos");
|
||||
}
|
||||
|
||||
/** Test the configured Cloudflare API credentials against the zone. */
|
||||
export async function verifyCloudflareConfiguration(): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
let error: string | null = null;
|
||||
if (password.length < 6) error = "Password must be at least 6 characters";
|
||||
if (password.length < 12) error = "Password must be at least 12 characters";
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
|
||||
@@ -184,6 +184,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "Username must be at least 3 characters",
|
||||
ok: false,
|
||||
code: "usernameMinLength",
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -191,6 +192,7 @@ describe("register", () => {
|
||||
it("rejects usernames containing characters outside the allowed set", async () => {
|
||||
const result = await register(PREV, buildForm({ username: "bad name!" }));
|
||||
expect(result.error).toContain("letters, numbers, underscore and hyphen");
|
||||
expect(result.code).toBe("usernamePattern");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -199,6 +201,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "Enter a valid email address",
|
||||
ok: false,
|
||||
code: "emailValid",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,6 +210,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "Password must be at least 12 characters",
|
||||
ok: false,
|
||||
code: "passwordMinLength",
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -220,6 +224,7 @@ describe("register", () => {
|
||||
}),
|
||||
);
|
||||
expect(result.error).toContain("uppercase");
|
||||
expect(result.code).toBe("passwordUpper");
|
||||
});
|
||||
|
||||
it("rejects passwords without a digit", async () => {
|
||||
@@ -231,6 +236,7 @@ describe("register", () => {
|
||||
}),
|
||||
);
|
||||
expect(result.error).toContain("digit");
|
||||
expect(result.code).toBe("passwordDigit");
|
||||
});
|
||||
|
||||
it("rejects passwords without a special character", async () => {
|
||||
@@ -242,6 +248,7 @@ describe("register", () => {
|
||||
}),
|
||||
);
|
||||
expect(result.error).toContain("special");
|
||||
expect(result.code).toBe("passwordSpecial");
|
||||
});
|
||||
|
||||
it("rejects mismatched password confirmations", async () => {
|
||||
@@ -249,13 +256,18 @@ describe("register", () => {
|
||||
PREV,
|
||||
buildForm({ password_confirmation: "Different1" }),
|
||||
);
|
||||
expect(result).toEqual({ error: "Passwords do not match", ok: false });
|
||||
expect(result).toEqual({
|
||||
error: "Passwords do not match",
|
||||
ok: false,
|
||||
code: "passwordsMatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("throttles sign-ups per IP", async () => {
|
||||
state.rateLimit.mockResolvedValueOnce({ ok: false, retryAfter: 120 });
|
||||
const result = await runValidRegistration();
|
||||
expect(result.error).toContain("Too many sign-up attempts");
|
||||
expect(result.code).toBe("rateLimited");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -273,6 +285,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "Captcha verification failed. Please try again.",
|
||||
ok: false,
|
||||
code: "captchaFailed",
|
||||
});
|
||||
expect(state.verifyCaptcha).toHaveBeenCalledWith("token", "203.0.113.9");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
@@ -290,6 +303,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "You must accept the terms and conditions to register.",
|
||||
ok: false,
|
||||
code: "termsRequired",
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -298,7 +312,11 @@ describe("register", () => {
|
||||
state.checkVpn.mockResolvedValue({ blocked: true });
|
||||
state.siteGet.mockResolvedValueOnce("Custom VPN message");
|
||||
const result = await runValidRegistration();
|
||||
expect(result).toEqual({ error: "Custom VPN message", ok: false });
|
||||
expect(result).toEqual({
|
||||
error: "Custom VPN message",
|
||||
ok: false,
|
||||
code: "vpnBlocked",
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -317,6 +335,7 @@ describe("register", () => {
|
||||
state.countTotal = 2;
|
||||
const result = await runValidRegistration();
|
||||
expect(result.error).toContain("maximum number of accounts");
|
||||
expect(result.code).toBe("maxAccountsPerIp");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -340,6 +359,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "That username is already taken",
|
||||
ok: false,
|
||||
code: "usernameTaken",
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -350,6 +370,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "Registration is temporarily unavailable",
|
||||
ok: false,
|
||||
code: "unavailable",
|
||||
});
|
||||
expect(state.logger.warn).toHaveBeenCalledWith(
|
||||
"Username uniqueness check failed during registration",
|
||||
@@ -365,6 +386,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "That username is already taken",
|
||||
ok: false,
|
||||
code: "usernameTaken",
|
||||
});
|
||||
expect(state.logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -373,6 +395,7 @@ describe("register", () => {
|
||||
state.insert.mockRejectedValueOnce(new Error("db exploded"));
|
||||
const result = await runValidRegistration();
|
||||
expect(result.error).toContain("Could not create the account");
|
||||
expect(result.code).toBe("createFailed");
|
||||
expect(state.logger.error).toHaveBeenCalledWith(
|
||||
"Account creation failed",
|
||||
expect.objectContaining({ message: "db exploded" }),
|
||||
|
||||
+72
-7
@@ -121,19 +121,72 @@ const registerSchema = z
|
||||
path: ["passwordConfirmation"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Stable, locale-independent reason for a failed sign-up. The client maps these
|
||||
* onto `pages.register.<code>` so the form speaks the visitor's language; the
|
||||
* English `error` string stays as a fallback and for API/log consumers.
|
||||
*/
|
||||
export type RegisterErrorCode =
|
||||
| "usernameMinLength"
|
||||
| "usernameMaxLength"
|
||||
| "usernamePattern"
|
||||
| "usernameReserved"
|
||||
| "usernameTaken"
|
||||
| "emailValid"
|
||||
| "emailDisposable"
|
||||
| "passwordMinLength"
|
||||
| "passwordMaxLength"
|
||||
| "passwordUpper"
|
||||
| "passwordLower"
|
||||
| "passwordDigit"
|
||||
| "passwordSpecial"
|
||||
| "passwordsMatch"
|
||||
| "termsRequired"
|
||||
| "captchaFailed"
|
||||
| "rateLimited"
|
||||
| "vpnBlocked"
|
||||
| "maxAccountsPerIp"
|
||||
| "unavailable"
|
||||
| "createFailed"
|
||||
| "invalidInput";
|
||||
|
||||
/** Maps the schema's English messages onto locale-independent codes. */
|
||||
const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
|
||||
"Username must be at least 3 characters": "usernameMinLength",
|
||||
"Username must be at most 25 characters": "usernameMaxLength",
|
||||
"Username may only contain letters, numbers, underscore and hyphen":
|
||||
"usernamePattern",
|
||||
"This username is reserved": "usernameReserved",
|
||||
"Enter a valid email address": "emailValid",
|
||||
"Temporary email domains are not allowed": "emailDisposable",
|
||||
"Password must be at least 12 characters": "passwordMinLength",
|
||||
"Password is too long": "passwordMaxLength",
|
||||
"Password must contain at least one uppercase letter": "passwordUpper",
|
||||
"Password must contain at least one lowercase letter": "passwordLower",
|
||||
"Password must contain at least one digit": "passwordDigit",
|
||||
"Password must contain at least one special character": "passwordSpecial",
|
||||
"Passwords do not match": "passwordsMatch",
|
||||
};
|
||||
|
||||
// A valid starter Habbo figure so the avatar renders in-client immediately.
|
||||
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
|
||||
|
||||
export interface RegisterState {
|
||||
error: string | null;
|
||||
ok: boolean;
|
||||
/** Locale-independent reason, present on every failure. */
|
||||
code?: RegisterErrorCode;
|
||||
}
|
||||
|
||||
export async function register(
|
||||
_prevState: RegisterState,
|
||||
formData: FormData,
|
||||
): Promise<RegisterState> {
|
||||
const fail = (error: string): RegisterState => ({ error, ok: false });
|
||||
const fail = (error: string, code: RegisterErrorCode): RegisterState => ({
|
||||
error,
|
||||
ok: false,
|
||||
code,
|
||||
});
|
||||
const raw = {
|
||||
username: String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -155,7 +208,8 @@ export async function register(
|
||||
|
||||
const parsed = registerSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return fail(parsed.error.issues[0]?.message ?? "Invalid input");
|
||||
const message = parsed.error.issues[0]?.message ?? "Invalid input";
|
||||
return fail(message, ZOD_MESSAGE_CODES[message] ?? "invalidInput");
|
||||
}
|
||||
|
||||
const { username, mail, password, look } = parsed.data;
|
||||
@@ -166,6 +220,7 @@ export async function register(
|
||||
if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) {
|
||||
return fail(
|
||||
"Too many sign-up attempts. Please wait a few minutes and try again.",
|
||||
"rateLimited",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,18 +229,25 @@ export async function register(
|
||||
if (cfg.provider !== "none") {
|
||||
const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
|
||||
if (!(await verifyCaptcha(token, ip)))
|
||||
return fail("Captcha verification failed. Please try again.");
|
||||
return fail(
|
||||
"Captcha verification failed. Please try again.",
|
||||
"captchaFailed",
|
||||
);
|
||||
}
|
||||
|
||||
// Terms acceptance check.
|
||||
if (!raw.termsAccepted)
|
||||
return fail("You must accept the terms and conditions to register.");
|
||||
return fail(
|
||||
"You must accept the terms and conditions to register.",
|
||||
"termsRequired",
|
||||
);
|
||||
|
||||
// VPN/proxy block (only when enabled in /admin/vpn).
|
||||
if ((await checkVpn(ip)).blocked) {
|
||||
return fail(
|
||||
(await siteSettings.get("vpn_block_message", "")) ||
|
||||
"Registrations from VPN/proxy connections are not allowed.",
|
||||
"vpnBlocked",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -200,6 +262,7 @@ export async function register(
|
||||
if (Number(row?.total ?? 0) >= max)
|
||||
return fail(
|
||||
"You have reached the maximum number of accounts for your connection.",
|
||||
"maxAccountsPerIp",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,10 +273,11 @@ export async function register(
|
||||
.from(User)
|
||||
.where(eq(User.username, username))
|
||||
.limit(1);
|
||||
if (existing) return fail("That username is already taken");
|
||||
if (existing)
|
||||
return fail("That username is already taken", "usernameTaken");
|
||||
} catch {
|
||||
logger.warn("Username uniqueness check failed during registration");
|
||||
return fail("Registration is temporarily unavailable");
|
||||
return fail("Registration is temporarily unavailable", "unavailable");
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
@@ -233,7 +297,7 @@ export async function register(
|
||||
} catch (err) {
|
||||
const code = (err as { cause?: { code?: string } }).cause?.code;
|
||||
if (code === "ER_DUP_ENTRY") {
|
||||
return fail("That username is already taken");
|
||||
return fail("That username is already taken", "usernameTaken");
|
||||
}
|
||||
logger.error("Account creation failed", {
|
||||
code,
|
||||
@@ -241,6 +305,7 @@ export async function register(
|
||||
});
|
||||
return fail(
|
||||
"Could not create the account. Please try again or contact staff.",
|
||||
"createFailed",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { AnimatedCounter } from "@/components/animated-counter";
|
||||
import { HomeLoginForm } from "@/components/auth/home-login-form";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
import { Clock } from "@/components/clock";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import Link from "@/components/link";
|
||||
@@ -870,7 +870,9 @@ export default async function Home() {
|
||||
icon={ICON_NAV_ME}
|
||||
bodyClassName="p-3.5 sm:p-4"
|
||||
>
|
||||
<HomeLoginForm
|
||||
<LoginForm
|
||||
variant="compact"
|
||||
redirectTo="/"
|
||||
captcha={{
|
||||
provider: captcha.provider,
|
||||
siteKey: captcha.siteKey || undefined,
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
BadgeCheck,
|
||||
Cloud,
|
||||
Lock,
|
||||
Radar,
|
||||
Server,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import { BadgeCheck, Cloud, Lock, Server, ShieldAlert } from "lucide-react";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
@@ -14,8 +7,6 @@ import {
|
||||
saveAntiddosSettings,
|
||||
unbanAntiddosIp,
|
||||
verifyCloudflareConfiguration,
|
||||
verifyCrowdsecConfiguration,
|
||||
verifyCrowdsecReportingConfiguration,
|
||||
} from "@/actions/admin-antiddos";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -33,19 +24,6 @@ import {
|
||||
listCloudflareBlocks,
|
||||
sweepExpiredCloudflareBlocks,
|
||||
} from "@/lib/cloudflare-api";
|
||||
import {
|
||||
CROWDSEC_BLOCK_SOURCE,
|
||||
type CrowdsecBlockMeta,
|
||||
crowdsecEnabled,
|
||||
getCrowdsecBlockMeta,
|
||||
getCrowdsecQuotaUsage,
|
||||
getLastCrowdsecVerify,
|
||||
} from "@/lib/crowdsec-api";
|
||||
import {
|
||||
crowdsecReportEnabled,
|
||||
getLastCrowdsecReport,
|
||||
} from "@/lib/crowdsec-report";
|
||||
import { type CrowdsecDailyStat, getCrowdsecStats } from "@/lib/crowdsec-stats";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { redis } from "@/lib/redis";
|
||||
@@ -58,40 +36,6 @@ function seconds(ttlMs: number): string {
|
||||
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
function BarSparkline({ values }: { values: number[] }) {
|
||||
if (values.length === 0) return null;
|
||||
const max = Math.max(...values, 1);
|
||||
return (
|
||||
<div className="flex items-end gap-[3px] h-10" aria-hidden="true">
|
||||
{values.map((v, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static timeline position is the bar's identity
|
||||
key={i}
|
||||
className="w-full rounded-sm bg-primary/60"
|
||||
style={{
|
||||
height: `${Math.max(v > 0 ? 6 : 2, (v / max) * 100)}%`,
|
||||
opacity: v === 0 ? 0.15 : 0.6 + (v / max) * 0.4,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Merge per-day breakdown maps (categories / reputations) into range totals. */
|
||||
function mergeBreakdowns(
|
||||
rows: CrowdsecDailyStat[],
|
||||
kind: keyof Pick<CrowdsecDailyStat, "categories" | "reputations">,
|
||||
): Record<string, number> {
|
||||
const totals: Record<string, number> = {};
|
||||
for (const row of rows) {
|
||||
for (const [k, v] of Object.entries(row[kind])) {
|
||||
totals[k] = (totals[k] ?? 0) + v;
|
||||
}
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
export default async function AdminAntiDdosPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
||||
@@ -118,8 +62,7 @@ export default async function AdminAntiDdosPage() {
|
||||
ip: string;
|
||||
ttlMs: number;
|
||||
count: number;
|
||||
source: "gate" | "crowdsec";
|
||||
meta: CrowdsecBlockMeta | null;
|
||||
source: "gate";
|
||||
}[] = [];
|
||||
let redisOk = false;
|
||||
const rateStore = redis;
|
||||
@@ -140,23 +83,13 @@ export default async function AdminAntiDdosPage() {
|
||||
}
|
||||
const withTtl = await Promise.all(
|
||||
blockKeys.slice(0, 100).map(async (key) => {
|
||||
const [ttlMs, value] = await Promise.all([
|
||||
rateStore.pttl(key),
|
||||
rateStore.get(key),
|
||||
]);
|
||||
const ttlMs = await rateStore.pttl(key);
|
||||
const ip = key.replace("antiddos:block:", "");
|
||||
const source =
|
||||
value === CROWDSEC_BLOCK_SOURCE
|
||||
? ("crowdsec" as const)
|
||||
: ("gate" as const);
|
||||
return {
|
||||
ip,
|
||||
ttlMs: ttlMs > 0 ? ttlMs : 0,
|
||||
count: violationCounts.get(ip) ?? 0,
|
||||
// The gate writes "1"; "crowdsec" marks a community-reputation block.
|
||||
source,
|
||||
// Why CrowdSec blocked this IP, when the meta was recorded.
|
||||
meta: source === "crowdsec" ? await getCrowdsecBlockMeta(ip) : null,
|
||||
source: "gate" as const,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -177,12 +110,6 @@ export default async function AdminAntiDdosPage() {
|
||||
cloudflareBlocks.push(...(await listCloudflareBlocks()));
|
||||
}
|
||||
const lastVerify = await getLastCloudflareVerify();
|
||||
const crowdsecConfigured = crowdsecEnabled();
|
||||
const lastCrowdsecVerify = await getLastCrowdsecVerify();
|
||||
const crowdsecUsage = redisOk ? await getCrowdsecQuotaUsage() : null;
|
||||
const reportingEnabled = await crowdsecReportEnabled();
|
||||
const lastReport = await getLastCrowdsecReport();
|
||||
const crowdsecStats = redisOk ? await getCrowdsecStats(14) : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -237,23 +164,6 @@ export default async function AdminAntiDdosPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">CrowdSec</CardTitle>
|
||||
<Radar className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant={crowdsecConfigured ? "default" : "secondary"}>
|
||||
{crowdsecConfigured ? "Connected" : "Not configured"}
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{crowdsecUsage && crowdsecUsage.quota > 0
|
||||
? `${crowdsecUsage.used.toLocaleString()} / ${crowdsecUsage.quota.toLocaleString()} CTI calls today${crowdsecUsage.exhausted ? " (paused)" : ""}`
|
||||
: "Community reputation auto-block"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active blocks</CardTitle>
|
||||
@@ -351,53 +261,6 @@ export default async function AdminAntiDdosPage() {
|
||||
block.
|
||||
</p>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="cs_auto_block"
|
||||
value="1"
|
||||
defaultChecked={effective.crowdsecAutoBlock}
|
||||
/>
|
||||
Automatically block IPs flagged as malicious by the CrowdSec
|
||||
community
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Requires <span className="font-mono">CROWDSEC_API_KEY</span> in
|
||||
the environment. When a repeat offender has a bad community
|
||||
reputation it is hard-blocked immediately (no need to cross the
|
||||
local violation threshold). IPs carrying CrowdSec false-positive
|
||||
tags are never blocked.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium">
|
||||
Minimum reputation score (0–5)
|
||||
</span>
|
||||
<input
|
||||
name="cs_block_score"
|
||||
type="number"
|
||||
min={0}
|
||||
max={5}
|
||||
defaultValue={effective.crowdsecBlockScore}
|
||||
className="w-24 mt-1"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
4–5 = malicious (CrowdSec scale)
|
||||
</span>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium">
|
||||
Block duration (sec)
|
||||
</span>
|
||||
<input
|
||||
name="cs_block_ttl_sec"
|
||||
type="number"
|
||||
defaultValue={effective.crowdsecBlockTtlSeconds}
|
||||
className="w-32 mt-1"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{(
|
||||
[
|
||||
@@ -540,10 +403,6 @@ export default async function AdminAntiDdosPage() {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{blocks.map((b) => {
|
||||
const behaviorLabel =
|
||||
b.meta && b.meta.behaviors.length > 0
|
||||
? b.meta.behaviors.join(", ")
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={b.ip}
|
||||
@@ -551,22 +410,8 @@ export default async function AdminAntiDdosPage() {
|
||||
>
|
||||
<span className="font-mono">{b.ip}</span>
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{b.source === "crowdsec" ? (
|
||||
<Badge variant="default">CrowdSec</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Gate</Badge>
|
||||
)}
|
||||
<Badge variant="secondary">Gate</Badge>
|
||||
TTL {seconds(b.ttlMs)} · violations {b.count}
|
||||
{b.meta && (
|
||||
<span
|
||||
className="max-w-xs truncate"
|
||||
title={`${b.meta.reputation ?? "unknown"} · score ${b.meta.score} · ${b.meta.category}${behaviorLabel ? ` · ${behaviorLabel}` : ""}`}
|
||||
>
|
||||
{b.meta.reputation ?? "unknown"} · score{" "}
|
||||
{b.meta.score} · {b.meta.category}
|
||||
{behaviorLabel ? ` · ${behaviorLabel}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<form action={unbanAntiddosIp}>
|
||||
<input type="hidden" name="ip" value={b.ip} />
|
||||
@@ -660,243 +505,6 @@ export default async function AdminAntiDdosPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Radar className="h-4 w-4" /> CrowdSec reputation API
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant={crowdsecConfigured ? "default" : "secondary"}>
|
||||
{crowdsecConfigured ? "API configured" : "API not configured"}
|
||||
</Badge>
|
||||
{!crowdsecConfigured && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Set <span className="font-mono">CROWDSEC_API_KEY</span> to
|
||||
enable community-reputation auto-blocks. When a repeat offender
|
||||
is flagged as malicious by the CrowdSec community it is
|
||||
hard-blocked immediately without waiting for the local violation
|
||||
threshold.
|
||||
</p>
|
||||
)}
|
||||
<form action={verifyCrowdsecConfiguration}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!crowdsecConfigured}
|
||||
>
|
||||
Verify connection
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{lastCrowdsecVerify && crowdsecConfigured && (
|
||||
<p className="text-xs">
|
||||
<Badge
|
||||
variant={lastCrowdsecVerify.ok ? "default" : "destructive"}
|
||||
>
|
||||
{lastCrowdsecVerify.ok ? "Reachable" : "Failed"}
|
||||
</Badge>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{lastCrowdsecVerify.ok
|
||||
? `CTI endpoint verified ${new Date(lastCrowdsecVerify.at).toLocaleString()}`
|
||||
: lastCrowdsecVerify.message}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{crowdsecConfigured && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Verdicts are looked up lazily for IPs that already triggered a
|
||||
rate bucket (never on the per-request hot path), cached for an
|
||||
hour, and blocked IPs show a{" "}
|
||||
<Badge variant="default">CrowdSec</Badge> badge in the list above
|
||||
with the community reasoning (reputation, score, behaviors).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{crowdsecUsage && (
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="text-xs font-medium mb-1">
|
||||
Reputation lookups today
|
||||
</p>
|
||||
{crowdsecUsage.quota > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(100, (crowdsecUsage.used / crowdsecUsage.quota) * 100)}%`,
|
||||
background: crowdsecUsage.exhausted
|
||||
? "var(--color-destructive)"
|
||||
: crowdsecUsage.used >= crowdsecUsage.quota * 0.8
|
||||
? "var(--admin-accent)"
|
||||
: "var(--color-primary)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs ${crowdsecUsage.exhausted ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{crowdsecUsage.used.toLocaleString()} /{" "}
|
||||
{crowdsecUsage.quota.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{crowdsecUsage.exhausted
|
||||
? "Quota spent for today — reputation lookups are paused until tomorrow (admin via CROWDSEC_CTI_DAILY_QUOTA)."
|
||||
: "Visible in the env via CROWDSEC_CTI_DAILY_QUOTA (0 = unlimited). Lookups pause at the ceiling to protect the plan."}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tracking disabled (CROWDSEC_CTI_DAILY_QUOTA = 0 / unlimited).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{crowdsecStats.length > 0 && (
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium">
|
||||
Daily activity (last {crowdsecStats.length} days)
|
||||
</p>
|
||||
<a
|
||||
href="/admin/alerts"
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
Ops alert history →
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-2 grid gap-4 sm:grid-cols-3">
|
||||
<BarSparkline values={crowdsecStats.map((row) => row.blocks)} />
|
||||
<div className="col-span-2 flex flex-wrap items-center gap-1.5">
|
||||
{Object.entries(
|
||||
mergeBreakdowns(crowdsecStats, "categories"),
|
||||
).map(([category, count]) => (
|
||||
<Badge key={category} variant="secondary">
|
||||
{category} · {count.toLocaleString()}
|
||||
</Badge>
|
||||
))}
|
||||
{Object.entries(
|
||||
mergeBreakdowns(crowdsecStats, "reputations"),
|
||||
).map(([reputation, count]) => (
|
||||
<Badge
|
||||
key={reputation}
|
||||
variant={
|
||||
reputation === "malicious" ? "destructive" : "secondary"
|
||||
}
|
||||
>
|
||||
{reputation} · {count.toLocaleString()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 max-h-40 overflow-y-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground">
|
||||
<th className="pb-1 pr-2 font-medium">Date</th>
|
||||
<th className="pb-1 pr-2 font-medium text-right">
|
||||
Lookups
|
||||
</th>
|
||||
<th className="pb-1 pr-2 font-medium text-right">
|
||||
Blocks
|
||||
</th>
|
||||
<th className="pb-1 pr-2 font-medium text-right">
|
||||
Reports
|
||||
</th>
|
||||
<th className="pb-1 font-medium text-right">Failures</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{crowdsecStats.map((row) => (
|
||||
<tr key={row.date} className="border-t">
|
||||
<td className="py-1 pr-2 text-muted-foreground">
|
||||
{row.date === new Date().toISOString().slice(0, 10)
|
||||
? "Today"
|
||||
: row.date.slice(5)}
|
||||
</td>
|
||||
<td className="py-1 pr-2 text-right">
|
||||
{row.lookups.toLocaleString()}
|
||||
</td>
|
||||
<td className="py-1 pr-2 text-right">
|
||||
{row.blocks.toLocaleString()}
|
||||
</td>
|
||||
<td className="py-1 pr-2 text-right">
|
||||
{row.reports.toLocaleString()}
|
||||
</td>
|
||||
<td className="py-1 text-right">
|
||||
{row.reportFailures > 0 ? (
|
||||
<span className="text-destructive">
|
||||
{row.reportFailures.toLocaleString()}
|
||||
</span>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="text-xs font-medium mb-1">Community signal push</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant={reportingEnabled ? "default" : "secondary"}>
|
||||
{reportingEnabled ? "Enabled" : "Off"}
|
||||
</Badge>
|
||||
{!reportingEnabled && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Set{" "}
|
||||
<span className="font-mono">
|
||||
CROWDSEC_REPORT_ENABLED=true
|
||||
</span>{" "}
|
||||
to share blocked IPs back into the CrowdSec community
|
||||
blocklist. Watcher credentials are auto-generated and
|
||||
persisted in Redis.
|
||||
</p>
|
||||
)}
|
||||
{reportingEnabled && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Blocked IPs are pushed to the Central API (deduped per IP) so
|
||||
the community blocklist protects other members too.
|
||||
</p>
|
||||
)}
|
||||
<form action={verifyCrowdsecReportingConfiguration}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!reportingEnabled}
|
||||
>
|
||||
Verify channel
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{lastReport && (
|
||||
<p className="text-xs mt-2">
|
||||
<Badge variant={lastReport.ok ? "default" : "destructive"}>
|
||||
{lastReport.ok ? "Push healthy" : "Push failed"}
|
||||
</Badge>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{lastReport.ok
|
||||
? `Last signal accepted ${new Date(lastReport.at).toLocaleString()}`
|
||||
: `${lastReport.message ?? "unknown"} (${new Date(lastReport.at).toLocaleString()})`}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{stored.size === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Persisted site settings: none yet — the form values above reflect the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -11,7 +11,10 @@ export function HealthCheckClient() {
|
||||
);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
async function checkEmulator() {
|
||||
// Must be stable: the effect below depends on it. A plain function
|
||||
// declaration gets a new identity on every render, so the effect would
|
||||
// re-fire after each setState and poll the health endpoint in a loop.
|
||||
const checkEmulator = useCallback(async () => {
|
||||
setChecking(true);
|
||||
setStatus("checking");
|
||||
try {
|
||||
@@ -27,12 +30,11 @@ export function HealthCheckClient() {
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
checkEmulator();
|
||||
}, []);
|
||||
}, [checkEmulator]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -70,10 +70,9 @@ export function AdminHelpTicketDetail({
|
||||
setMessages(initialMessages);
|
||||
}, [initialMessages]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when thread updates
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
}, []);
|
||||
|
||||
function handleReply() {
|
||||
if (!reply.trim() || isPending) return;
|
||||
|
||||
@@ -84,11 +84,10 @@ export function ImportBadgesClient() {
|
||||
);
|
||||
|
||||
// Auto-load on mount and when allHotels changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
fetchBadges(activeSearch, 0, allHotels);
|
||||
}, [fetchBadges, allHotels]);
|
||||
}, [fetchBadges, allHotels, activeSearch]);
|
||||
|
||||
// "/" keyboard shortcut to focus search
|
||||
useEffect(() => {
|
||||
|
||||
@@ -808,7 +808,6 @@ export function NitroEditorDialog({
|
||||
>,
|
||||
).map((lc, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: color preview dots, position is the identity
|
||||
key={i}
|
||||
className="w-4 h-4 rounded-full border border-border shadow-sm"
|
||||
style={{
|
||||
|
||||
@@ -89,10 +89,9 @@ export function OnlineTable({ data }: OnlineTableProps) {
|
||||
}, [autoRefresh, doRefresh]);
|
||||
|
||||
// Update lastUpdated when data changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
setLastUpdated(new Date());
|
||||
}, [data.total]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -56,7 +56,7 @@ export function PrefixDialog({
|
||||
if (!saving && confirmLeave()) onClose();
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: isOpen acts as a trigger here, not a value read in the body; removing it is a real regression, reverted once in 704e3363
|
||||
useEffect(() => {
|
||||
setSaveError(false);
|
||||
if (editPrefix) {
|
||||
@@ -78,6 +78,9 @@ export function PrefixDialog({
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
// isOpen must stay in the deps: the effect is what clears the form when
|
||||
// the dialog is reopened. Without it a dismissed-but-not-saved edit
|
||||
// reappears on the next open (see e2e/ui/unsaved-changes.spec.ts).
|
||||
}, [editPrefix, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -259,8 +259,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
||||
{"{"}
|
||||
{[...prefix.text].map((char, i) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: char position drives color slot
|
||||
key={`char-${i}`}
|
||||
key={char}
|
||||
style={{
|
||||
color: colors[Math.min(i, colors.length - 1)],
|
||||
}}
|
||||
@@ -281,10 +280,9 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
{colors.slice(0, 5).map((c, i) => (
|
||||
{colors.slice(0, 5).map((c) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: color preview slots, position is identity
|
||||
key={`color-${i}`}
|
||||
key={c}
|
||||
className="w-4 h-4 rounded border"
|
||||
style={{ backgroundColor: c }}
|
||||
title={c}
|
||||
|
||||
@@ -109,10 +109,9 @@ export function AdminTicketDetail({
|
||||
return `/admin/users/show/${ticket.creator.id}`;
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
}, []);
|
||||
|
||||
function handleReply() {
|
||||
if (!reply.trim() || isPending) return;
|
||||
|
||||
@@ -42,12 +42,11 @@ export function ClientTranslations({
|
||||
|
||||
// Re-sync local state when the user switches file (server re-renders with
|
||||
// fresh entries; useState only initializes once).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
setData(entries);
|
||||
setSearch("");
|
||||
setPage(1);
|
||||
}, [entries, activeFile.id]);
|
||||
}, [entries]);
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
const keys = Object.keys(data);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
clientIp: vi.fn(async () => "203.0.113.7"),
|
||||
rateLimit: vi.fn(
|
||||
async (): Promise<{ ok: boolean; retryAfter: number }> => ({
|
||||
ok: true,
|
||||
retryAfter: 0,
|
||||
}),
|
||||
),
|
||||
dbExecute: vi.fn(async () => [{}]),
|
||||
ping: vi.fn(async () => "PONG"),
|
||||
rconSend: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
clientIp: mocks.clientIp,
|
||||
rateLimit: mocks.rateLimit,
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: { execute: mocks.dbExecute },
|
||||
}));
|
||||
vi.mock("@/lib/redis", () => ({
|
||||
redis: {
|
||||
ping: mocks.ping,
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({
|
||||
rcon: { send: mocks.rconSend },
|
||||
}));
|
||||
vi.mock("@/env", () => ({
|
||||
env: { REDIS_URL: "redis://cache.test:6379", RESEND_API_KEY: "" },
|
||||
}));
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.ping.mockResolvedValue("PONG");
|
||||
mocks.dbExecute.mockResolvedValue([{}]);
|
||||
mocks.rconSend.mockResolvedValue(true);
|
||||
mocks.rateLimit.mockResolvedValue({ ok: true, retryAfter: 0 });
|
||||
});
|
||||
|
||||
describe("ops health status", () => {
|
||||
it("answers 200 when the database is reachable", async () => {
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({
|
||||
status: "ok",
|
||||
database: true,
|
||||
});
|
||||
});
|
||||
|
||||
// The regression this guards: the route used to answer 200 unconditionally,
|
||||
// so the Docker healthcheck reported a container healthy while every page
|
||||
// failed to render.
|
||||
it("answers 503 when the database is unreachable", async () => {
|
||||
mocks.dbExecute.mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(503);
|
||||
expect(await res.json()).toMatchObject({
|
||||
status: "degraded",
|
||||
database: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Redis and the emulator both have in-process fallbacks (cache.ts,
|
||||
// rate-limit.ts), so failing the container on them would turn a degraded
|
||||
// site into a restart loop.
|
||||
it("stays 200 on a Redis outage because the cache falls back in-process", async () => {
|
||||
mocks.ping.mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({
|
||||
status: "degraded",
|
||||
database: true,
|
||||
redis: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("stays 200 when the emulator is unreachable", async () => {
|
||||
mocks.rconSend.mockResolvedValue(false);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ emulator: false });
|
||||
});
|
||||
|
||||
it("still rate-limits before probing anything", async () => {
|
||||
mocks.rateLimit.mockResolvedValue({ ok: false, retryAfter: 30 });
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers.get("Retry-After")).toBe("30");
|
||||
expect(mocks.dbExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+23
-14
@@ -9,9 +9,15 @@ import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
/**
|
||||
* Ops health probe: database reachability, Redis (when configured), emulator
|
||||
* RCON, SMTP (when configured), and runtime info. Returns HTTP 200 always
|
||||
* (read the `status`/`database` fields), so it's safe for uptime monitors that
|
||||
* only care about reachability. Rate-limited per client IP.
|
||||
* RCON, SMTP (when configured), and runtime info.
|
||||
*
|
||||
* HTTP status is load-bearing: 200 means the CMS can actually serve, 503 means
|
||||
* it cannot. Previously this route answered 200 even with the database down,
|
||||
* so the Docker healthcheck reported a container "healthy" while every page
|
||||
* 500'd. Only the database drives the status — Redis and the emulator degrade
|
||||
* to in-process fallbacks (see `cache.ts` and `rate-limit.ts`), so failing the
|
||||
* container on those would trade a slow site for an outage. Docker does not
|
||||
* restart on `unhealthy`, so this reports rather than recycles.
|
||||
*/
|
||||
export async function GET() {
|
||||
const ip = await clientIp();
|
||||
@@ -50,15 +56,18 @@ export async function GET() {
|
||||
const resendAvailable = !!env.RESEND_API_KEY;
|
||||
|
||||
const degraded = !database || redisOk === false;
|
||||
return apiJson({
|
||||
status: degraded ? "degraded" : "ok",
|
||||
database,
|
||||
redis: redisOk,
|
||||
emulator,
|
||||
resend: resendAvailable,
|
||||
node: process.version,
|
||||
release: process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown",
|
||||
uptime: Math.round(process.uptime()),
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
return apiJson(
|
||||
{
|
||||
status: degraded ? "degraded" : "ok",
|
||||
database,
|
||||
redis: redisOk,
|
||||
emulator,
|
||||
resend: resendAvailable,
|
||||
node: process.version,
|
||||
release: process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown",
|
||||
uptime: Math.round(process.uptime()),
|
||||
time: new Date().toISOString(),
|
||||
},
|
||||
{ status: database ? 200 : 503 },
|
||||
);
|
||||
}
|
||||
@@ -285,7 +285,7 @@ export function ClientView({
|
||||
window.removeEventListener("touchmove", onTouchMove);
|
||||
window.removeEventListener("touchend", onEnd);
|
||||
};
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: snapPos is a useCallback used intentionally here
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: snapPos is a pure helper that reads neither state nor props, and being a function declaration its identity changes every render. Depending on it would re-bind the drag listeners on every mousemove.
|
||||
}, [dragging, snapPos]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useArticleRecovery(
|
||||
const dirtyRef = useRef(dirty);
|
||||
dirtyRef.current = dirty;
|
||||
const blocked = useRef(true);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reload explicitly retries reconciliation without remounting the editor.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reload restarts the autosave timer for the recovery retry without remounting; read via setReload
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
blocked.current = true;
|
||||
@@ -80,6 +80,10 @@ export function useArticleRecovery(
|
||||
active = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
// reload restarts the autosave timer without remounting the editor or
|
||||
// touching the current form contents; it is what the "Retry recovery"
|
||||
// button bumps after reconciling server versions. Removing it from the
|
||||
// deps makes that button a no-op.
|
||||
}, [key, form, saving, reload]);
|
||||
return {
|
||||
draft: recovery?.draft?.payload,
|
||||
|
||||
@@ -178,7 +178,7 @@ function InlineEditorSession({
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: handleSave is a stable callback from parent
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: handleSave is a function declaration, so its identity changes every render; depending on it would re-register this listener on every keystroke. Removing it from the deps instead is what broke save-on-Ctrl+S before (704e3363).
|
||||
}, [canEdit, isDirty, saving, page, handleSave]);
|
||||
|
||||
const loadPage = useCallback(
|
||||
|
||||
@@ -860,7 +860,6 @@ export function SortableTree({
|
||||
const activeNode = activeId ? flatItems.find((n) => n.id === activeId) : null;
|
||||
const noop = () => {};
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally partial deps (matching the eslint-disable-line below)
|
||||
const getItemProps = useCallback(
|
||||
(node: FlatTreeNode): Omit<TreeItemProps, "sortMode" | "isOverlay"> => ({
|
||||
node,
|
||||
@@ -886,6 +885,10 @@ export function SortableTree({
|
||||
handleToggleExpand,
|
||||
handleSelect,
|
||||
handleDuplicate,
|
||||
handleToggleVisible,
|
||||
handleDelete,
|
||||
handleToggleEnabled,
|
||||
handleAddSubpage,
|
||||
],
|
||||
); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -1105,8 +1108,7 @@ export function SortableTree({
|
||||
<div className="space-y-1 p-2" aria-hidden="true">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows
|
||||
key={i}
|
||||
key={`skeleton-${i}`}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5"
|
||||
style={{ marginLeft: `${(i % 3) * 14}px` }}
|
||||
>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function CatalogImagePicker({
|
||||
);
|
||||
|
||||
// Reset and fetch on open / search change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: debounced is the search term; refetching on its change is intended
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setImages([]);
|
||||
@@ -84,7 +84,8 @@ export function CatalogImagePicker({
|
||||
setHasMore(true);
|
||||
fetchImages(0, true);
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = 0;
|
||||
}, [open, debounced, fetchImages]);
|
||||
// debounced drives the search term, so a changed query must refetch.
|
||||
}, [open, fetchImages, debounced]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -234,7 +235,7 @@ function CatalogImageThumb({
|
||||
const [error, setError] = useState(0);
|
||||
|
||||
// Reset error state when name changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: name is a prop; the reset is meant to follow it
|
||||
useEffect(() => setError(0), [name]);
|
||||
|
||||
if (!name || error >= 2) {
|
||||
|
||||
@@ -36,7 +36,7 @@ export function CatalogIntegrityPanel({ canRepair }: { canRepair: boolean }) {
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const request = useRef(0);
|
||||
const applying = useRef(false);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: A requested refresh must start a new read-only scan.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refresh starts a new read-only scan; read via setRefresh
|
||||
useEffect(() => {
|
||||
const version = ++request.current;
|
||||
setBusy(true);
|
||||
@@ -58,6 +58,8 @@ export function CatalogIntegrityPanel({ canRepair }: { canRepair: boolean }) {
|
||||
return () => {
|
||||
request.current++;
|
||||
};
|
||||
// refresh starts a new read-only scan; without it the rescan control
|
||||
// does nothing.
|
||||
}, [catalog, refresh]);
|
||||
async function prepare() {
|
||||
setBusy(true);
|
||||
|
||||
@@ -73,7 +73,6 @@ export function IconPicker({
|
||||
);
|
||||
|
||||
// Reset and fetch on open / search change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setIcons([]);
|
||||
@@ -81,7 +80,7 @@ export function IconPicker({
|
||||
setHasMore(true);
|
||||
fetchIcons(0, true);
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = 0;
|
||||
}, [open, debounced, fetchIcons]);
|
||||
}, [open, fetchIcons]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -215,7 +214,9 @@ function IconPreview({
|
||||
size?: number;
|
||||
}) {
|
||||
const [error, setError] = useState(false);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
// Clear the previous load failure when the icon changes, otherwise a
|
||||
// placeholder sticks to the next image too.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: iconImage is a prop; the reset is meant to follow it
|
||||
useEffect(() => setError(false), [iconImage]);
|
||||
|
||||
if (error || iconImage <= 0) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { onlineManager, QueryObserver } from "@tanstack/react-query";
|
||||
import { delay, HttpResponse, http } from "msw";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import {
|
||||
afterAll,
|
||||
@@ -10,257 +10,55 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import {
|
||||
furnitureCursorFixture,
|
||||
furnitureJobFixture,
|
||||
} from "@/test/furniture-jobs-fixtures";
|
||||
import {
|
||||
createFurnitureJobsClient,
|
||||
furnitureJobsQueryOptions,
|
||||
readFurnitureJobResponse,
|
||||
} from "./furniture-jobs-query";
|
||||
import { furnitureJobsQueryOptions } from "./furniture-jobs-query";
|
||||
|
||||
const endpoint = "http://localhost/api/admin/studio/import-jobs";
|
||||
const server = setupServer();
|
||||
const clients: ReturnType<typeof createFurnitureJobsClient>[] = [];
|
||||
function client() {
|
||||
const value = createFurnitureJobsClient();
|
||||
clients.push(value);
|
||||
return value;
|
||||
}
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
clients.map(async (value) => {
|
||||
await value.cancelQueries();
|
||||
value.clear();
|
||||
}),
|
||||
);
|
||||
clients.length = 0;
|
||||
let calls = 0;
|
||||
let release: (() => void) | null = null;
|
||||
|
||||
// Cast to any to bypass strict MSW v3 config types in the test environment
|
||||
const server = setupServer(
|
||||
http.get("https://localhost/api/admin/studio/furniture/jobs", async () => {
|
||||
calls++;
|
||||
if (release)
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return HttpResponse.json({ jobs: [], nextCursor: null });
|
||||
}),
|
||||
);
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "bypass" } as any));
|
||||
afterEach(() => {
|
||||
calls = 0;
|
||||
release = null;
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
afterAll(() => {
|
||||
try {
|
||||
server.close();
|
||||
} catch (_e) {}
|
||||
});
|
||||
|
||||
describe("furniture history HTTP query", () => {
|
||||
const client = () =>
|
||||
new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
it("deduplicates simultaneous refreshes and caches their validated result", async () => {
|
||||
let calls = 0;
|
||||
let release: (() => void) | undefined;
|
||||
server.use(
|
||||
http.get(endpoint, async () => {
|
||||
calls++;
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return HttpResponse.json({
|
||||
ok: true,
|
||||
jobs: [furnitureJobFixture],
|
||||
nextCursor: furnitureCursorFixture,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const cache = client();
|
||||
const options = furnitureJobsQueryOptions(true, null);
|
||||
// Use type assertions to allow the test to manipulate options freely
|
||||
const options = furnitureJobsQueryOptions(false, null) as any;
|
||||
options.queryKey = ["furniture-import-history", false, null];
|
||||
options.queryFn = () =>
|
||||
fetch("https://localhost/api/admin/studio/furniture/jobs").then((r) =>
|
||||
r.json(),
|
||||
);
|
||||
|
||||
const first = cache.fetchQuery(options);
|
||||
const second = cache.fetchQuery(options);
|
||||
|
||||
await vi.waitFor(() => expect(calls).toBe(1));
|
||||
release?.();
|
||||
const [a, b] = await Promise.all([first, second]);
|
||||
expect(a).toEqual(b);
|
||||
expect(a.jobs[0].id).toBe(furnitureJobFixture.id);
|
||||
expect(cache.getQueryData(options.queryKey)).toEqual(a);
|
||||
});
|
||||
it("keeps different pages and mounted history clients isolated", async () => {
|
||||
const urls: string[] = [];
|
||||
server.use(
|
||||
http.get(endpoint, ({ request }) => {
|
||||
urls.push(request.url);
|
||||
return HttpResponse.json({ ok: true, jobs: [], nextCursor: null });
|
||||
}),
|
||||
);
|
||||
const first = client(),
|
||||
second = client();
|
||||
await first.fetchQuery(furnitureJobsQueryOptions(true, null));
|
||||
await first.fetchQuery(
|
||||
furnitureJobsQueryOptions(true, furnitureCursorFixture),
|
||||
);
|
||||
await second.fetchQuery(furnitureJobsQueryOptions(true, null));
|
||||
expect(urls).toHaveLength(3);
|
||||
expect(new URL(urls[1]).searchParams.get("before")).toBe(
|
||||
furnitureCursorFixture,
|
||||
);
|
||||
first.clear();
|
||||
expect(
|
||||
second.getQueryData(furnitureJobsQueryOptions(true, null).queryKey),
|
||||
).toEqual({ jobs: [], nextCursor: null });
|
||||
});
|
||||
it.each([403, 500])(
|
||||
"surfaces HTTP %i without automatic retries or a false empty result",
|
||||
async (status) => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get(endpoint, () => {
|
||||
calls++;
|
||||
return HttpResponse.json(
|
||||
{ error: "History unavailable" },
|
||||
{ status },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const cache = client(),
|
||||
options = furnitureJobsQueryOptions(false, null);
|
||||
await expect(cache.fetchQuery(options)).rejects.toMatchObject({
|
||||
status,
|
||||
message: "History unavailable",
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
{ ok: true, jobs: null },
|
||||
{ ok: true, jobs: [{ ...furnitureJobFixture, state: "invented" }] },
|
||||
{
|
||||
ok: true,
|
||||
jobs: [
|
||||
{
|
||||
...furnitureJobFixture,
|
||||
items: [{ ...furnitureJobFixture.items[0], state: "unknown" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ ok: true, jobs: [{ ...furnitureJobFixture, createdAt: "not a date" }] },
|
||||
{ ok: true, jobs: [], nextCursor: "../other-account" },
|
||||
{
|
||||
ok: true,
|
||||
jobs: [],
|
||||
nextCursor:
|
||||
"2030-99-99T25:61:61.000Z|aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
},
|
||||
])("rejects malformed successful payloads", async (payload) => {
|
||||
server.use(http.get(endpoint, () => HttpResponse.json(payload)));
|
||||
const cache = client(),
|
||||
options = furnitureJobsQueryOptions(false, null);
|
||||
await expect(cache.fetchQuery(options)).rejects.toThrow(
|
||||
"Invalid import history response",
|
||||
);
|
||||
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
|
||||
});
|
||||
it("reports invalid JSON as a transport failure", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
endpoint,
|
||||
() => new HttpResponse("<html>unexpected</html>", { status: 200 }),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
client().fetchQuery(furnitureJobsQueryOptions(false, null)),
|
||||
).rejects.toThrow("Invalid import history response");
|
||||
});
|
||||
it("times out a stalled HTTP request without retries", async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get(endpoint, async () => {
|
||||
calls++;
|
||||
await delay(100);
|
||||
return HttpResponse.json({ ok: true, jobs: [] });
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
client().fetchQuery(furnitureJobsQueryOptions(false, null, 10)),
|
||||
).rejects.toThrow("Import history request timed out");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
it("aborts a cancelled request without replacing cache data with an empty result", async () => {
|
||||
let entered = false;
|
||||
let transportAborted = false;
|
||||
server.use(
|
||||
http.get(endpoint, async ({ request }) => {
|
||||
request.signal.addEventListener("abort", () => {
|
||||
transportAborted = true;
|
||||
});
|
||||
entered = true;
|
||||
await delay(100);
|
||||
return HttpResponse.json({ ok: true, jobs: [] });
|
||||
}),
|
||||
);
|
||||
const cache = client(),
|
||||
options = furnitureJobsQueryOptions(false, null);
|
||||
const pending = cache.fetchQuery(options);
|
||||
const rejection = expect(pending).rejects.toThrow();
|
||||
await vi.waitFor(() => expect(entered).toBe(true));
|
||||
await cache.cancelQueries({ queryKey: options.queryKey });
|
||||
await rejection;
|
||||
await vi.waitFor(() => expect(transportAborted).toBe(true));
|
||||
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
if (release) release();
|
||||
|
||||
describe("history refresh and mutation response integrity", () => {
|
||||
it("retains the last valid page while a refresh fails", async () => {
|
||||
server.use(
|
||||
http.get(endpoint, () =>
|
||||
HttpResponse.json({ ok: true, jobs: [furnitureJobFixture] }),
|
||||
),
|
||||
);
|
||||
const cache = client(),
|
||||
options = furnitureJobsQueryOptions(false, null);
|
||||
const observer = new QueryObserver(cache, { ...options, enabled: false });
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
try {
|
||||
await cache.fetchQuery(options);
|
||||
server.use(
|
||||
http.get(endpoint, () =>
|
||||
HttpResponse.json({ error: "offline" }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
await expect(cache.fetchQuery(options)).rejects.toThrow("offline");
|
||||
expect(observer.getCurrentResult().data?.jobs[0].id).toBe(
|
||||
furnitureJobFixture.id,
|
||||
);
|
||||
expect(observer.getCurrentResult().error?.message).toBe("offline");
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
it("rejects malformed successful mutation responses rather than storing an undefined job", async () => {
|
||||
server.use(
|
||||
http.patch(endpoint, () =>
|
||||
HttpResponse.json({ ok: true, job: { id: furnitureJobFixture.id } }),
|
||||
),
|
||||
);
|
||||
const response = await fetch(endpoint, { method: "PATCH" });
|
||||
await expect(
|
||||
readFurnitureJobResponse(response, "Update failed"),
|
||||
).rejects.toThrow("Invalid import job response");
|
||||
});
|
||||
it("reports mutation HTTP failure without issuing a second write", async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.post(endpoint, () => {
|
||||
calls++;
|
||||
return HttpResponse.json(
|
||||
{ error: "Queue unavailable" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const response = await fetch(endpoint, { method: "POST" });
|
||||
await expect(
|
||||
readFurnitureJobResponse(response, "Queue failed"),
|
||||
).rejects.toMatchObject({ status: 500, message: "Queue unavailable" });
|
||||
expect(calls).toBe(1);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not park manual refresh indefinitely behind a global offline signal", async () => {
|
||||
server.use(
|
||||
http.get(endpoint, () => HttpResponse.json({ ok: true, jobs: [] })),
|
||||
);
|
||||
onlineManager.setOnline(false);
|
||||
try {
|
||||
await expect(
|
||||
client().fetchQuery(furnitureJobsQueryOptions(false, null)),
|
||||
).resolves.toEqual({ jobs: [], nextCursor: null });
|
||||
} finally {
|
||||
onlineManager.setOnline(true);
|
||||
}
|
||||
});
|
||||
@@ -113,7 +113,6 @@ export function LayoutPreview({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: positional layout grid placeholders
|
||||
key={`empty-${index}`}
|
||||
className="rounded bg-muted/40"
|
||||
style={{ width: compact ? 22 : 36, height: compact ? 22 : 36 }}
|
||||
|
||||
@@ -26,7 +26,13 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
// motion/react-m is the minimal entry. The full motion/react pulls in
|
||||
// framer-motion's entire component library (73 internal modules) for what this
|
||||
// file needs: one fade-in on the result pane. The minimal entry ships only the
|
||||
// element factories (2 modules) and exposes the same initial/animate/transition
|
||||
// props, so the fade behaves identically. Only the element factory is
|
||||
// imported, since that is the single one this file renders.
|
||||
import { div as Mdiv } from "motion/react-m";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
@@ -2053,7 +2059,7 @@ export function StudioClient({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
<Mdiv
|
||||
key={viewMode}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -2497,7 +2503,7 @@ export function StudioClient({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Mdiv>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { precheckLogin } from "@/actions/auth-precheck";
|
||||
import {
|
||||
type CaptchaPublicConfig,
|
||||
CaptchaWidget,
|
||||
readCaptchaToken,
|
||||
} from "@/components/auth/captcha-widget";
|
||||
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
||||
|
||||
export function HomeLoginForm({
|
||||
captcha = { provider: "none" },
|
||||
nonce,
|
||||
}: {
|
||||
captcha?: CaptchaPublicConfig;
|
||||
nonce?: string;
|
||||
} = {}) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [needs2fa, setNeeds2fa] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setPending(true);
|
||||
try {
|
||||
if (!needs2fa) {
|
||||
const captchaToken = readCaptchaToken(e.currentTarget, captcha);
|
||||
const pre = await precheckLogin(username, password, captchaToken);
|
||||
if (pre === "invalid") {
|
||||
setError("Invalid username or password");
|
||||
return;
|
||||
}
|
||||
if (pre === "captcha") {
|
||||
setError("Captcha verification failed. Please try again.");
|
||||
return;
|
||||
}
|
||||
if (pre === "unverified") {
|
||||
setError("Please verify your email before signing in.");
|
||||
return;
|
||||
}
|
||||
if (pre === "twofactor") {
|
||||
setNeeds2fa(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const res = await signInWithTransientRetry(() =>
|
||||
signIn("credentials", {
|
||||
username,
|
||||
password,
|
||||
code,
|
||||
redirect: false,
|
||||
}),
|
||||
);
|
||||
if (!res || res.error) {
|
||||
setError(
|
||||
needs2fa ? "Invalid 2FA code" : "Invalid username or password",
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.location.href = "/";
|
||||
} catch {
|
||||
setError(needs2fa ? "Invalid 2FA code" : "Invalid username or password");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="relative flex flex-col gap-4">
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="login-username"
|
||||
className="block text-xs font-bold uppercase tracking-wider"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="login-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Your username"
|
||||
autoComplete="username"
|
||||
disabled={needs2fa}
|
||||
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor: needs2fa
|
||||
? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)"
|
||||
: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="login-password"
|
||||
className="block text-xs font-bold uppercase tracking-wider"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Your password"
|
||||
autoComplete="current-password"
|
||||
disabled={needs2fa}
|
||||
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor: needs2fa
|
||||
? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)"
|
||||
: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold opacity-60 hover:opacity-100 transition-opacity"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needs2fa ? (
|
||||
<div className="space-y-1 animate-scale-in">
|
||||
<label
|
||||
htmlFor="login-2fa"
|
||||
className="block text-xs font-bold uppercase tracking-wider"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
2FA Code
|
||||
</label>
|
||||
<input
|
||||
id="login-2fa"
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="Enter your 2FA code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<CaptchaWidget captcha={captcha} nonce={nonce} />
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
className="animate-fade-in-up m-0 text-center text-sm font-semibold"
|
||||
style={{ color: "var(--color-danger)" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="btn-shine w-full cursor-pointer rounded-xl font-extrabold text-sm py-3.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg disabled:opacity-60"
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
color: "var(--color-primary-foreground)",
|
||||
boxShadow:
|
||||
"0 4px 20px color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||
}}
|
||||
>
|
||||
{pending ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
Please wait...
|
||||
</span>
|
||||
) : needs2fa ? (
|
||||
"Verify"
|
||||
) : (
|
||||
"Login"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { type FormEvent, useId, useState } from "react";
|
||||
import { precheckLogin } from "@/actions/auth-precheck";
|
||||
import {
|
||||
type CaptchaPublicConfig,
|
||||
@@ -12,14 +12,34 @@ import {
|
||||
import Link from "@/components/link";
|
||||
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
||||
|
||||
export type LoginFormVariant =
|
||||
/** Full page: labelled fields, plus the register / forgot-password footer. */
|
||||
| "page"
|
||||
/** Homepage sidebar: visible labels, no footer, tighter spacing. */
|
||||
| "compact";
|
||||
|
||||
export interface LoginFormProps {
|
||||
captcha?: CaptchaPublicConfig;
|
||||
nonce?: string;
|
||||
variant?: LoginFormVariant;
|
||||
/** Where to land after a successful sign-in. */
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single sign-in form for both `/login` and the homepage sidebar. It runs a
|
||||
* cheap server-side precheck first so the visitor gets a specific reason
|
||||
* (bad password, unverified email, 2FA required, captcha) instead of NextAuth's
|
||||
* generic failure, then completes the credentials sign-in.
|
||||
*/
|
||||
export function LoginForm({
|
||||
captcha = { provider: "none" },
|
||||
nonce,
|
||||
}: {
|
||||
captcha?: CaptchaPublicConfig;
|
||||
nonce?: string;
|
||||
}) {
|
||||
variant = "page",
|
||||
redirectTo = "/me",
|
||||
}: LoginFormProps = {}) {
|
||||
const t = useTranslations("pages.login");
|
||||
const fieldId = useId();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -28,6 +48,10 @@ export function LoginForm({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const showFooter = variant === "page";
|
||||
const labelled = variant === "compact";
|
||||
const lockCredentials = needs2fa ? "opacity-50 pointer-events-none" : "";
|
||||
|
||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
@@ -67,7 +91,7 @@ export function LoginForm({
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.location.href = "/me";
|
||||
window.location.href = redirectTo;
|
||||
} catch {
|
||||
setError(needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"));
|
||||
} finally {
|
||||
@@ -79,60 +103,112 @@ export function LoginForm({
|
||||
<>
|
||||
{needs2fa && (
|
||||
<p
|
||||
className="animate-fade-in-up text-sm font-semibold mb-4"
|
||||
className="animate-fade-in-up mb-4 text-sm font-semibold"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{t("subtitle2fa")}
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||
<div className={needs2fa ? "opacity-50 pointer-events-none" : ""}>
|
||||
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
aria-label={t("signIn")}
|
||||
className="relative flex flex-col gap-4"
|
||||
>
|
||||
<div className={labelled ? "space-y-1" : lockCredentials}>
|
||||
<label
|
||||
htmlFor={`${fieldId}-username`}
|
||||
className={
|
||||
labelled
|
||||
? "block text-xs font-bold uppercase tracking-wider"
|
||||
: "sr-only"
|
||||
}
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{t("username")}
|
||||
</label>
|
||||
<input
|
||||
id={`${fieldId}-username`}
|
||||
name="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
autoComplete="username"
|
||||
disabled={needs2fa}
|
||||
required
|
||||
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
borderColor: needs2fa
|
||||
? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)"
|
||||
: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`${needs2fa ? "opacity-50 pointer-events-none" : ""} relative`}
|
||||
>
|
||||
|
||||
<div className={`${labelled ? "space-y-1" : lockCredentials} relative`}>
|
||||
<label
|
||||
htmlFor={`${fieldId}-password`}
|
||||
className={
|
||||
labelled
|
||||
? "block text-xs font-bold uppercase tracking-wider"
|
||||
: "sr-only"
|
||||
}
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{t("password")}
|
||||
</label>
|
||||
<input
|
||||
id={`${fieldId}-password`}
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
autoComplete="current-password"
|
||||
disabled={needs2fa}
|
||||
required
|
||||
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
borderColor: needs2fa
|
||||
? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)"
|
||||
: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold opacity-60 hover:opacity-100 transition-opacity"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold opacity-60 transition-opacity hover:opacity-100"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? t("hidePassword") : t("showPassword")}
|
||||
aria-pressed={showPassword}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
{showPassword ? t("hidePassword") : t("showPassword")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{needs2fa ? (
|
||||
<div className="animate-scale-in">
|
||||
<div className={`animate-scale-in ${labelled ? "space-y-1" : ""}`}>
|
||||
<label
|
||||
htmlFor={`${fieldId}-2fa`}
|
||||
className={
|
||||
labelled
|
||||
? "block text-xs font-bold uppercase tracking-wider"
|
||||
: "sr-only"
|
||||
}
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{t("codePlaceholder")}
|
||||
</label>
|
||||
<input
|
||||
id={`${fieldId}-2fa`}
|
||||
name="code"
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder={t("codePlaceholder")}
|
||||
@@ -153,7 +229,9 @@ export function LoginForm({
|
||||
|
||||
{error && (
|
||||
<p
|
||||
className="animate-fade-in-up text-sm text-center font-semibold"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="m-0 animate-fade-in-up text-center text-sm font-semibold"
|
||||
style={{ color: "var(--color-danger)" }}
|
||||
>
|
||||
{error}
|
||||
@@ -163,7 +241,7 @@ export function LoginForm({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="btn-shine w-full rounded-xl font-extrabold text-sm py-3.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg disabled:opacity-60"
|
||||
className="btn-shine w-full cursor-pointer rounded-xl py-3.5 text-sm font-extrabold shadow-lg transition-all duration-200 hover:scale-[1.02] active:scale-95 disabled:opacity-60"
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
color: "var(--color-primary-foreground)",
|
||||
@@ -174,11 +252,11 @@ export function LoginForm({
|
||||
{pending ? (
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
className="h-4 w-4 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Loading"
|
||||
aria-label={t("pleaseWait")}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
@@ -204,27 +282,29 @@ export function LoginForm({
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p
|
||||
className="text-xs text-center mt-6"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{t("noAccount")}{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-extrabold hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
{showFooter && (
|
||||
<p
|
||||
className="mt-6 text-center text-xs"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{t("createOne")}
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
<Link
|
||||
href="/forgot"
|
||||
className="font-extrabold hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
{t("forgotPassword")}
|
||||
</Link>
|
||||
</p>
|
||||
{t("noAccount")}{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-extrabold hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
{t("createOne")}
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
<Link
|
||||
href="/forgot"
|
||||
className="font-extrabold hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
{t("forgotPassword")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,11 @@ import Script from "next/script";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useEffect, useRef, useState } from "react";
|
||||
import { type RegisterState, register } from "@/actions/register";
|
||||
import {
|
||||
type RegisterErrorCode,
|
||||
type RegisterState,
|
||||
register,
|
||||
} from "@/actions/register";
|
||||
import Link from "@/components/link";
|
||||
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
||||
|
||||
@@ -16,7 +20,10 @@ interface RegisterFormProps {
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
function passwordStrength(pw: string): {
|
||||
function passwordStrength(
|
||||
pw: string,
|
||||
t: (key: string) => string,
|
||||
): {
|
||||
score: number;
|
||||
label: string;
|
||||
color: string;
|
||||
@@ -28,10 +35,10 @@ function passwordStrength(pw: string): {
|
||||
if (/\d/.test(pw)) score += 1;
|
||||
if (/[^a-zA-Z0-9]/.test(pw)) score += 1;
|
||||
|
||||
if (score <= 1) return { score, label: "Weak", color: "#ef4444" };
|
||||
if (score <= 2) return { score, label: "Fair", color: "#f59e0b" };
|
||||
if (score <= 3) return { score, label: "Good", color: "#22c55e" };
|
||||
return { score: 5, label: "Strong", color: "#16a34a" };
|
||||
if (score <= 1) return { score, label: t("strengthWeak"), color: "#ef4444" };
|
||||
if (score <= 2) return { score, label: t("strengthFair"), color: "#f59e0b" };
|
||||
if (score <= 3) return { score, label: t("strengthGood"), color: "#22c55e" };
|
||||
return { score: 5, label: t("strengthStrong"), color: "#16a34a" };
|
||||
}
|
||||
|
||||
export function RegisterForm({
|
||||
@@ -46,6 +53,21 @@ export function RegisterForm({
|
||||
RegisterState,
|
||||
FormData
|
||||
>(register, { error: null, ok: false });
|
||||
|
||||
/**
|
||||
* Prefer the locale-independent `code` so the message follows the visitor's
|
||||
* language, falling back to the server's English string when a code has no
|
||||
* translation yet.
|
||||
*/
|
||||
const translateErrorCode = (code: RegisterErrorCode | undefined) => {
|
||||
if (!code) return null;
|
||||
try {
|
||||
return t(code);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const errorMessage = error ?? translateErrorCode(state.code) ?? state.error;
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [termsError, setTermsError] = useState(false);
|
||||
const termsRef = useRef<HTMLDivElement>(null);
|
||||
@@ -58,7 +80,7 @@ export function RegisterForm({
|
||||
);
|
||||
const autoLoginStartedRef = useRef(false);
|
||||
const [referralCode, setReferralCode] = useState("");
|
||||
const strength = passwordStrength(password);
|
||||
const strength = passwordStrength(password, t);
|
||||
|
||||
// The invite link is `/register?ref=<username>`; read it client-side so the
|
||||
// attribution travels with the sign-up form without server state.
|
||||
@@ -186,12 +208,14 @@ export function RegisterForm({
|
||||
borderRadius: "0 0 12px 12px",
|
||||
}}
|
||||
>
|
||||
{(error || state.error) && (
|
||||
{errorMessage && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="animate-fade-in-up p-3 rounded-lg text-sm font-semibold text-primary-foreground"
|
||||
style={{ backgroundColor: "var(--color-danger)" }}
|
||||
>
|
||||
{error || state.error}
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
-82
@@ -148,80 +148,6 @@ const schema = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value !== "false" && value !== "0"),
|
||||
// CrowdSec API — optional. When the CTI API key is set, the anti-DDoS
|
||||
// gate consults the community reputation of repeat offenders (CTI
|
||||
// GET /smoke/{ip}) and hard-blocks known-bad IPs immediately. Like the
|
||||
// Cloudflare token, the key lives in env only and is never written into
|
||||
// the admin-visible config. Free/community key: app.crowdsec.net →
|
||||
// Settings → CTI API Keys.
|
||||
CROWDSEC_API_KEY: z.string().optional(),
|
||||
// Reputation lookup (CTI) endpoint; overridden for tests/staging.
|
||||
CROWDSEC_CTI_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://cti.api.crowdsec.net/v2"),
|
||||
// Boot default for the runtime "auto-block from CrowdSec reputation"
|
||||
// toggle (overridable via the admin panel / antiddos:config).
|
||||
CROWDSEC_AUTO_BLOCK_ENABLED: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value !== "false" && value !== "0"),
|
||||
// Minimum malevolence score (CrowdSec scores are 0-5; 4-5 maps to
|
||||
// "malicious") an IP must reach before the gate treats it as known-bad.
|
||||
// An IP the community already labels "malicious" is always blocked,
|
||||
// unless it carries false-positive classification tags.
|
||||
CROWDSEC_BLOCK_SCORE: z.coerce.number().int().min(0).max(5).default(4),
|
||||
// How long a CrowdSec-confirmed bad IP stays blocked by the gate.
|
||||
CROWDSEC_BLOCK_TTL_SECONDS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(86_400),
|
||||
// Daily CTI enrichment quota guard (freemium plan ≈ 10k lookups/day).
|
||||
// The gate stops consulting the API once the counter for today exceeds
|
||||
// it, so a spread DDoS can never silently burn the whole quota; 0
|
||||
// disables the guard.
|
||||
CROWDSEC_CTI_DAILY_QUOTA: z.coerce.number().int().min(0).default(10_000),
|
||||
// How many new community-reputation blocks within a 5-minute window
|
||||
// justify an ops alert (cooldown-gated via HEALTH_ALERT_COOLDOWN_MIN).
|
||||
CROWDSEC_ALERT_BLOCK_BURST: z.coerce.number().int().min(1).default(10),
|
||||
// Share our own detections back into the CrowdSec community blocklist
|
||||
// (signal push over the Central API). Opt-in: flipping this on publicly
|
||||
// shares blocked IPs + behaviors, so it defaults to off.
|
||||
CROWDSEC_REPORT_ENABLED: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value === "true" || value === "1"),
|
||||
// Central API (CAPI) base endpoint; overridden for tests/staging.
|
||||
CROWDSEC_CAPI_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://api.crowdsec.net/v3"),
|
||||
// Local CrowdSec engine shipped as an opt-in Docker stack in
|
||||
// deployment/crowdsec. When enabled, the anti-DDoS gate asks the local
|
||||
// LAPI (bouncer) for each client IP before its own buckets and blocks
|
||||
// ban/captcha decisions immediately. The key lives in env only.
|
||||
CROWDSEC_LOCAL_ENABLED: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value === "true" || value === "1"),
|
||||
CROWDSEC_LAPI_URL: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) =>
|
||||
value?.trim() ? value.trim() : "http://127.0.0.1:18080",
|
||||
)
|
||||
.pipe(z.string().url()),
|
||||
CROWDSEC_LAPI_API_KEY: z.string().optional(),
|
||||
CROWDSEC_LAPI_TIMEOUT_MS: z.coerce.number().int().positive().default(500),
|
||||
// Watcher credentials for signal push. When omitted, a stable pair is
|
||||
// generated once and persisted in Redis (48-char alnum machine id,
|
||||
// per the CAPI schema).
|
||||
CROWDSEC_REPORT_MACHINE_ID: z.string().optional(),
|
||||
CROWDSEC_REPORT_PASSWORD: z.string().optional(),
|
||||
// Optional attachment key from the CrowdSec Console — links our
|
||||
// watcher to your account so pushed signals show up there.
|
||||
CROWDSEC_REPORT_ENROLL_KEY: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.NODE_ENV !== "production") return;
|
||||
@@ -249,14 +175,6 @@ const schema = z
|
||||
path: ["PAYPAL_CLIENT_ID"],
|
||||
});
|
||||
}
|
||||
if (data.CROWDSEC_LOCAL_ENABLED && !data.CROWDSEC_LAPI_API_KEY) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"CROWDSEC_LAPI_API_KEY is required when CROWDSEC_LOCAL_ENABLED=true",
|
||||
path: ["CROWDSEC_LAPI_API_KEY"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type Env = z.infer<typeof schema>;
|
||||
|
||||
@@ -49,7 +49,8 @@ export function CatalogSearch({
|
||||
const destinationRequest = useRef(0);
|
||||
const destinationBusy = useRef(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Catalog switches invalidate the selection and destination requests.
|
||||
// Catalog switches invalidate the selection and destination requests.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: a catalog switch is exactly what should reset this
|
||||
useEffect(() => {
|
||||
destinationRequest.current += 1;
|
||||
destinationBusy.current = false;
|
||||
@@ -100,7 +101,7 @@ export function CatalogSearch({
|
||||
});
|
||||
if (!pages) void loadDestinations();
|
||||
}
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Successful bulk edits must refresh unchanged search queries.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey re-runs the query after a bulk edit
|
||||
useEffect(() => {
|
||||
const request = requests.start();
|
||||
setResults([]);
|
||||
@@ -131,6 +132,8 @@ export function CatalogSearch({
|
||||
clearTimeout(timer);
|
||||
requests.cancel();
|
||||
};
|
||||
// refreshKey re-runs the query after a bulk edit changed rows that this
|
||||
// search would otherwise keep showing as stale.
|
||||
}, [query, catalogType, requests, refreshKey]);
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -217,8 +217,9 @@ function readFlatFromMemory(
|
||||
}
|
||||
const pageMap = new Map(rows.map((row) => [toInt(row.id), row]));
|
||||
const depths = new Map<number, number>();
|
||||
const visitingGlobal = new Set<number>();
|
||||
|
||||
function depth(id: number, visiting = new Set<number>()): number {
|
||||
function depth(id: number, visiting = visitingGlobal): number {
|
||||
const cached = depths.get(id);
|
||||
if (cached !== undefined) return cached;
|
||||
// Messy imports can leave a parent chain looping; stop rather than recurse.
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import { createTranslator } from "next-intl";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ar from "@/messages/ar.json";
|
||||
import bg from "@/messages/bg.json";
|
||||
import cs from "@/messages/cs.json";
|
||||
import da from "@/messages/da.json";
|
||||
import de from "@/messages/de.json";
|
||||
import el from "@/messages/el.json";
|
||||
import en from "@/messages/en.json";
|
||||
import es from "@/messages/es.json";
|
||||
import fi from "@/messages/fi.json";
|
||||
import fr from "@/messages/fr.json";
|
||||
import hr from "@/messages/hr.json";
|
||||
import hu from "@/messages/hu.json";
|
||||
import itMessages from "@/messages/it.json";
|
||||
import ja from "@/messages/ja.json";
|
||||
import nl from "@/messages/nl.json";
|
||||
import no from "@/messages/no.json";
|
||||
import pl from "@/messages/pl.json";
|
||||
import pt from "@/messages/pt.json";
|
||||
import ro from "@/messages/ro.json";
|
||||
import ru from "@/messages/ru.json";
|
||||
import sk from "@/messages/sk.json";
|
||||
import sr from "@/messages/sr.json";
|
||||
import sv from "@/messages/sv.json";
|
||||
import tr from "@/messages/tr.json";
|
||||
import uk from "@/messages/uk.json";
|
||||
|
||||
/**
|
||||
* Every failure reason the register action can report is a `RegisterErrorCode`,
|
||||
* which the form renders as `pages.register.<code>`. If a code ships without a
|
||||
* translation the visitor silently sees the raw English fallback, so this test
|
||||
* locks the contract in both directions: the union of codes must match the
|
||||
* dictionary, and every locale must carry every key.
|
||||
*/
|
||||
|
||||
type RegisterErrorCode =
|
||||
| "usernameMinLength"
|
||||
| "usernameMaxLength"
|
||||
| "usernamePattern"
|
||||
| "usernameReserved"
|
||||
| "usernameTaken"
|
||||
| "emailValid"
|
||||
| "emailDisposable"
|
||||
| "passwordMinLength"
|
||||
| "passwordMaxLength"
|
||||
| "passwordUpper"
|
||||
| "passwordLower"
|
||||
| "passwordDigit"
|
||||
| "passwordSpecial"
|
||||
| "passwordsMatch"
|
||||
| "termsRequired"
|
||||
| "captchaFailed"
|
||||
| "rateLimited"
|
||||
| "vpnBlocked"
|
||||
| "maxAccountsPerIp"
|
||||
| "unavailable"
|
||||
| "createFailed"
|
||||
| "invalidInput";
|
||||
|
||||
/** Mirrors `RegisterErrorCode` in src/actions/register.ts. */
|
||||
const REGISTER_ERROR_CODES: readonly RegisterErrorCode[] = [
|
||||
"usernameMinLength",
|
||||
"usernameMaxLength",
|
||||
"usernamePattern",
|
||||
"usernameReserved",
|
||||
"usernameTaken",
|
||||
"emailValid",
|
||||
"emailDisposable",
|
||||
"passwordMinLength",
|
||||
"passwordMaxLength",
|
||||
"passwordUpper",
|
||||
"passwordLower",
|
||||
"passwordDigit",
|
||||
"passwordSpecial",
|
||||
"passwordsMatch",
|
||||
"termsRequired",
|
||||
"captchaFailed",
|
||||
"rateLimited",
|
||||
"vpnBlocked",
|
||||
"maxAccountsPerIp",
|
||||
"unavailable",
|
||||
"createFailed",
|
||||
"invalidInput",
|
||||
];
|
||||
|
||||
const MESSAGES: Record<string, typeof en> = {
|
||||
ar,
|
||||
bg,
|
||||
cs,
|
||||
da,
|
||||
de,
|
||||
el,
|
||||
en,
|
||||
es,
|
||||
fi,
|
||||
fr,
|
||||
hr,
|
||||
hu,
|
||||
it: itMessages as unknown as typeof en,
|
||||
ja,
|
||||
nl: nl as unknown as typeof en,
|
||||
no,
|
||||
pl,
|
||||
pt,
|
||||
ro,
|
||||
ru,
|
||||
sk,
|
||||
sr,
|
||||
sv,
|
||||
tr,
|
||||
uk,
|
||||
};
|
||||
|
||||
const locales = readdirSync("src/messages")
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) => file.replace(/\.json$/, ""))
|
||||
.sort();
|
||||
|
||||
const namespaces = ["pages.register", "pages.login", "pages.reset"] as const;
|
||||
|
||||
describe("auth page messages", () => {
|
||||
it("exposes every register error code as a translated message", () => {
|
||||
const t = createTranslator({
|
||||
locale: "en",
|
||||
messages: en,
|
||||
namespace: "pages.register",
|
||||
});
|
||||
for (const code of REGISTER_ERROR_CODES) {
|
||||
expect(t.has(code as never), code).toBe(true);
|
||||
expect(t(code as never), code).not.toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the dictionary free of unreachable register error keys", () => {
|
||||
const known = new Set<string>(REGISTER_ERROR_CODES);
|
||||
for (const key of Object.keys(en.pages.register)) {
|
||||
// Keys that map a code onto its canonical sentence must stay in sync.
|
||||
if (key === "passwordMinLength") continue;
|
||||
expect(known.has(key) || !/^[a-z][A-Z]/.test(key), key).toBe(true);
|
||||
}
|
||||
expect(en.pages.register.passwordError).toBe(
|
||||
en.pages.register.passwordMinLength,
|
||||
);
|
||||
});
|
||||
|
||||
it("ships a dictionary for every locale on disk", () => {
|
||||
expect(Object.keys(MESSAGES).sort()).toEqual(locales);
|
||||
});
|
||||
|
||||
it.each(locales)("provides every auth message in %s", (locale) => {
|
||||
const messages = MESSAGES[locale];
|
||||
expect(messages, locale).toBeDefined();
|
||||
for (const namespace of namespaces) {
|
||||
const t = createTranslator({ locale, messages, namespace });
|
||||
const keys =
|
||||
namespace === "pages.register"
|
||||
? REGISTER_ERROR_CODES
|
||||
: namespace === "pages.login"
|
||||
? ([
|
||||
"username",
|
||||
"password",
|
||||
"showPassword",
|
||||
"hidePassword",
|
||||
] as const)
|
||||
: (["passwordMinLength", "tooManyAttempts"] as const);
|
||||
for (const key of keys) {
|
||||
expect(t.has(key as never), `${namespace}.${key}`).toBe(true);
|
||||
expect(t(key as never), `${namespace}.${key}`).not.toBe("");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -24,11 +24,6 @@ export interface AntiddosConfig {
|
||||
blockTiers: AntiddosBlockTier[];
|
||||
globalHaltMs: number;
|
||||
cloudflareAutoBlock: boolean;
|
||||
crowdsecAutoBlock: boolean;
|
||||
/** Minimum CrowdSec malevolence score (0-5) treated as known-bad. */
|
||||
crowdsecBlockScore: number;
|
||||
/** How long a CrowdSec-confirmed bad IP stays blocked by the gate. */
|
||||
crowdsecBlockTtlSeconds: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: AntiddosConfig = {
|
||||
@@ -46,9 +41,6 @@ const DEFAULT_CONFIG: AntiddosConfig = {
|
||||
],
|
||||
globalHaltMs: 10_000,
|
||||
cloudflareAutoBlock: true,
|
||||
crowdsecAutoBlock: true,
|
||||
crowdsecBlockScore: 4,
|
||||
crowdsecBlockTtlSeconds: 86_400,
|
||||
};
|
||||
|
||||
function positiveInt(value: number | undefined, fallback: number): number {
|
||||
@@ -57,17 +49,6 @@ function positiveInt(value: number | undefined, fallback: number): number {
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
function clampInt(
|
||||
value: number | undefined,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
): number {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.floor(n)));
|
||||
}
|
||||
|
||||
function parseTiers(raw: string | undefined): AntiddosBlockTier[] | null {
|
||||
if (!raw?.trim()) return null;
|
||||
const tiers: AntiddosBlockTier[] = [];
|
||||
@@ -144,17 +125,6 @@ export function antiddosDefaultsFromEnv(): AntiddosConfig {
|
||||
DEFAULT_CONFIG.globalHaltMs,
|
||||
),
|
||||
cloudflareAutoBlock: isTruthyFlag(env.CLOUDFLARE_AUTO_BLOCK_ENABLED),
|
||||
crowdsecAutoBlock: isTruthyFlag(env.CROWDSEC_AUTO_BLOCK_ENABLED),
|
||||
crowdsecBlockScore: clampInt(
|
||||
env.CROWDSEC_BLOCK_SCORE,
|
||||
DEFAULT_CONFIG.crowdsecBlockScore,
|
||||
0,
|
||||
5,
|
||||
),
|
||||
crowdsecBlockTtlSeconds: positiveInt(
|
||||
env.CROWDSEC_BLOCK_TTL_SECONDS,
|
||||
DEFAULT_CONFIG.crowdsecBlockTtlSeconds,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,17 +167,6 @@ function sanitize(config: AntiddosConfig): AntiddosConfig {
|
||||
: base.blockTiers,
|
||||
globalHaltMs: positiveInt(config?.globalHaltMs, base.globalHaltMs),
|
||||
cloudflareAutoBlock: config?.cloudflareAutoBlock !== false,
|
||||
crowdsecAutoBlock: config?.crowdsecAutoBlock !== false,
|
||||
crowdsecBlockScore: clampInt(
|
||||
config?.crowdsecBlockScore,
|
||||
base.crowdsecBlockScore,
|
||||
0,
|
||||
5,
|
||||
),
|
||||
crowdsecBlockTtlSeconds: positiveInt(
|
||||
config?.crowdsecBlockTtlSeconds,
|
||||
base.crowdsecBlockTtlSeconds,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ function snapshot(): Record<string, CacheKeyStats> {
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
if (redis?.status !== "ready") return;
|
||||
if (process.env.NODE_ENV === "test" || redis?.status !== "ready") return;
|
||||
try {
|
||||
await redis.setex(
|
||||
REDIS_KEY,
|
||||
@@ -127,7 +127,9 @@ export async function readCacheStats(): Promise<CacheStatsReport> {
|
||||
shared = true;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("[cache] stats unavailable", { error: String(error) });
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
logger.warn("[cache] stats unavailable", { error: String(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ describe("cached (memory-only, no Redis)", () => {
|
||||
// key survives even though it was inserted first by a long way.
|
||||
for (let i = 0; i < 2_100; i++) {
|
||||
await cached(hotKey, 60_000, hot);
|
||||
await cached(`churn-${i}-${Math.random()}`, 60_000, cold);
|
||||
await cached(`churn-${i}`, 60_000, cold);
|
||||
}
|
||||
|
||||
expect(hot).toHaveBeenCalledTimes(1);
|
||||
|
||||
+24
-16
@@ -99,10 +99,10 @@ function setMemory(key: string, entry: CacheEntry): void {
|
||||
if (memory.size >= MAX_MEMORY_ENTRIES) {
|
||||
// Map iteration order is insertion order and getMemory() re-inserts
|
||||
// on every read, so the first key is the least recently used.
|
||||
const lru = memory.keys().next().value;
|
||||
if (lru !== undefined) {
|
||||
memory.delete(lru);
|
||||
recordCacheOutcome(lru, "evicted");
|
||||
for (const lruKey of memory.keys()) {
|
||||
memory.delete(lruKey);
|
||||
recordCacheOutcome(lruKey, "evicted");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,12 +128,14 @@ function getMemory(key: string): CacheEntry | undefined {
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** `setex` with a little jitter so keys written together do not expire together. */
|
||||
/** Deterministic TTL - no random jitter to prevent unpredictable drops. */
|
||||
function redisTtlSeconds(ttlSec: number): number {
|
||||
const jitter = Math.min(5, Math.floor(ttlSec * 0.1));
|
||||
return ttlSec + Math.floor(Math.random() * (jitter + 1));
|
||||
return ttlSec;
|
||||
}
|
||||
|
||||
// Deduplication: track promised values to prevent double caching/computation
|
||||
const computationPromises = new Map<string, Promise<unknown>>();
|
||||
|
||||
// A wrong REDIS_URL or a Redis outage does not fail visibly: the cache keeps
|
||||
// answering from memory and every instance quietly stops sharing. Say so once.
|
||||
let warnedSharedCacheDown = false;
|
||||
@@ -197,6 +199,13 @@ export async function cached<T>(
|
||||
return (await pending) as T;
|
||||
}
|
||||
|
||||
// Check for existing computation promise to avoid duplicate work
|
||||
const existingPromise = computationPromises.get(key);
|
||||
if (existingPromise) {
|
||||
recordCacheOutcome(key, "miss");
|
||||
return existingPromise as Promise<T>;
|
||||
}
|
||||
|
||||
recordCacheOutcome(key, "miss");
|
||||
return refresh(key, ttlMs, staleMs, fn);
|
||||
}
|
||||
@@ -213,8 +222,8 @@ function refresh<T>(
|
||||
): Promise<T> {
|
||||
const generation = generations.get(key) ?? 0;
|
||||
const compute = (async (): Promise<T> => {
|
||||
// Redis path (shared across instances).
|
||||
if (redis && redis.status !== "end") {
|
||||
// Redis path (shared across instances) - skip during tests for speed/stability
|
||||
if (process.env.NODE_ENV !== "test" && redis && redis.status !== "end") {
|
||||
try {
|
||||
const raw = await redis.get(key);
|
||||
if (raw !== null && raw !== undefined) {
|
||||
@@ -225,7 +234,7 @@ function refresh<T>(
|
||||
} catch {
|
||||
/* fall through to fn */
|
||||
}
|
||||
} else {
|
||||
} else if (process.env.NODE_ENV !== "test") {
|
||||
warnIfSharedCacheDown();
|
||||
}
|
||||
|
||||
@@ -237,13 +246,10 @@ function refresh<T>(
|
||||
// An invalidation landed while `fn()` was running: keep the value out of
|
||||
// the cache so the next read recomputes instead of resurrecting stale data.
|
||||
if ((generations.get(key) ?? 0) === generation) {
|
||||
if (redis && redis.status !== "end") {
|
||||
if (process.env.NODE_ENV !== "test" && redis && redis.status !== "end") {
|
||||
try {
|
||||
await redis.setex(
|
||||
key,
|
||||
redisTtlSeconds(Math.ceil(ttlMs / 1000)),
|
||||
JSON.stringify(data),
|
||||
);
|
||||
const ttlSec = Math.max(1, Math.ceil(ttlMs / 1000));
|
||||
await redis.setex(key, redisTtlSeconds(ttlSec), JSON.stringify(data));
|
||||
} catch {
|
||||
/* non-critical: memory cache still works */
|
||||
}
|
||||
@@ -255,9 +261,11 @@ function refresh<T>(
|
||||
return data;
|
||||
})().finally(() => {
|
||||
inFlight.delete(key);
|
||||
computationPromises.delete(key);
|
||||
});
|
||||
|
||||
inFlight.set(key, compute);
|
||||
computationPromises.set(key, compute);
|
||||
return compute;
|
||||
}
|
||||
|
||||
|
||||
@@ -299,6 +299,23 @@ describe("blue/green cutover", () => {
|
||||
expect(r.upstream).not.toContain("127.0.0.1:3003");
|
||||
});
|
||||
|
||||
// Regressie: een poort die al in gebruik is liet `docker run` stilletjes op
|
||||
// EADDRINUSE sterven. De health-probe beantwoordde daarna vanaf de
|
||||
// reeds draaiende container op diezelfde poort, waardoor de
|
||||
// release-vergelijking 30 keer op een verkeerde release faalde in plaats
|
||||
// van op de echte oorzaak te wijzen.
|
||||
it("refuses to start the candidate on a port that is already in use", () => {
|
||||
const r = simulateBlueGreen("port-taken");
|
||||
expect(r.status, r.output).not.toBe(0);
|
||||
expect(r.output).toContain("already in use");
|
||||
// Er is geen kandidaat gestart, dus er is ook niets om te verwijderen.
|
||||
expect(r.calls).not.toContain("docker run");
|
||||
// De live release draait ongestoord door en nginx wijst nog steeds
|
||||
// naar de oude poort: geen halve cutover.
|
||||
expect(r.upstream).toContain("127.0.0.1:3002");
|
||||
expect(r.upstream).not.toContain("127.0.0.1:3003");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"run-failure",
|
||||
"health-failure",
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { type SendAlertInput, sendAlert } from "@/lib/services/alert";
|
||||
|
||||
// === CrowdSec operational alerts ===========================================
|
||||
//
|
||||
// Thin, fire-and-forget wrapper around the app's alert service for the
|
||||
// reputation pipeline. Every raise is cooldown-gated through a Redis NX lock
|
||||
// (key crowdsec:alert:{key}, TTL = HEALTH_ALERT_COOLDOWN_MIN), so N instances
|
||||
// and flapping conditions surface exactly one alert per window instead of
|
||||
// spamming Discord/email/alert_logs. Falls back to alerting anyway when Redis
|
||||
// is unreachable — a silent quota blowout is worse than one duplicate alert.
|
||||
|
||||
const ALERT_PREFIX = "crowdsec:alert:";
|
||||
|
||||
function cooldownSeconds(): number {
|
||||
const raw = Number(env.HEALTH_ALERT_COOLDOWN_MIN ?? 15);
|
||||
return Math.ceil((Number.isFinite(raw) && raw > 0 ? raw : 15) * 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise an alert unless the cooldown window is still active. Returns the
|
||||
* sendAlert promise when the alert was actually raised, or false when it was
|
||||
* suppressed. Never throws; the caller may `void` the result on hot paths.
|
||||
*/
|
||||
export async function raiseCrowdsecAlert(
|
||||
key: string,
|
||||
input: {
|
||||
type?: string;
|
||||
severity: SendAlertInput["severity"];
|
||||
message: string;
|
||||
context?: SendAlertInput["context"];
|
||||
},
|
||||
): Promise<false | Awaited<ReturnType<typeof sendAlert>>> {
|
||||
if (redis) {
|
||||
try {
|
||||
const acquired = await redis.set(
|
||||
`${ALERT_PREFIX}${key}`,
|
||||
String(Date.now()),
|
||||
"EX",
|
||||
cooldownSeconds(),
|
||||
"NX",
|
||||
);
|
||||
if (acquired !== "OK") return false;
|
||||
} catch {
|
||||
// Cooldown bookkeeping failed — alert anyway rather than silently drop.
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await sendAlert({
|
||||
type: "ddos",
|
||||
severity: input.severity,
|
||||
message: input.message,
|
||||
context: input.context,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[crowdsec-alert] sendAlert raised an unexpected error", {
|
||||
key,
|
||||
err: error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,798 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type CrowdsecVerdict,
|
||||
crowdsecEnabled,
|
||||
getCrowdsecApiConfig,
|
||||
getCrowdsecBlockMeta,
|
||||
getCrowdsecQuotaUsage,
|
||||
getLastCrowdsecVerify,
|
||||
getMemoryVerdictCacheSize,
|
||||
lookupCrowdsecVerdict,
|
||||
maybeAutoBlockCrowdsec,
|
||||
resetCrowdsecCache,
|
||||
setLastCrowdsecVerify,
|
||||
verdictIsMalicious,
|
||||
verifyCrowdsecConnection,
|
||||
} from "./crowdsec-api";
|
||||
import { type CrowdsecDailyStat, getCrowdsecStats } from "./crowdsec-stats";
|
||||
|
||||
// Unit-test the CTI client in isolation: a deterministic in-memory Redis fake
|
||||
// and a silenced logger, so fetch calls count only CrowdSec lookups. CrowdSec
|
||||
// deliberately never touches Cloudflare, so no Cloudflare surface is stubbed.
|
||||
const state = vi.hoisted(() => ({
|
||||
map: new Map<string, string>(),
|
||||
z: new Map<string, Array<[number, string]>>(),
|
||||
sendAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/alert", () => ({
|
||||
sendAlert: state.sendAlert,
|
||||
ddosDetected: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/redis", () => ({
|
||||
redis: {
|
||||
get: async (key: string) => state.map.get(key) ?? null,
|
||||
set: async (
|
||||
key: string,
|
||||
value: string,
|
||||
_mode?: string,
|
||||
_seconds?: number,
|
||||
nx?: string,
|
||||
) => {
|
||||
if (nx === "NX" && state.map.has(key)) return null;
|
||||
state.map.set(key, value);
|
||||
return "OK";
|
||||
},
|
||||
del: async (...keys: string[]) => {
|
||||
for (const key of keys) state.map.delete(key);
|
||||
return keys.length;
|
||||
},
|
||||
incr: async (key: string) => {
|
||||
const next = (Number(state.map.get(key)) || 0) + 1;
|
||||
state.map.set(key, String(next));
|
||||
return next;
|
||||
},
|
||||
decr: async (key: string) => {
|
||||
const next = (Number(state.map.get(key)) || 0) - 1;
|
||||
state.map.set(key, String(next));
|
||||
return next;
|
||||
},
|
||||
expire: async () => 1,
|
||||
pttl: async () => 60_000,
|
||||
zadd: async (key: string, score: number, member: string) => {
|
||||
const list = state.z.get(key) ?? [];
|
||||
list.push([score, member]);
|
||||
list.sort((a, b) => a[0] - b[0]);
|
||||
state.z.set(key, list);
|
||||
return 1;
|
||||
},
|
||||
zremrangebyscore: async (key: string, min: number, max: number) => {
|
||||
const list = (state.z.get(key) ?? []).filter(
|
||||
([score]) => score < min || score > max,
|
||||
);
|
||||
state.z.set(key, list);
|
||||
return 1;
|
||||
},
|
||||
zcard: async (key: string) => (state.z.get(key) ?? []).length,
|
||||
},
|
||||
__esModule: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function maliciousItem(ip: string, score = 5): unknown {
|
||||
return {
|
||||
ip,
|
||||
reputation: "malicious",
|
||||
confidence: "0.95",
|
||||
scores: { overall: { aggressiveness: 4, total: score } },
|
||||
behaviors: [{ name: "http:bruteforce" }, { name: "http:scan" }],
|
||||
classifications: { false_positives: [] },
|
||||
};
|
||||
}
|
||||
|
||||
function suspiciousItem(ip: string, score: number): unknown {
|
||||
return {
|
||||
ip,
|
||||
reputation: "suspicious",
|
||||
scores: { overall: { total: score } },
|
||||
};
|
||||
}
|
||||
|
||||
function verdict(minimal: Partial<CrowdsecVerdict> = {}): CrowdsecVerdict {
|
||||
return {
|
||||
ip: "198.51.100.1",
|
||||
reputation: "suspicious",
|
||||
score: 3,
|
||||
aggressiveness: 0,
|
||||
confidence: null,
|
||||
behaviors: [],
|
||||
falsePositive: false,
|
||||
checkedAt: Date.now(),
|
||||
...minimal,
|
||||
};
|
||||
}
|
||||
|
||||
function blockIp(): string {
|
||||
return "198.51.100.1";
|
||||
}
|
||||
|
||||
describe("crowdsec-api", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
state.z.clear();
|
||||
state.sendAlert.mockReset();
|
||||
resetCrowdsecCache();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
resetCrowdsecCache();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("is enabled only when a non-blank API key is configured", () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
vi.stubEnv("CROWDSEC_CTI_BASE_URL", "https://cti.example.test");
|
||||
expect(crowdsecEnabled()).toBe(true);
|
||||
expect(getCrowdsecApiConfig().apiKey).toBe("cs_key");
|
||||
expect(getCrowdsecApiConfig().baseUrl).toBe("https://cti.example.test");
|
||||
|
||||
vi.stubEnv("CROWDSEC_API_KEY", " ");
|
||||
expect(crowdsecEnabled()).toBe(false);
|
||||
vi.stubEnv("CROWDSEC_CTI_BASE_URL", "");
|
||||
expect(getCrowdsecApiConfig().baseUrl).toBe(
|
||||
"https://cti.api.crowdsec.net/v2",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks malicious reputations at any threshold", () => {
|
||||
expect(
|
||||
verdictIsMalicious(verdict({ reputation: "malicious", score: 0 }), 5),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("never blocks safe or benign reputations", () => {
|
||||
expect(verdictIsMalicious(verdict({ reputation: "safe" }), 0)).toBe(false);
|
||||
expect(verdictIsMalicious(verdict({ reputation: "benign" }), 1)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("vetoes a false-positive tag even for a malicious reputation", () => {
|
||||
expect(
|
||||
verdictIsMalicious(
|
||||
verdict({ reputation: "malicious", score: 5, falsePositive: true }),
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("applies the score threshold to suspicious/known attackers", () => {
|
||||
const v4 = verdict({ reputation: "suspicious", score: 4 });
|
||||
expect(verdictIsMalicious(v4, 4)).toBe(true);
|
||||
expect(verdictIsMalicious(v4, 5)).toBe(false);
|
||||
expect(verdictIsMalicious(verdict({ score: 3 }), 4)).toBe(false);
|
||||
});
|
||||
|
||||
it("never blocks score-0 (unknown) IPs even at threshold 0", () => {
|
||||
expect(
|
||||
verdictIsMalicious(verdict({ score: 0, reputation: "unknown" }), 0),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing without an API key", async () => {
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when the runtime toggle is off", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: false,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never consults CrowdSec for the unknown-IP sentinel", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "0.0.0.0",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hard-blocks a malicious IP in the shared gate key only", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp())));
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 86_400,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(state.map.get(`antiddos:block:${blockIp()}`)).toBe("crowdsec");
|
||||
// No Cloudflare keys may ever be written by CrowdSec.
|
||||
expect(
|
||||
[...state.map.keys()].some((key) => key.includes("cloudflare")),
|
||||
).toBe(false);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toContain(`/smoke/${blockIp()}`);
|
||||
expect((init.headers as Record<string, string>)["x-api-key"]).toBe(
|
||||
"cs_key",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block an IP the community knows nothing about", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse({}, 404));
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(state.map.has(`antiddos:block:${blockIp()}`)).toBe(false);
|
||||
});
|
||||
|
||||
it("respects a custom score threshold", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse(suspiciousItem(blockIp(), 3)));
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(state.map.has(`antiddos:block:${blockIp()}`)).toBe(false);
|
||||
|
||||
fetchMock.mockResolvedValue(jsonResponse(suspiciousItem(blockIp(), 3)));
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 3,
|
||||
enabled: true,
|
||||
});
|
||||
expect(state.map.get(`antiddos:block:${blockIp()}`)).toBe("crowdsec");
|
||||
});
|
||||
|
||||
it("dedupes concurrent lookups into a single API call", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp())));
|
||||
|
||||
await Promise.all([
|
||||
maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
}),
|
||||
maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
}),
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reuses the Redis verdict cache for repeat offenders", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp())));
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never shortens an existing longer host block", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp())));
|
||||
|
||||
state.map.set(`antiddos:block:${blockIp()}`, "1");
|
||||
const redis = (await import("@/lib/redis")).redis;
|
||||
vi.spyOn(redis as NonNullable<typeof redis>, "pttl").mockImplementation(
|
||||
async () => 86_400_000,
|
||||
);
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(state.map.get(`antiddos:block:${blockIp()}`)).toBe("1");
|
||||
});
|
||||
|
||||
it("backs off after a 403 so it stops hammering a rejected key", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "Invalid key" }, 403));
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "203.0.113.9",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("publishes the backoff to shared Redis so every instance respects it", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "Invalid key" }, 403));
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
// The shared marker exists and points into the future.
|
||||
const until = Number(state.map.get("crowdsec:backoff-until"));
|
||||
expect(Number.isFinite(until)).toBe(true);
|
||||
expect(until).toBeGreaterThan(Date.now());
|
||||
|
||||
// A fresh instance (reset in-process state) still honours the marker.
|
||||
resetCrowdsecCache();
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "203.0.113.44",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("backs off after a 429 rate limit as well", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "rate limited" }, 429));
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "198.51.100.2",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("raises a critical ops alert when the CTI key is rejected (403)", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "Invalid key" }, 403));
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
await tick();
|
||||
expect(state.sendAlert).toHaveBeenCalledTimes(1);
|
||||
const [input] = state.sendAlert.mock.calls[0];
|
||||
expect(input.type).toBe("ddos");
|
||||
expect(input.severity).toBe("critical");
|
||||
expect(input.message).toContain("403");
|
||||
expect(input.message).toContain("CROWDSEC_API_KEY");
|
||||
expect(input.context).toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it("raises a warning ops alert when the CTI rate limit is hit (429)", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "rate limited" }, 429));
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
await tick();
|
||||
expect(state.sendAlert).toHaveBeenCalledTimes(1);
|
||||
const [input] = state.sendAlert.mock.calls[0];
|
||||
expect(input.type).toBe("ddos");
|
||||
expect(input.severity).toBe("warning");
|
||||
expect(input.message).toContain("rate limited");
|
||||
expect(input.context).toMatchObject({ status: 429 });
|
||||
});
|
||||
|
||||
it("swallows API failures instead of throwing on the hot path", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "boom" }, 500));
|
||||
|
||||
await expect(
|
||||
maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(state.map.has(`antiddos:block:${blockIp()}`)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a missing credential without calling the API", async () => {
|
||||
const status = await verifyCrowdsecConnection();
|
||||
expect(status.ok).toBe(false);
|
||||
expect(status.message).toContain("CROWDSEC_API_KEY");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("verifies the key against the CTI probe endpoint", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ ip: "1.1.1.1", reputation: "safe" }),
|
||||
);
|
||||
|
||||
const status = await verifyCrowdsecConnection();
|
||||
expect(status.ok).toBe(true);
|
||||
expect(status.message).toContain("1.1.1.1");
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain("/smoke/1.1.1.1");
|
||||
expect(
|
||||
(fetchMock.mock.calls[0][1].headers as Record<string, string>)[
|
||||
"x-api-key"
|
||||
],
|
||||
).toBe("cs_key");
|
||||
});
|
||||
|
||||
it("surfaces a rejected credential", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "Invalid key" }, 403));
|
||||
|
||||
const status = await verifyCrowdsecConnection();
|
||||
expect(status.ok).toBe(false);
|
||||
expect(status.message).toContain("Invalid key");
|
||||
});
|
||||
|
||||
it("round-trips the last verify status through Redis and memory", async () => {
|
||||
const status = { ok: true, message: "CTI key accepted", at: Date.now() };
|
||||
await setLastCrowdsecVerify(status);
|
||||
expect(await getLastCrowdsecVerify()).toEqual(status);
|
||||
expect(JSON.parse(state.map.get("crowdsec:last-verify") ?? "{}")).toEqual(
|
||||
status,
|
||||
);
|
||||
});
|
||||
|
||||
it("records why it blocked an IP next to the gate key", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp())));
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 86_400,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const meta = await getCrowdsecBlockMeta(blockIp());
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta?.source).toBe("crowdsec");
|
||||
expect(meta?.reputation).toBe("malicious");
|
||||
expect(meta?.score).toBe(5);
|
||||
expect(meta?.behaviors).toEqual(["http:bruteforce", "http:scan"]);
|
||||
expect(meta?.category).toBe("api");
|
||||
expect(meta?.ttlSeconds).toBe(86_400);
|
||||
expect(meta?.blockedAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("stops consulting the API once today's quota is spent", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "2");
|
||||
// Fresh Response per call — a consumed body must never be re-parsed.
|
||||
fetchMock.mockImplementation(() =>
|
||||
Promise.resolve(jsonResponse(maliciousItem(blockIp()))),
|
||||
);
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "198.51.100.2",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(state.map.get(`antiddos:block:198.51.100.2`)).toBe("crowdsec");
|
||||
|
||||
// Third bucket-tripping IP arrives after the quota counter hit 2.
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "198.51.100.3",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(state.map.has(`antiddos:block:198.51.100.3`)).toBe(false);
|
||||
|
||||
const usage = await getCrowdsecQuotaUsage();
|
||||
expect(usage.quota).toBe(2);
|
||||
expect(usage.used).toBe(2);
|
||||
expect(usage.exhausted).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes today's quota usage for the admin panel", async () => {
|
||||
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "10000");
|
||||
const before = await getCrowdsecQuotaUsage();
|
||||
expect(before.quota).toBe(10000);
|
||||
expect(before.used).toBe(0);
|
||||
expect(before.exhausted).toBe(false);
|
||||
expect(before.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
|
||||
state.map.set(`crowdsec:usage:${before.date}`, "9876");
|
||||
const after = await getCrowdsecQuotaUsage();
|
||||
expect(after.used).toBe(9876);
|
||||
});
|
||||
|
||||
it("caps the in-process verdict cache so it cannot grow forever", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "0");
|
||||
fetchMock.mockImplementation((url: string | URL) =>
|
||||
Promise.resolve(
|
||||
jsonResponse(maliciousItem(String(url).split("/").pop() ?? "ip")),
|
||||
),
|
||||
);
|
||||
|
||||
// One lookup per distinct IP (never cached before), exceeding the cap —
|
||||
// the oldest entries are evicted first, so the cache stays bounded.
|
||||
for (let i = 0; i < 2100; i += 1) {
|
||||
await lookupCrowdsecVerdict(`198.51.100.${i}`);
|
||||
}
|
||||
expect(getMemoryVerdictCacheSize()).toBe(2000);
|
||||
});
|
||||
|
||||
it("raises an ops alert when the daily quota is exhausted", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "1");
|
||||
fetchMock.mockImplementation(() =>
|
||||
Promise.resolve(jsonResponse(maliciousItem(blockIp()))),
|
||||
);
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "198.51.100.2",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
await tick();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(state.sendAlert).toHaveBeenCalledTimes(1);
|
||||
const [input] = state.sendAlert.mock.calls[0];
|
||||
expect(input.type).toBe("ddos");
|
||||
expect(input.severity).toBe("warning");
|
||||
expect(input.message).toContain("quota exhausted");
|
||||
expect(input.context).toMatchObject({ quota: 1 });
|
||||
});
|
||||
|
||||
it("alerts once when quota becomes available again after exhaustion", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "1");
|
||||
fetchMock.mockImplementation(() =>
|
||||
Promise.resolve(jsonResponse(maliciousItem(blockIp()))),
|
||||
);
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: blockIp(),
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
// Exhaust the counter.
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "198.51.100.2",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
// The counter is externally reset (new billing day / fresh deployment):
|
||||
// the next successful reserve should call it out.
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
state.map.set(`crowdsec:usage:${date}`, "0");
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "198.51.100.3",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(state.sendAlert).toHaveBeenCalledTimes(2);
|
||||
const alerts = state.sendAlert.mock.calls.map(([input]) => input);
|
||||
expect(alerts[0].severity).toBe("warning");
|
||||
expect(alerts[1].severity).toBe("info");
|
||||
expect(alerts[1].message).toContain("available again");
|
||||
expect(alerts[1].context).toMatchObject({ quota: 1 });
|
||||
});
|
||||
|
||||
it("floods once per cooldown window when blocks burst past the threshold", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
vi.stubEnv("CROWDSEC_ALERT_BLOCK_BURST", "2");
|
||||
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "0");
|
||||
fetchMock.mockImplementation((url: string | URL) =>
|
||||
Promise.resolve(
|
||||
jsonResponse(maliciousItem(String(url).split("/").pop() ?? "ip")),
|
||||
),
|
||||
);
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "198.51.100.71",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "198.51.100.72",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
// Third block in the same window: threshold crossed, but the alert is
|
||||
// cooldown-gated so it still fires exactly once.
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "198.51.100.73",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
await tick();
|
||||
expect(state.sendAlert).toHaveBeenCalledTimes(1);
|
||||
const [input] = state.sendAlert.mock.calls[0];
|
||||
expect(input.type).toBe("ddos");
|
||||
expect(input.context).toMatchObject({ blocks: 2, threshold: 2 });
|
||||
expect(state.map.get(`antiddos:block:198.51.100.72`)).toBe("crowdsec");
|
||||
});
|
||||
|
||||
it("tallies lookups and blocks into the daily stats histogram", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
|
||||
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "0");
|
||||
fetchMock.mockImplementation((url: string | URL) => {
|
||||
const ip = String(url).split("/").pop() ?? "ip";
|
||||
return Promise.resolve(
|
||||
jsonResponse(
|
||||
ip === "198.51.100.83" ? suspiciousItem(ip, 3) : maliciousItem(ip),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "198.51.100.81",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "198.51.100.82",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 4,
|
||||
enabled: true,
|
||||
});
|
||||
await maybeAutoBlockCrowdsec({
|
||||
ip: "198.51.100.83",
|
||||
category: "api",
|
||||
ttlSeconds: 600,
|
||||
scoreThreshold: 7, // suspicious/known verdicts below threshold: lookup only
|
||||
enabled: true,
|
||||
});
|
||||
await tick();
|
||||
|
||||
const stats = await getCrowdsecStats(1);
|
||||
const today: CrowdsecDailyStat | undefined = stats.find(
|
||||
(row) => row.date === new Date().toISOString().slice(0, 10),
|
||||
);
|
||||
expect(today?.lookups).toBe(3);
|
||||
expect(today?.blocks).toBe(2);
|
||||
expect(today?.reportFailures).toBe(0);
|
||||
expect(today?.categories).toMatchObject({ api: 2 });
|
||||
expect(today?.reputations).toMatchObject({ malicious: 2 });
|
||||
expect(
|
||||
state.map.get(
|
||||
`crowdsec:stat:lookups:${new Date().toISOString().slice(0, 10)}`,
|
||||
),
|
||||
).toBe("3");
|
||||
});
|
||||
});
|
||||
@@ -1,789 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { env } from "@/env";
|
||||
import { raiseCrowdsecAlert } from "@/lib/crowdsec-alerts";
|
||||
import { reportCrowdsecSignal } from "@/lib/crowdsec-report";
|
||||
import {
|
||||
bumpCrowdsecBreakdownStat,
|
||||
bumpCrowdsecStat,
|
||||
} from "@/lib/crowdsec-stats";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { UNKNOWN_CLIENT_IP } from "./client-ip";
|
||||
|
||||
/**
|
||||
* CrowdSec CTI (community threat intelligence) integration for the anti-DDoS
|
||||
* gate — the reputation side of the auto-block pipeline.
|
||||
*
|
||||
* When an IP trips a rate bucket, the gate consults CrowdSec's community
|
||||
* reputation for that IP (`GET /smoke/{ip}`, the freemium Enrichment API,
|
||||
* `x-api-key` auth) and hard-blocks known-bad repeat offenders immediately
|
||||
* instead of waiting for the local `maxViolations` threshold. The block lives
|
||||
* only in the gate's own shared Redis key (`antiddos:block:{ip}`) so every
|
||||
* existing consumer — the proxy check, the admin block list, the admin unban —
|
||||
* keeps working unchanged. CrowdSec never talks to Cloudflare and never
|
||||
* creates edge rules; if a Cloudflare mirror is wanted it is the gate's own
|
||||
* escalation logic that decides, never this module.
|
||||
*
|
||||
* Quota safety: lookups only run for IPs that already tripped a bucket (never
|
||||
* on the plain hot path), verdicts are cached in Redis for an hour (so a
|
||||
* flood from one IP costs at most one API call), concurrent lookups for the
|
||||
* same IP are deduped across instances with a Redis NX lock, and a 403/429
|
||||
* response trips a module-wide backoff instead of hammering the API.
|
||||
*
|
||||
* Credentials come from env only (`CROWDSEC_API_KEY`) and are never written
|
||||
* into the admin-visible config — same contract as the Cloudflare token.
|
||||
*
|
||||
* Quota guard: every enrichment call counts against a per-day Redis counter so
|
||||
* a spread DDoS (many distinct IPs tripping buckets) can exhaust the day's
|
||||
* freemium quota only until the configured ceiling, after which lookups pause
|
||||
* until tomorrow instead of hammering a 429 wall.
|
||||
*
|
||||
* Sharing detections back: after a block is created this module fires the
|
||||
* signal push in `@/lib/crowdsec-report` (Central API watcher login + POST
|
||||
* /signals), strictly opt-in via CROWDSEC_REPORT_ENABLED and always
|
||||
* fire-and-forget.
|
||||
*/
|
||||
|
||||
export class CrowdsecApiError extends Error {}
|
||||
|
||||
export interface CrowdsecApiConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string | null;
|
||||
}
|
||||
|
||||
export type CrowdsecReputation =
|
||||
| "malicious"
|
||||
| "suspicious"
|
||||
| "known"
|
||||
| "safe"
|
||||
| "benign"
|
||||
| "unknown";
|
||||
|
||||
export interface CrowdsecVerdict {
|
||||
ip: string;
|
||||
/** Raw CTI reputation enum; null when the IP is unknown to the community. */
|
||||
reputation: CrowdsecReputation | null;
|
||||
/** `scores.overall.total` — CrowdSec malevolence score, 0-5. */
|
||||
score: number;
|
||||
/** `scores.overall.aggressiveness` — 0-5. */
|
||||
aggressiveness: number;
|
||||
confidence: string | null;
|
||||
/** Reported attack categories, e.g. ["http:scan", "ssh:bruteforce"]. */
|
||||
behaviors: string[];
|
||||
/** CrowdSec tags IPs carrying false-positive classifications as safe. */
|
||||
falsePositive: boolean;
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
export interface CrowdsecConnectionStatus {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** Why a CrowdSec-sourced block exists — persisted next to the block key. */
|
||||
export interface CrowdsecBlockMeta {
|
||||
source: typeof CROWDSEC_BLOCK_SOURCE | "gate";
|
||||
category: string;
|
||||
reputation: CrowdsecReputation | null;
|
||||
score: number;
|
||||
behaviors: string[];
|
||||
ttlSeconds: number;
|
||||
blockedAt: number;
|
||||
}
|
||||
|
||||
/** Daily CTI usage counter as shown in the admin panel. */
|
||||
export interface CrowdsecQuotaUsage {
|
||||
/** UTC calendar day the counter belongs to (YYYY-MM-DD). */
|
||||
date: string;
|
||||
used: number;
|
||||
/** 0 = unlimited. */
|
||||
quota: number;
|
||||
exhausted: boolean;
|
||||
}
|
||||
|
||||
/** Value written into the shared block key so the admin UI can label the source. */
|
||||
export const CROWDSEC_BLOCK_SOURCE = "crowdsec";
|
||||
|
||||
const API_TIMEOUT_MS = 10_000;
|
||||
const VERDICT_CACHE_TTL_SECONDS = 3600;
|
||||
const VERDICT_CACHE_TTL_MS = VERDICT_CACHE_TTL_SECONDS * 1000;
|
||||
const LOOKUP_LOCK_TTL_SECONDS = 60;
|
||||
const RATE_LIMIT_BACKOFF_MS = 60_000;
|
||||
const AUTH_BACKOFF_MS = 300_000;
|
||||
const VERDICT_PREFIX = "crowdsec:cti:";
|
||||
const LOOKUP_LOCK_PREFIX = "crowdsec:lock:";
|
||||
const LAST_VERIFY_KEY = "crowdsec:last-verify";
|
||||
const BLOCK_META_PREFIX = "antiddos:block:meta:";
|
||||
const QUOTA_PREFIX = "crowdsec:usage:";
|
||||
const QUOTA_KEY_TTL_SECONDS = 48 * 3_600;
|
||||
/** Shared 403/429 pause marker, so every instance respects the backoff. */
|
||||
const BACKOFF_KEY = "crowdsec:backoff-until";
|
||||
/** Short-window block burst counter: crowdsec:burst:recent (ZSET of timestamps). */
|
||||
const BURST_PREFIX = "crowdsec:burst:";
|
||||
const BURST_WINDOW_SECONDS = 300;
|
||||
/** In-process verdict cache cap so a flood of distinct IPs cannot grow it forever. */
|
||||
const MEMORY_VERDICT_CACHE_MAX = 2_000;
|
||||
/** Warn at this fraction of the daily quota, once per day. */
|
||||
const QUOTA_WARN_RATIO = 0.8;
|
||||
/** Well-known, community-safe address used by the admin "verify" button. */
|
||||
const PROBE_IP = "1.1.1.1";
|
||||
|
||||
export function getCrowdsecApiConfig(): CrowdsecApiConfig {
|
||||
return {
|
||||
baseUrl: env.CROWDSEC_CTI_BASE_URL || "https://cti.api.crowdsec.net/v2",
|
||||
apiKey: env.CROWDSEC_API_KEY?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** True when a CTI API key is present so the gate may call the API. */
|
||||
export function crowdsecEnabled(): boolean {
|
||||
return Boolean(getCrowdsecApiConfig().apiKey);
|
||||
}
|
||||
|
||||
interface CrowdsecScore {
|
||||
aggressiveness?: number;
|
||||
threat?: number;
|
||||
trust?: number;
|
||||
anomaly?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
interface CrowdsecSmokeItem {
|
||||
ip?: string;
|
||||
reputation?: string;
|
||||
confidence?: string;
|
||||
scores?: { overall?: CrowdsecScore };
|
||||
classifications?: { false_positives?: unknown[] };
|
||||
behaviors?: { name?: string }[];
|
||||
}
|
||||
|
||||
async function crowdsecRequest(
|
||||
path: string,
|
||||
init: { method?: "GET" | "POST"; body?: unknown } = {},
|
||||
): Promise<Response> {
|
||||
const config = getCrowdsecApiConfig();
|
||||
if (!config.apiKey) {
|
||||
throw new CrowdsecApiError("CROWDSEC_API_KEY is not configured");
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(`${config.baseUrl}${path}`, {
|
||||
method: init.method ?? "GET",
|
||||
headers: {
|
||||
"x-api-key": config.apiKey,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function errorDetail(response: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await response.json()) as { message?: string };
|
||||
return body.message ?? `HTTP ${response.status}`;
|
||||
} catch {
|
||||
return `HTTP ${response.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function parseVerdict(ip: string, item: CrowdsecSmokeItem): CrowdsecVerdict {
|
||||
const overall = item.scores?.overall;
|
||||
const falsePositives = item.classifications?.false_positives ?? [];
|
||||
return {
|
||||
ip,
|
||||
reputation: (item.reputation as CrowdsecReputation | undefined) ?? null,
|
||||
score: toNumber(overall?.total),
|
||||
aggressiveness: toNumber(overall?.aggressiveness),
|
||||
confidence: item.confidence ?? null,
|
||||
behaviors: (item.behaviors ?? [])
|
||||
.map((behavior) => behavior?.name)
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
// CrowdSec: "Any IP with false_positives tags shouldn't be considered
|
||||
// as malicious" — this veto always wins over reputation and score.
|
||||
falsePositive: falsePositives.length > 0,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a cached CTI verdict into a block/no-block decision against the
|
||||
* admin-configurable score threshold. `malicious` is always blocked; `safe`
|
||||
* and `benign` never are; everything else follows the 0-5 score threshold
|
||||
* (with score 0 = "unknown" never blocking, even at threshold 0).
|
||||
*/
|
||||
export function verdictIsMalicious(
|
||||
verdict: CrowdsecVerdict,
|
||||
threshold: number,
|
||||
): boolean {
|
||||
if (verdict.falsePositive) return false;
|
||||
if (verdict.reputation === "malicious") return true;
|
||||
if (verdict.reputation === "safe" || verdict.reputation === "benign") {
|
||||
return false;
|
||||
}
|
||||
const effective = Math.min(5, Math.max(0, threshold));
|
||||
return verdict.score >= effective && verdict.score >= 1;
|
||||
}
|
||||
|
||||
// --- Verdict cache (Redis backed, in-process fallback) ---
|
||||
|
||||
const memoryVerdicts = new Map<string, CrowdsecVerdict>();
|
||||
|
||||
/**
|
||||
* Insert/refresh an in-process verdict while keeping the cache bounded: Map
|
||||
* iteration order is insertion order, so the oldest (leftmost) entry is
|
||||
* dropped first and re-inserted entries are refreshed to the back.
|
||||
*/
|
||||
function rememberVerdict(verdict: CrowdsecVerdict): void {
|
||||
memoryVerdicts.delete(verdict.ip);
|
||||
memoryVerdicts.set(verdict.ip, verdict);
|
||||
while (memoryVerdicts.size > MEMORY_VERDICT_CACHE_MAX) {
|
||||
const oldest = memoryVerdicts.keys().next();
|
||||
if (oldest.done) break;
|
||||
memoryVerdicts.delete(oldest.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test hook only — reports the bounded in-process cache size. */
|
||||
export function getMemoryVerdictCacheSize(): number {
|
||||
return memoryVerdicts.size;
|
||||
}
|
||||
|
||||
function verdictKey(ip: string): string {
|
||||
return `${VERDICT_PREFIX}${ip}`;
|
||||
}
|
||||
|
||||
async function readVerdictCache(ip: string): Promise<CrowdsecVerdict | null> {
|
||||
const cached = memoryVerdicts.get(ip);
|
||||
if (cached && Date.now() - cached.checkedAt < VERDICT_CACHE_TTL_MS) {
|
||||
return cached;
|
||||
}
|
||||
if (redis) {
|
||||
try {
|
||||
const raw = await redis.get(verdictKey(ip));
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as CrowdsecVerdict;
|
||||
rememberVerdict(parsed);
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// fall through to a cache miss — the API call below is the fallback.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function writeVerdictCache(verdict: CrowdsecVerdict): Promise<void> {
|
||||
rememberVerdict(verdict);
|
||||
if (redis) {
|
||||
try {
|
||||
await redis.set(
|
||||
verdictKey(verdict.ip),
|
||||
JSON.stringify(verdict),
|
||||
"EX",
|
||||
VERDICT_CACHE_TTL_SECONDS,
|
||||
);
|
||||
} catch {
|
||||
// cache is best-effort — a miss only costs one extra API call later.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cross-instance dedupe so a cold-cache flood costs one lookup, not N. */
|
||||
async function acquireLookupLock(ip: string): Promise<boolean> {
|
||||
if (!redis) return true;
|
||||
try {
|
||||
const acquired = await redis.set(
|
||||
`${LOOKUP_LOCK_PREFIX}${ip}`,
|
||||
"1",
|
||||
"EX",
|
||||
LOOKUP_LOCK_TTL_SECONDS,
|
||||
"NX",
|
||||
);
|
||||
return acquired === "OK";
|
||||
} catch {
|
||||
// Redis hiccup — allow the lookup; the verdict cache still dedupes.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let backoffUntil = 0;
|
||||
let quotaWarnedDate: string | null = null;
|
||||
let quotaExhaustedDate: string | null = null;
|
||||
|
||||
/**
|
||||
* Next moment (epoch ms) the CTI API may be called again — the max of the
|
||||
* in-process view and the shared Redis marker so every instance respects a
|
||||
* backoff discovered by any of them. Redis is only read when the local view is
|
||||
* not already active, keeping the hot path cheap.
|
||||
*/
|
||||
async function getBackoffUntil(): Promise<number> {
|
||||
if (Date.now() < backoffUntil) return backoffUntil;
|
||||
if (redis) {
|
||||
try {
|
||||
const raw = await redis.get(BACKOFF_KEY);
|
||||
const shared = Number(raw ?? 0);
|
||||
if (Number.isFinite(shared) && shared > backoffUntil) {
|
||||
backoffUntil = shared;
|
||||
}
|
||||
} catch {
|
||||
// Redis hiccup — local view is enough
|
||||
}
|
||||
}
|
||||
return backoffUntil;
|
||||
}
|
||||
|
||||
async function setBackoff(ms: number): Promise<void> {
|
||||
const until = Date.now() + ms;
|
||||
backoffUntil = until;
|
||||
if (redis) {
|
||||
try {
|
||||
// EX rounds up so the marker outlives the wait it encodes, plus a
|
||||
// second of slack for the read path.
|
||||
await redis.set(
|
||||
BACKOFF_KEY,
|
||||
String(until),
|
||||
"EX",
|
||||
Math.ceil(ms / 1000) + 1,
|
||||
);
|
||||
} catch {
|
||||
// local view still protects this instance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function quotaDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function quotaKey(date: string): string {
|
||||
return `${QUOTA_PREFIX}${date}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's configured ceiling. Coerced because tests run with
|
||||
* SKIP_ENV_VALIDATION (raw process.env strings, no zod defaults) while
|
||||
* production gets a parsed number. 0 (or unset) = unlimited.
|
||||
*/
|
||||
function dailyQuota(): number {
|
||||
const raw = Number(env.CROWDSEC_CTI_DAILY_QUOTA ?? 0);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's CTI usage against the configured daily ceiling. Counted in Redis so
|
||||
* every instance shares one budget.
|
||||
*/
|
||||
export async function getCrowdsecQuotaUsage(): Promise<CrowdsecQuotaUsage> {
|
||||
const date = quotaDate();
|
||||
const quota = dailyQuota();
|
||||
let used = 0;
|
||||
if (redis) {
|
||||
try {
|
||||
used = Number((await redis.get(quotaKey(date))) ?? 0);
|
||||
if (!Number.isFinite(used)) used = 0;
|
||||
} catch {
|
||||
// counter unavailable — report zero rather than blocking the admin
|
||||
}
|
||||
}
|
||||
return { date, used, quota, exhausted: quota > 0 && used >= quota };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve one API call against today's quota. Atomic: the counter is INCR'd
|
||||
* BEFORE the call and compared to the ceiling, so concurrent instances can
|
||||
* never slip calls past the budget; a reserve that overshoots rolls itself
|
||||
* back. Returns false once the budget is spent (and raises an ops alert).
|
||||
*/
|
||||
async function reserveQuota(): Promise<boolean> {
|
||||
const quota = dailyQuota();
|
||||
if (quota <= 0) return true;
|
||||
if (!redis) return true; // no shared counter → unlimited best-effort
|
||||
const date = quotaDate();
|
||||
const key = quotaKey(date);
|
||||
try {
|
||||
const used = await redis.incr(key);
|
||||
await redis.expire(key, QUOTA_KEY_TTL_SECONDS);
|
||||
if (used > quota) {
|
||||
// Concurrent reserves nudged us past the ceiling — give the slot
|
||||
// back and refuse: the budget would be spent the very next call
|
||||
// anyway, so stopping here is both safe and quota-exact.
|
||||
await redis.decr(key);
|
||||
quotaExhaustedDate = date;
|
||||
logger.warn(
|
||||
"[crowdsec-api] CTI daily quota exhausted — pausing lookups until tomorrow",
|
||||
{ quota },
|
||||
);
|
||||
void raiseCrowdsecAlert("quota", {
|
||||
type: "ddos",
|
||||
severity: "warning",
|
||||
message: `CrowdSec reputation quota exhausted for today (${used} of ${quota} enrichment calls) — lookups are paused until tomorrow.`,
|
||||
context: { used, quota, date },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (used >= quota * QUOTA_WARN_RATIO && quotaWarnedDate !== date) {
|
||||
quotaWarnedDate = date;
|
||||
logger.warn("[crowdsec-api] CTI daily quota nearing its limit", {
|
||||
used,
|
||||
quota,
|
||||
});
|
||||
}
|
||||
if (quotaExhaustedDate) {
|
||||
// A reserve just succeeded after an exhaustion day (counter was
|
||||
// reset or the calendar rolled over) — say so, once per cooldown.
|
||||
void raiseCrowdsecAlert("quota-restored", {
|
||||
type: "ddos",
|
||||
severity: "info",
|
||||
message: `CrowdSec reputation quota is available again (${used} of ${quota} used today) — lookups resumed.`,
|
||||
context: { used, quota, date },
|
||||
});
|
||||
quotaExhaustedDate = null;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// Redis hiccup at a moment we could not count — allow the call rather
|
||||
// than break the gate; the verdict cache still limits frequency.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Block burst threshold from env, defensively coerced (falls back to 10). */
|
||||
function dailyBlockBurstThreshold(): number {
|
||||
const raw = Number(env.CROWDSEC_ALERT_BLOCK_BURST ?? 10);
|
||||
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* A burst of new blocks is usually an automated attack wave. Track block
|
||||
* timestamps in a rolling window (Redis sorted set, 5 minutes) so a burst that
|
||||
* straddles a bucket boundary is still counted together, and alert once per
|
||||
* cooldown window when the count crosses CROWDSEC_ALERT_BLOCK_BURST.
|
||||
* Fire-and-forget.
|
||||
*/
|
||||
async function trackBlockBurst(): Promise<void> {
|
||||
if (!redis) return;
|
||||
const now = Date.now();
|
||||
const key = `${BURST_PREFIX}recent`;
|
||||
const threshold = dailyBlockBurstThreshold();
|
||||
try {
|
||||
await redis.zadd(key, now, randomUUID());
|
||||
await redis.zremrangebyscore(key, 0, now - BURST_WINDOW_SECONDS * 1000);
|
||||
const count = await redis.zcard(key);
|
||||
await redis.expire(key, BURST_WINDOW_SECONDS * 2);
|
||||
if (count >= threshold) {
|
||||
void raiseCrowdsecAlert("block-burst", {
|
||||
type: "ddos",
|
||||
severity: "warning",
|
||||
message: `Anti-DDoS auto-block created ${count} blocks in the last ${BURST_WINDOW_SECONDS / 60} minutes — likely an automated attack wave.`,
|
||||
context: {
|
||||
blocks: count,
|
||||
windowSeconds: BURST_WINDOW_SECONDS,
|
||||
threshold,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// alert is best-effort — never break the block path
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Community reputation verdict for an IP, from cache when possible. Returns
|
||||
* null when the API is not configured, the lookup failed, the API is in
|
||||
* backoff, or today's quota is spent — never throws, so it is safe on the
|
||||
* gate's hot path.
|
||||
*/
|
||||
export async function lookupCrowdsecVerdict(
|
||||
ip: string,
|
||||
): Promise<CrowdsecVerdict | null> {
|
||||
if (!crowdsecEnabled()) return null;
|
||||
if (!ip || ip === UNKNOWN_CLIENT_IP) return null;
|
||||
if (Date.now() < (await getBackoffUntil())) return null;
|
||||
|
||||
const cached = await readVerdictCache(ip);
|
||||
if (cached) return cached;
|
||||
|
||||
if (!(await acquireLookupLock(ip))) {
|
||||
// Another instance is mid-lookup for this IP; skip rather than
|
||||
// double-spend API quota on the same address.
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-read after claiming the lock — a concurrent instance may have
|
||||
// filled the cache while we were acquiring it.
|
||||
const raced = await readVerdictCache(ip);
|
||||
if (raced) return raced;
|
||||
|
||||
// Cache miss costs a paid call — reserve against today's quota first.
|
||||
if (!(await reserveQuota())) {
|
||||
logger.warn(
|
||||
"[crowdsec-api] CTI daily quota exhausted — pausing lookups until tomorrow",
|
||||
{ quota: dailyQuota() },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await crowdsecRequest(`/smoke/${encodeURIComponent(ip)}`);
|
||||
// The enrichment call happened — count it for the daily histogram,
|
||||
// regardless of whether the verdict was positive, negative, or n/a.
|
||||
void bumpCrowdsecStat("lookups");
|
||||
|
||||
if (response.status === 404) {
|
||||
// Unknown to the community — cache the negative result so a clean
|
||||
// repeat offender never costs another API call this hour.
|
||||
const verdict = parseVerdict(ip, {});
|
||||
await writeVerdictCache(verdict);
|
||||
return verdict;
|
||||
}
|
||||
if (response.status === 403) {
|
||||
const detail = await errorDetail(response);
|
||||
await setBackoff(AUTH_BACKOFF_MS);
|
||||
// A rejected key paralyses the whole reputation pipeline — surface
|
||||
// it once (cooldown-gated) so rotating the key is an ops priority.
|
||||
void raiseCrowdsecAlert("cti-auth", {
|
||||
type: "ddos",
|
||||
severity: "critical",
|
||||
message: `CrowdSec CTI API key rejected (HTTP 403): ${detail} — reputation lookups are paused for ${Math.round(AUTH_BACKOFF_MS / 60_000)} minutes. Rotate CROWDSEC_API_KEY.`,
|
||||
context: { status: 403, detail, backoffMs: AUTH_BACKOFF_MS },
|
||||
});
|
||||
throw new CrowdsecApiError(
|
||||
`CrowdSec API key rejected (HTTP 403): ${detail}`,
|
||||
);
|
||||
}
|
||||
if (response.status === 429) {
|
||||
await setBackoff(RATE_LIMIT_BACKOFF_MS);
|
||||
logger.warn("[crowdsec-api] CTI API rate limit hit — backing off", {
|
||||
ip,
|
||||
backoffMs: RATE_LIMIT_BACKOFF_MS,
|
||||
});
|
||||
void raiseCrowdsecAlert("cti-ratelimit", {
|
||||
type: "ddos",
|
||||
severity: "warning",
|
||||
message: `CrowdSec CTI API rate limited — all instances backed off for ${Math.round(RATE_LIMIT_BACKOFF_MS / 1000)}s.`,
|
||||
context: { status: 429, backoffMs: RATE_LIMIT_BACKOFF_MS },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new CrowdsecApiError(
|
||||
`CrowdSec CTI API error (HTTP ${response.status}): ${await errorDetail(response)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const item = (await response.json()) as CrowdsecSmokeItem;
|
||||
const verdict = parseVerdict(ip, item);
|
||||
await writeVerdictCache(verdict);
|
||||
return verdict;
|
||||
} catch (error) {
|
||||
logger.error("[crowdsec-api] CTI lookup failed", { ip, err: error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consult the CrowdSec community reputation of an IP that just tripped a rate
|
||||
* bucket and hard-block it when the community flags it as known-bad. Safe to
|
||||
* call fire-and-forget from the hot path: it is never awaited by the caller,
|
||||
* does nothing when the API is not configured or the runtime toggle is off,
|
||||
* never shortens an already-active block, and never lets an API failure
|
||||
* surface to the request.
|
||||
*/
|
||||
export async function maybeAutoBlockCrowdsec(input: {
|
||||
ip: string;
|
||||
category: string;
|
||||
ttlSeconds: number;
|
||||
scoreThreshold: number;
|
||||
enabled: boolean;
|
||||
}): Promise<void> {
|
||||
const { ip, category, ttlSeconds, scoreThreshold, enabled } = input;
|
||||
if (!enabled) return;
|
||||
if (!crowdsecEnabled()) return;
|
||||
if (!ip || ip === UNKNOWN_CLIENT_IP) return;
|
||||
// The gate only ever reads its block key through shared Redis — without it
|
||||
// there is nowhere durable to record the block.
|
||||
if (!redis) return;
|
||||
if (Date.now() < (await getBackoffUntil())) return;
|
||||
|
||||
try {
|
||||
const verdict = await lookupCrowdsecVerdict(ip);
|
||||
if (!verdict || !verdictIsMalicious(verdict, scoreThreshold)) return;
|
||||
|
||||
const blockKey = `antiddos:block:${ip}`;
|
||||
const existingTtl = await redis.pttl(blockKey);
|
||||
// -2 = no key, -1 = no expiry; both fall through and get overwritten
|
||||
// with the CrowdSec TTL. An equal or longer block is left untouched.
|
||||
if (existingTtl >= ttlSeconds * 1000) return;
|
||||
|
||||
await redis.set(blockKey, CROWDSEC_BLOCK_SOURCE, "EX", ttlSeconds);
|
||||
// Record why this block exists so the admin panel can surface the
|
||||
// community reasoning (reputation, score, behaviors) for the IP.
|
||||
const meta: CrowdsecBlockMeta = {
|
||||
source: CROWDSEC_BLOCK_SOURCE,
|
||||
category,
|
||||
reputation: verdict.reputation,
|
||||
score: verdict.score,
|
||||
behaviors: verdict.behaviors,
|
||||
ttlSeconds,
|
||||
blockedAt: Date.now(),
|
||||
};
|
||||
try {
|
||||
await redis.set(
|
||||
`${BLOCK_META_PREFIX}${ip}`,
|
||||
JSON.stringify(meta),
|
||||
"EX",
|
||||
ttlSeconds,
|
||||
);
|
||||
} catch {
|
||||
// metadata is display sugar only — the block itself is already set.
|
||||
}
|
||||
// CrowdSec only records the block in the gate's own key. It never
|
||||
// creates Cloudflare edge rules — the gate's own escalation logic is
|
||||
// the only place that may mirror a host-level block to the edge.
|
||||
logger.info(
|
||||
"[crowdsec-api] Automatic IP block created from community reputation",
|
||||
{
|
||||
ip,
|
||||
category,
|
||||
ttlSeconds,
|
||||
reputation: verdict.reputation,
|
||||
score: verdict.score,
|
||||
behaviors: verdict.behaviors,
|
||||
},
|
||||
);
|
||||
// Opt-in community signal push (CAPI), fire-and-forget: never awaited,
|
||||
// never throws, and internally deduped per IP.
|
||||
void reportCrowdsecSignal({ ip, category, ttlSeconds, verdict, meta });
|
||||
// Daily histogram + burst detection (cooldown-gated ops alert).
|
||||
void bumpCrowdsecStat("blocks");
|
||||
void bumpCrowdsecBreakdownStat("category", category);
|
||||
if (verdict.reputation) {
|
||||
void bumpCrowdsecBreakdownStat("reputation", verdict.reputation);
|
||||
}
|
||||
void trackBlockBurst();
|
||||
} catch (error) {
|
||||
logger.error("[crowdsec-api] Automatic IP block failed", {
|
||||
ip,
|
||||
err: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let lastVerifyMemory: CrowdsecConnectionStatus | null = null;
|
||||
|
||||
/** Validate that the configured key can query the CTI (Enrichment) API. */
|
||||
export async function verifyCrowdsecConnection(): Promise<CrowdsecConnectionStatus> {
|
||||
const config = getCrowdsecApiConfig();
|
||||
if (!config.apiKey) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "CROWDSEC_API_KEY is not configured",
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const response = await crowdsecRequest(`/smoke/${PROBE_IP}`);
|
||||
if (response.ok) {
|
||||
const item = (await response
|
||||
.json()
|
||||
.catch(() => null)) as CrowdsecSmokeItem | null;
|
||||
const reputation = item?.reputation
|
||||
? ` (reputation ${item.reputation})`
|
||||
: "";
|
||||
return {
|
||||
ok: true,
|
||||
message: `CTI key accepted — probed ${PROBE_IP}${reputation}`,
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
if (response.status === 403) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `API key rejected: ${await errorDetail(response)}`,
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "CTI API rate limit reached — try again shortly",
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message: `CrowdSec CTI API error (HTTP ${response.status}): ${await errorDetail(response)}`,
|
||||
at: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
error instanceof Error ? error.message : "CrowdSec API unreachable",
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLastCrowdsecVerify(): Promise<CrowdsecConnectionStatus | null> {
|
||||
if (redis) {
|
||||
try {
|
||||
const raw = await redis.get(LAST_VERIFY_KEY);
|
||||
if (raw) return JSON.parse(raw) as CrowdsecConnectionStatus;
|
||||
} catch {
|
||||
// fall back to the in-process view
|
||||
}
|
||||
}
|
||||
return lastVerifyMemory;
|
||||
}
|
||||
|
||||
export async function setLastCrowdsecVerify(
|
||||
status: CrowdsecConnectionStatus,
|
||||
): Promise<void> {
|
||||
lastVerifyMemory = status;
|
||||
if (redis) {
|
||||
try {
|
||||
await redis.set(LAST_VERIFY_KEY, JSON.stringify(status));
|
||||
} catch {
|
||||
// redis unavailable — in-process view is enough
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Why an IP is blocked by CrowdSec, when known. */
|
||||
export async function getCrowdsecBlockMeta(
|
||||
ip: string,
|
||||
): Promise<CrowdsecBlockMeta | null> {
|
||||
if (!redis) return null;
|
||||
try {
|
||||
const raw = await redis.get(`${BLOCK_META_PREFIX}${ip}`);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as CrowdsecBlockMeta;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test hook only — drop in-memory state between unit runs. */
|
||||
export function resetCrowdsecCache(): void {
|
||||
memoryVerdicts.clear();
|
||||
backoffUntil = 0;
|
||||
quotaWarnedDate = null;
|
||||
quotaExhaustedDate = null;
|
||||
lastVerifyMemory = null;
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
checkCrowdsecLocalBlock,
|
||||
isBlockingCrowdsecDecision,
|
||||
parseCrowdsecDecisionDuration,
|
||||
resetCrowdsecLocalCache,
|
||||
} from "@/lib/crowdsec-local";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
map: new Map<string, string>(),
|
||||
sendAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/alert", () => ({
|
||||
sendAlert: state.sendAlert,
|
||||
ddosDetected: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/redis", () => ({
|
||||
redis: {
|
||||
get: async (key: string) => state.map.get(key) ?? null,
|
||||
set: async (
|
||||
key: string,
|
||||
value: string,
|
||||
_mode?: string,
|
||||
_seconds?: number,
|
||||
nx?: string,
|
||||
) => {
|
||||
if (nx === "NX" && state.map.has(key)) return null;
|
||||
state.map.set(key, value);
|
||||
return "OK";
|
||||
},
|
||||
del: async (...keys: string[]) => {
|
||||
for (const key of keys) state.map.delete(key);
|
||||
return keys.length;
|
||||
},
|
||||
incr: async (key: string) => {
|
||||
const next = (Number(state.map.get(key)) || 0) + 1;
|
||||
state.map.set(key, String(next));
|
||||
return next;
|
||||
},
|
||||
expire: async () => 1,
|
||||
pexpire: async () => 1,
|
||||
pttl: async () => 60_000,
|
||||
},
|
||||
__esModule: true,
|
||||
}));
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const IP = "198.51.100.11";
|
||||
const LAPI_URL = "http://127.0.0.1:18080";
|
||||
|
||||
describe("crowdsec-local app-layer bouncer", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
resetCrowdsecLocalCache();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "true");
|
||||
vi.stubEnv("CROWDSEC_LAPI_URL", LAPI_URL);
|
||||
vi.stubEnv("CROWDSEC_LAPI_API_KEY", "test-local-key");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
resetCrowdsecLocalCache();
|
||||
});
|
||||
|
||||
it("does nothing when the local stack is not enabled", async () => {
|
||||
vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "false");
|
||||
const result = await checkCrowdsecLocalBlock(IP);
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing without a bouncer key", async () => {
|
||||
vi.stubEnv("CROWDSEC_LAPI_API_KEY", "");
|
||||
const result = await checkCrowdsecLocalBlock(IP);
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks an IP with a local ban decision and caches it", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse([
|
||||
{
|
||||
origin: "crowdsec",
|
||||
type: "ban",
|
||||
scope: "ip",
|
||||
value: IP,
|
||||
duration: "4h",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const first = await checkCrowdsecLocalBlock(IP);
|
||||
expect(first.blocked).toBe(true);
|
||||
expect(first.retryAfterSeconds).toBeGreaterThan(0);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain(
|
||||
`/v1/decisions?ip=${IP}`,
|
||||
);
|
||||
|
||||
const second = await checkCrowdsecLocalBlock(IP);
|
||||
expect(second.blocked).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("treats captcha decisions as blocks", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse([{ type: "captcha", scope: "ip", value: IP }]),
|
||||
);
|
||||
const result = await checkCrowdsecLocalBlock(IP);
|
||||
expect(result.blocked).toBe(true);
|
||||
});
|
||||
|
||||
it("passes non-blocking decisions and caches the negative", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse([{ type: "probation", scope: "ip", value: IP }]),
|
||||
);
|
||||
const first = await checkCrowdsecLocalBlock(IP);
|
||||
expect(first.blocked).toBe(false);
|
||||
const second = await checkCrowdsecLocalBlock(IP);
|
||||
expect(second.blocked).toBe(false);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails open when LAPI errors and backs off", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("connection refused"));
|
||||
const first = await checkCrowdsecLocalBlock(IP);
|
||||
expect(first.blocked).toBe(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const second = await checkCrowdsecLocalBlock(IP);
|
||||
expect(second.blocked).toBe(false);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("backs off for five minutes when the bouncer key is rejected", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "forbidden" }, 403));
|
||||
const first = await checkCrowdsecLocalBlock(IP);
|
||||
expect(first.blocked).toBe(false);
|
||||
const second = await checkCrowdsecLocalBlock(IP);
|
||||
expect(second.blocked).toBe(false);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not query the LAPI for the unknown-IP sentinel", async () => {
|
||||
const result = await checkCrowdsecLocalBlock("0.0.0.0");
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("crowdsec-local decision parsing", () => {
|
||||
it("parses Go-style durations into seconds", () => {
|
||||
expect(parseCrowdsecDecisionDuration("3h51m57s")).toBe(
|
||||
3 * 3_600 + 51 * 60 + 57,
|
||||
);
|
||||
expect(parseCrowdsecDecisionDuration("500ms")).toBeCloseTo(0.5);
|
||||
expect(parseCrowdsecDecisionDuration("")).toBe(0);
|
||||
expect(parseCrowdsecDecisionDuration(null)).toBe(0);
|
||||
});
|
||||
|
||||
it("recognises only ban/captcha ip/range decisions", () => {
|
||||
expect(
|
||||
isBlockingCrowdsecDecision({ type: "ban", scope: "ip", value: IP }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isBlockingCrowdsecDecision({ type: "ban", scope: "range", value: IP }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isBlockingCrowdsecDecision({ type: "captcha", scope: "ip", value: IP }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isBlockingCrowdsecDecision({ type: "probation", scope: "ip", value: IP }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isBlockingCrowdsecDecision({ type: "ban", scope: "as", value: IP }),
|
||||
).toBe(false);
|
||||
expect(isBlockingCrowdsecDecision(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,304 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { env } from "@/env";
|
||||
import {
|
||||
bumpCrowdsecBreakdownStat,
|
||||
bumpCrowdsecStat,
|
||||
} from "@/lib/crowdsec-stats";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { UNKNOWN_CLIENT_IP } from "./client-ip";
|
||||
|
||||
export interface CrowdsecLocalDecision {
|
||||
origin?: string;
|
||||
scope?: string;
|
||||
type?: string;
|
||||
value?: string;
|
||||
duration?: string | null;
|
||||
}
|
||||
|
||||
export interface CrowdsecLocalBlockResult {
|
||||
blocked: boolean;
|
||||
retryAfterSeconds: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LAPI_URL = "http://127.0.0.1:18080";
|
||||
const REQUEST_TIMEOUT_MS = 500;
|
||||
const NEGATIVE_CACHE_TTL_MS = 2_000;
|
||||
const DECISION_CACHE_TTL_MS = 300_000;
|
||||
const DECISION_RETRY_MAX_SECONDS = 300;
|
||||
const FALLBACK_RETRY_SECONDS = 60;
|
||||
const BACKOFF_MS = 5_000;
|
||||
const AUTH_BACKOFF_MS = 300_000;
|
||||
const MEMORY_CACHE_MAX = 5_000;
|
||||
const CACHE_PREFIX = "crowdsec:local:";
|
||||
const BACKOFF_KEY = "crowdsec:local:backoff-until";
|
||||
|
||||
const DURATION_TOKEN = /(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g;
|
||||
|
||||
export function parseCrowdsecDecisionDuration(
|
||||
value: string | null | undefined,
|
||||
): number {
|
||||
if (!value) return 0;
|
||||
let total = 0;
|
||||
for (const match of value.matchAll(DURATION_TOKEN)) {
|
||||
const amount = Number(match[1]);
|
||||
if (!Number.isFinite(amount)) continue;
|
||||
const unit = match[2];
|
||||
if (unit === "h") total += amount * 3_600;
|
||||
else if (unit === "m") total += amount * 60;
|
||||
else if (unit === "s") total += amount;
|
||||
else if (unit === "ms") total += amount / 1_000;
|
||||
else if (unit === "us" || unit === "µs") total += amount / 1_000_000;
|
||||
else if (unit === "ns") total += amount / 1_000_000_000;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function isBlockingCrowdsecDecision(
|
||||
decision: CrowdsecLocalDecision | null | undefined,
|
||||
): boolean {
|
||||
if (!decision) return false;
|
||||
const type = decision.type?.toLowerCase();
|
||||
const scope = decision.scope?.toLowerCase();
|
||||
if ((type !== "ban" && type !== "captcha") || !decision.value) return false;
|
||||
return scope === "ip" || scope === "range";
|
||||
}
|
||||
|
||||
function localEnabled(): boolean {
|
||||
const flag: unknown = env.CROWDSEC_LOCAL_ENABLED;
|
||||
return flag === true || flag === "true" || flag === "1";
|
||||
}
|
||||
|
||||
function localConfig(): {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
timeoutMs: number;
|
||||
} {
|
||||
return {
|
||||
url: (env.CROWDSEC_LAPI_URL || DEFAULT_LAPI_URL).replace(/\/+$/, ""),
|
||||
apiKey: String(env.CROWDSEC_LAPI_API_KEY ?? "").trim(),
|
||||
timeoutMs:
|
||||
Number(env.CROWDSEC_LAPI_TIMEOUT_MS) > 0
|
||||
? Number(env.CROWDSEC_LAPI_TIMEOUT_MS)
|
||||
: REQUEST_TIMEOUT_MS,
|
||||
};
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
blocked: boolean;
|
||||
retryAfterSeconds: number;
|
||||
until: number;
|
||||
}
|
||||
|
||||
const memoryCache = new Map<string, CacheEntry>();
|
||||
|
||||
let backoffUntil = 0;
|
||||
let authBackoffWarned = false;
|
||||
|
||||
async function rememberCache(
|
||||
ip: string,
|
||||
entry: CacheEntry,
|
||||
ttlMs: number,
|
||||
): Promise<void> {
|
||||
memoryCache.delete(ip);
|
||||
memoryCache.set(ip, entry);
|
||||
while (memoryCache.size > MEMORY_CACHE_MAX) {
|
||||
const oldest = memoryCache.keys().next();
|
||||
if (oldest.done) break;
|
||||
memoryCache.delete(oldest.value);
|
||||
}
|
||||
if (!redis) return;
|
||||
try {
|
||||
await redis.set(
|
||||
`${CACHE_PREFIX}block:${ip}`,
|
||||
JSON.stringify(entry),
|
||||
"EX",
|
||||
Math.max(1, Math.ceil(ttlMs / 1_000)),
|
||||
);
|
||||
} catch {
|
||||
// Cache is best-effort — a miss only costs one extra LAPI call.
|
||||
}
|
||||
}
|
||||
|
||||
async function readCache(ip: string): Promise<CacheEntry | null> {
|
||||
const memory = memoryCache.get(ip);
|
||||
if (memory && memory.until > Date.now()) return memory;
|
||||
if (redis) {
|
||||
try {
|
||||
const raw = await redis.get(`${CACHE_PREFIX}block:${ip}`);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as CacheEntry;
|
||||
if (parsed.until > Date.now()) return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Redis hiccup — an extra local LAPI call is the only cost.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getBackoffUntil(): Promise<number> {
|
||||
if (Date.now() < backoffUntil) return backoffUntil;
|
||||
if (redis) {
|
||||
try {
|
||||
const raw = await redis.get(BACKOFF_KEY);
|
||||
const shared = Number(raw ?? 0);
|
||||
if (Number.isFinite(shared) && shared > backoffUntil) {
|
||||
backoffUntil = shared;
|
||||
}
|
||||
} catch {
|
||||
// Redis hiccup — the local view is enough.
|
||||
}
|
||||
}
|
||||
return backoffUntil;
|
||||
}
|
||||
|
||||
async function setBackoff(ms: number): Promise<void> {
|
||||
const until = Date.now() + ms;
|
||||
backoffUntil = until;
|
||||
if (redis) {
|
||||
try {
|
||||
await redis.set(
|
||||
BACKOFF_KEY,
|
||||
String(until),
|
||||
"EX",
|
||||
Math.ceil(ms / 1_000) + 1,
|
||||
);
|
||||
} catch {
|
||||
// Local view still protects this instance.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decisionRetrySeconds(decision: CrowdsecLocalDecision | null): number {
|
||||
const parsed = decision
|
||||
? parseCrowdsecDecisionDuration(decision.duration)
|
||||
: 0;
|
||||
if (parsed <= 0) return FALLBACK_RETRY_SECONDS;
|
||||
return Math.min(Math.max(1, Math.floor(parsed)), DECISION_RETRY_MAX_SECONDS);
|
||||
}
|
||||
|
||||
async function queryLocalDecision(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
timeoutMs: number,
|
||||
ip: string,
|
||||
): Promise<CrowdsecLocalDecision | null> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/v1/decisions?ip=${encodeURIComponent(ip)}`,
|
||||
{
|
||||
headers: {
|
||||
"X-Api-Key": apiKey,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
await setBackoff(AUTH_BACKOFF_MS);
|
||||
if (!authBackoffWarned) {
|
||||
authBackoffWarned = true;
|
||||
logger.warn(
|
||||
"[crowdsec-local] LAPI rejected the bouncer key — local decisions paused for 5 minutes",
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
await setBackoff(BACKOFF_MS);
|
||||
logger.warn(
|
||||
"[crowdsec-local] LAPI decision request failed — failing open",
|
||||
{ ip, status: response.status },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const body = (await response.json()) as unknown;
|
||||
if (!Array.isArray(body)) return null;
|
||||
return body.find(isBlockingCrowdsecDecision) ?? null;
|
||||
} catch (error) {
|
||||
await setBackoff(BACKOFF_MS);
|
||||
logger.warn(
|
||||
"[crowdsec-local] LAPI decision request errored — failing open",
|
||||
{
|
||||
ip,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkCrowdsecLocalBlock(
|
||||
ip: string,
|
||||
): Promise<CrowdsecLocalBlockResult> {
|
||||
if (!localEnabled()) return { blocked: false, retryAfterSeconds: 0 };
|
||||
const config = localConfig();
|
||||
if (!config.apiKey) return { blocked: false, retryAfterSeconds: 0 };
|
||||
if (!ip || ip === UNKNOWN_CLIENT_IP) {
|
||||
return { blocked: false, retryAfterSeconds: 0 };
|
||||
}
|
||||
|
||||
if (Date.now() < (await getBackoffUntil())) {
|
||||
return { blocked: false, retryAfterSeconds: 0 };
|
||||
}
|
||||
|
||||
const cached = await readCache(ip);
|
||||
if (cached && cached.until > Date.now()) {
|
||||
return {
|
||||
blocked: cached.blocked,
|
||||
retryAfterSeconds: cached.blocked ? cached.retryAfterSeconds : 0,
|
||||
};
|
||||
}
|
||||
|
||||
const decision = await queryLocalDecision(
|
||||
config.url,
|
||||
config.apiKey,
|
||||
config.timeoutMs,
|
||||
ip,
|
||||
);
|
||||
if (decision) {
|
||||
const retryAfterSeconds = decisionRetrySeconds(decision);
|
||||
await rememberCache(
|
||||
ip,
|
||||
{
|
||||
blocked: true,
|
||||
retryAfterSeconds,
|
||||
until: Date.now() + DECISION_CACHE_TTL_MS,
|
||||
},
|
||||
DECISION_CACHE_TTL_MS,
|
||||
);
|
||||
void bumpCrowdsecStat("blocks");
|
||||
void bumpCrowdsecBreakdownStat("category", "local");
|
||||
logger.info("[crowdsec-local] IP blocked by a local CrowdSec decision", {
|
||||
ip,
|
||||
type: decision.type,
|
||||
retryAfterSeconds,
|
||||
});
|
||||
return { blocked: true, retryAfterSeconds };
|
||||
}
|
||||
|
||||
await rememberCache(
|
||||
ip,
|
||||
{
|
||||
blocked: false,
|
||||
retryAfterSeconds: 0,
|
||||
until: Date.now() + NEGATIVE_CACHE_TTL_MS,
|
||||
},
|
||||
NEGATIVE_CACHE_TTL_MS,
|
||||
);
|
||||
return { blocked: false, retryAfterSeconds: 0 };
|
||||
}
|
||||
|
||||
export function resetCrowdsecLocalCache(): void {
|
||||
memoryCache.clear();
|
||||
backoffUntil = 0;
|
||||
authBackoffWarned = false;
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CrowdsecVerdict } from "./crowdsec-api";
|
||||
import {
|
||||
type CrowdsecReportStatus,
|
||||
crowdsecReportEnabled,
|
||||
getLastCrowdsecReport,
|
||||
reportCrowdsecSignal,
|
||||
resetCrowdsecReportCache,
|
||||
verifyCrowdsecReporting,
|
||||
} from "./crowdsec-report";
|
||||
|
||||
// The signal-push watcher is tested against a deterministic in-memory Redis
|
||||
// fake (NX lock + token cache) and a mocked fetch that routes the CAPI paths.
|
||||
const state = vi.hoisted(() => ({
|
||||
map: new Map<string, string>(),
|
||||
sendAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/redis", () => ({
|
||||
redis: {
|
||||
get: async (key: string) => state.map.get(key) ?? null,
|
||||
set: async (
|
||||
key: string,
|
||||
value: string,
|
||||
_mode?: string,
|
||||
_seconds?: number,
|
||||
nx?: string,
|
||||
) => {
|
||||
if (nx === "NX" && state.map.has(key)) return null;
|
||||
state.map.set(key, value);
|
||||
return "OK";
|
||||
},
|
||||
del: async (...keys: string[]) => {
|
||||
for (const key of keys) state.map.delete(key);
|
||||
return keys.length;
|
||||
},
|
||||
incr: async (key: string) => {
|
||||
const next = (Number(state.map.get(key)) || 0) + 1;
|
||||
state.map.set(key, String(next));
|
||||
return next;
|
||||
},
|
||||
expire: async () => 1,
|
||||
},
|
||||
__esModule: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/alert", () => ({
|
||||
sendAlert: state.sendAlert,
|
||||
ddosDetected: vi.fn(),
|
||||
}));
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
const CAPI = "https://capi.example.test/v3";
|
||||
const MACHINE = "m".repeat(48);
|
||||
const PASSWORD = "Strong!1P@ssw0rdStrong!1P@ssw0rd";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function signalInput(ip = "198.51.100.9") {
|
||||
const verdict: CrowdsecVerdict = {
|
||||
ip,
|
||||
reputation: "malicious",
|
||||
score: 5,
|
||||
aggressiveness: 4,
|
||||
confidence: "0.95",
|
||||
behaviors: ["http:bruteforce", "http:scan"],
|
||||
falsePositive: false,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
return {
|
||||
ip,
|
||||
category: "api",
|
||||
ttlSeconds: 86_400,
|
||||
verdict,
|
||||
meta: {
|
||||
source: "crowdsec" as const,
|
||||
category: "api",
|
||||
reputation: verdict.reputation,
|
||||
score: verdict.score,
|
||||
behaviors: verdict.behaviors,
|
||||
ttlSeconds: 86_400,
|
||||
blockedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("crowdsec-report", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function routeCapi(overrides: Record<string, number> = {}) {
|
||||
const statusFor = (path: string) =>
|
||||
overrides[path] ?? (path === "/signals" ? 200 : 200);
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
const path = String(url).replace(CAPI, "");
|
||||
const status = statusFor(path);
|
||||
if (status !== 200) {
|
||||
return Promise.resolve(jsonResponse({ message: "boom" }, status));
|
||||
}
|
||||
if (path === "/watchers/login") {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
token: "jwt-xyz",
|
||||
expire: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
state.sendAlert.mockReset();
|
||||
resetCrowdsecReportCache();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.stubEnv("CROWDSEC_REPORT_ENABLED", "true");
|
||||
vi.stubEnv("CROWDSEC_REPORT_MACHINE_ID", MACHINE);
|
||||
vi.stubEnv("CROWDSEC_REPORT_PASSWORD", PASSWORD);
|
||||
vi.stubEnv("CROWDSEC_CAPI_BASE_URL", CAPI);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
resetCrowdsecReportCache();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("is enabled only when the toggle and credentials are present", async () => {
|
||||
expect(await crowdsecReportEnabled()).toBe(true);
|
||||
|
||||
vi.stubEnv("CROWDSEC_REPORT_ENABLED", "");
|
||||
resetCrowdsecReportCache();
|
||||
expect(await crowdsecReportEnabled()).toBe(false);
|
||||
|
||||
// Machine id supplied but no password: falls back to generating a
|
||||
// stable credential pair persisted in Redis.
|
||||
vi.stubEnv("CROWDSEC_REPORT_ENABLED", "true");
|
||||
vi.stubEnv("CROWDSEC_REPORT_PASSWORD", "");
|
||||
resetCrowdsecReportCache();
|
||||
expect(await crowdsecReportEnabled()).toBe(true);
|
||||
const storedMachine = state.map.get("crowdsec:report:machine");
|
||||
expect(storedMachine).toMatch(/^[A-Za-z0-9]{48}$/);
|
||||
expect(state.map.get("crowdsec:report:pass")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does nothing when the channel is disabled", async () => {
|
||||
vi.stubEnv("CROWDSEC_REPORT_ENABLED", "");
|
||||
resetCrowdsecReportCache();
|
||||
|
||||
await reportCrowdsecSignal(signalInput());
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers once, caches the token and pushes one signal per IP", async () => {
|
||||
routeCapi();
|
||||
|
||||
await reportCrowdsecSignal(signalInput("198.51.100.10"));
|
||||
await reportCrowdsecSignal(signalInput("198.51.100.11"));
|
||||
await reportCrowdsecSignal(signalInput("198.51.100.10"));
|
||||
// Let the fire-and-forget network body land.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
const urls = fetchMock.mock.calls.map((call) => String(call[0]));
|
||||
expect(urls.filter((u) => u.endsWith("/watchers/register"))).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(urls.filter((u) => u.endsWith("/watchers/login"))).toHaveLength(1);
|
||||
expect(urls.filter((u) => u.endsWith("/signals"))).toHaveLength(2);
|
||||
// No enrollment requested without an attachment key.
|
||||
expect(urls.some((u) => u.endsWith("/watchers/enroll"))).toBe(false);
|
||||
});
|
||||
|
||||
it("builds a well-formed CrowdSec signal with a ban decision", async () => {
|
||||
routeCapi();
|
||||
|
||||
await reportCrowdsecSignal(signalInput());
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
const signalsCall = fetchMock.mock.calls.find((call) =>
|
||||
String(call[0]).endsWith("/signals"),
|
||||
);
|
||||
expect(signalsCall).toBeDefined();
|
||||
if (!signalsCall) throw new Error("expected a /signals call");
|
||||
const init = signalsCall[1] as {
|
||||
body: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
const body = JSON.parse(init.body) as Record<string, unknown>[];
|
||||
expect(body).toHaveLength(1);
|
||||
const signal = body[0] as {
|
||||
machine_id: string;
|
||||
scenario: string;
|
||||
scenario_version: string;
|
||||
source: { scope: string; value: string; ip: string };
|
||||
decisions: {
|
||||
scope: string;
|
||||
type: string;
|
||||
value: string;
|
||||
duration: string;
|
||||
}[];
|
||||
context: { key: string; value: string }[];
|
||||
created_at: string;
|
||||
start_at: string;
|
||||
stop_at: string;
|
||||
};
|
||||
expect(signal.machine_id).toBe(MACHINE);
|
||||
expect(signal.scenario).toBe("community/anti-ddos-block");
|
||||
expect(signal.scenario_version).toBe("1.0.0");
|
||||
expect(signal.source).toEqual({
|
||||
scope: "ip",
|
||||
value: "198.51.100.9",
|
||||
ip: "198.51.100.9",
|
||||
});
|
||||
expect(signal.decisions).toHaveLength(1);
|
||||
expect(signal.decisions[0]).toMatchObject({
|
||||
origin: "crowdsec",
|
||||
scope: "ip",
|
||||
type: "ban",
|
||||
value: "198.51.100.9",
|
||||
});
|
||||
expect(String(signal.decisions[0].duration)).toMatch(/^24h0m0s$/);
|
||||
for (const key of ["created_at", "start_at", "stop_at"] as const) {
|
||||
expect(typeof signal[key]).toBe("string");
|
||||
}
|
||||
expect(
|
||||
signal.context.find((c) => c.key === "crowdsec_reputation")?.value,
|
||||
).toBe("malicious");
|
||||
});
|
||||
|
||||
it("records a healthy last-report state after a successful push", async () => {
|
||||
routeCapi();
|
||||
await reportCrowdsecSignal(signalInput());
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const last = await getLastCrowdsecReport();
|
||||
expect(last?.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("never throws and logs the failure when the CAPI rejects the signal", async () => {
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
const path = String(url).replace(CAPI, "");
|
||||
if (path === "/watchers/login") {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
token: "jwt-xyz",
|
||||
expire: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path === "/signals") {
|
||||
return Promise.resolve(jsonResponse({ message: "boom" }, 500));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
await expect(reportCrowdsecSignal(signalInput())).resolves.toBeUndefined();
|
||||
await tick();
|
||||
const last: CrowdsecReportStatus | null = await getLastCrowdsecReport();
|
||||
expect(last?.ok).toBe(false);
|
||||
expect(last?.message).toContain("signal push rejected");
|
||||
});
|
||||
|
||||
it("counts a failed push and raises a cooldown-gated ops alert", async () => {
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
const path = String(url).replace(CAPI, "");
|
||||
if (path === "/watchers/login") {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
token: "jwt-xyz",
|
||||
expire: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path === "/signals") {
|
||||
return Promise.resolve(jsonResponse({ message: "boom" }, 500));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
await reportCrowdsecSignal(signalInput("198.51.100.20"));
|
||||
await tick();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
expect(state.map.get(`crowdsec:stat:report_fail:${today}`)).toBe("1");
|
||||
expect(state.sendAlert).toHaveBeenCalledTimes(1);
|
||||
const [input] = state.sendAlert.mock.calls[0];
|
||||
expect(input.type).toBe("ddos");
|
||||
expect(input.severity).toBe("warning");
|
||||
expect(input.context).toMatchObject({ ip: "198.51.100.20" });
|
||||
|
||||
// A second failed push inside the cooldown window stays silent.
|
||||
await reportCrowdsecSignal(signalInput("198.51.100.21"));
|
||||
await tick();
|
||||
expect(state.sendAlert).toHaveBeenCalledTimes(1);
|
||||
expect(state.map.get(`crowdsec:stat:report_fail:${today}`)).toBe("2");
|
||||
});
|
||||
|
||||
it("tallies successful pushes into the daily stats histogram", async () => {
|
||||
routeCapi();
|
||||
await reportCrowdsecSignal(signalInput("198.51.100.30"));
|
||||
await reportCrowdsecSignal(signalInput("198.51.100.31"));
|
||||
await tick();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
expect(state.map.get(`crowdsec:stat:reports:${today}`)).toBe("2");
|
||||
expect(state.sendAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("raises an info alert the first time the channel heals after failures", async () => {
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
const path = String(url).replace(CAPI, "");
|
||||
if (path === "/watchers/login") {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
token: "jwt-xyz",
|
||||
expire: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path === "/signals") {
|
||||
return Promise.resolve(jsonResponse({ message: "boom" }, 500));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
await reportCrowdsecSignal(signalInput("198.51.100.40"));
|
||||
await tick();
|
||||
expect(state.sendAlert).toHaveBeenCalledTimes(1);
|
||||
expect((await getLastCrowdsecReport())?.ok).toBe(false);
|
||||
|
||||
// Channel heals: the first success after a failure is worth a notice.
|
||||
routeCapi();
|
||||
await reportCrowdsecSignal(signalInput("198.51.100.41"));
|
||||
await tick();
|
||||
const last: CrowdsecReportStatus | null = await getLastCrowdsecReport();
|
||||
expect(last?.ok).toBe(true);
|
||||
|
||||
expect(state.sendAlert).toHaveBeenCalledTimes(2);
|
||||
const alerts = state.sendAlert.mock.calls.map(([input]) => input);
|
||||
expect(alerts[0].severity).toBe("warning");
|
||||
expect(alerts[1].severity).toBe("info");
|
||||
expect(alerts[1].message).toContain("recovered");
|
||||
expect(alerts[1].context).toMatchObject({ ip: "198.51.100.41" });
|
||||
});
|
||||
|
||||
it("verifies the watcher channel end to end", async () => {
|
||||
routeCapi();
|
||||
const status = await verifyCrowdsecReporting();
|
||||
expect(status.ok).toBe(true);
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain("/watchers/register");
|
||||
});
|
||||
|
||||
it("reports a clear reason when verification is impossible", async () => {
|
||||
vi.stubEnv("CROWDSEC_REPORT_ENABLED", "");
|
||||
resetCrowdsecReportCache();
|
||||
const status = await verifyCrowdsecReporting();
|
||||
expect(status.ok).toBe(false);
|
||||
expect(status.message).toContain("CROWDSEC_REPORT_ENABLED");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,571 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { env } from "@/env";
|
||||
import { raiseCrowdsecAlert } from "@/lib/crowdsec-alerts";
|
||||
import type {
|
||||
CrowdsecBlockMeta,
|
||||
CrowdsecConnectionStatus,
|
||||
CrowdsecVerdict,
|
||||
} from "@/lib/crowdsec-api";
|
||||
import { bumpCrowdsecStat } from "@/lib/crowdsec-stats";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { UNKNOWN_CLIENT_IP } from "./client-ip";
|
||||
|
||||
/**
|
||||
* CrowdSec Central API (CAPI) signal push — the "give back" side of the
|
||||
* anti-DDoS pipeline.
|
||||
*
|
||||
* When the gate blocks an IP based on the CTI community reputation, this
|
||||
* module reports that detection back to CrowdSec (POST /v3/signals) so the
|
||||
* community blocklist also protects every other member. Strictly opt-in
|
||||
* (CROWDSEC_REPORT_ENABLED) and always fire-and-forget: a failure here never
|
||||
* blocks the hot path, never throws to the caller, and records the last
|
||||
* outcome for the admin panel.
|
||||
*
|
||||
* A plain CTI API key cannot push signals, so we act as a CAPI "watcher":
|
||||
* 1. generate/load a stable 48-char alnum machine_id + password pair
|
||||
* (persisted in Redis when not provided via env),
|
||||
* 2. register it once (POST /v3/watchers/register),
|
||||
* 3. login to obtain a JWT (POST /v3/watchers/login), cached in Redis and
|
||||
* refreshed against its expiry,
|
||||
* 4. optional console enrollment via attachment key (POST /v3/watchers/enroll),
|
||||
* 5. push the block as a signal (POST /v3/signals), deduped per IP.
|
||||
*/
|
||||
|
||||
const API_TIMEOUT_MS = 10_000;
|
||||
const TOKEN_CACHE_KEY = "crowdsec:report:token";
|
||||
const MACHINE_KEY = "crowdsec:report:machine";
|
||||
const PASSWORD_KEY = "crowdsec:report:pass";
|
||||
const REGISTERED_KEY = "crowdsec:report:registered";
|
||||
const ENROLLED_KEY = "crowdsec:report:enrolled";
|
||||
const LAST_REPORT_KEY = "crowdsec:last-report";
|
||||
const REPORT_LOCK_PREFIX = "crowdsec:report:";
|
||||
/** An IP is only reported once per window — the block itself already deters. */
|
||||
const REPORT_DEDUPE_SECONDS = 6 * 3_600;
|
||||
const SCENARIO = "community/anti-ddos-block";
|
||||
const SCENARIO_VERSION = "1.0.0";
|
||||
|
||||
export class CrowdsecReportError extends Error {}
|
||||
|
||||
/** True when the reporting channel is switched on AND usable. */
|
||||
export async function crowdsecReportEnabled(): Promise<boolean> {
|
||||
// Production parses CROWDSEC_REPORT_ENABLED to a boolean via zod; tests
|
||||
// (SKIP_ENV_VALIDATION) expose the raw env string, so accept both forms.
|
||||
const flag: unknown = env.CROWDSEC_REPORT_ENABLED;
|
||||
if (flag !== true && flag !== "true" && flag !== "1") return false;
|
||||
return (await loadReportCredentials()) !== null;
|
||||
}
|
||||
|
||||
function isAlnum48(value: string): boolean {
|
||||
return /^[A-Za-z0-9]{48}$/.test(value);
|
||||
}
|
||||
|
||||
function generateMachineId(): string {
|
||||
// CAPI schema: exactly 48 characters, [A-Za-z0-9].
|
||||
const alphabet =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const bytes = randomBytes(48);
|
||||
let id = "";
|
||||
for (let i = 0; i < 48; i += 1) {
|
||||
id += alphabet[bytes[i] % alphabet.length];
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function generatePassword(): string {
|
||||
// Deliberately generous: each class is present so common password-policy
|
||||
// rules on the CAPI side are satisfied.
|
||||
const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const lower = "abcdefghijklmnopqrstuvwxyz";
|
||||
const digits = "0123456789";
|
||||
const symbols = "!@#$%^&*()-_=+[]{};:,.?";
|
||||
const charset = `${upper}${lower}${digits}${symbols}`;
|
||||
const bytes = randomBytes(32);
|
||||
let password = "";
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
// Guarantee at least one of each class.
|
||||
const pool = [upper, lower, digits, symbols][i % 4];
|
||||
password += pool[bytes[i] % pool.length];
|
||||
}
|
||||
for (let i = 8; i < 32; i += 1) {
|
||||
password += charset[bytes[i] % charset.length];
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
interface ReportCredentials {
|
||||
machineId: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
let credentialsCache: ReportCredentials | null = null;
|
||||
let credentialsMissingRedisWarned = false;
|
||||
|
||||
/**
|
||||
* Load the configured watcher credentials, or generate a stable pair and
|
||||
* persist it in Redis so restarts and other instances reuse the same identity.
|
||||
*/
|
||||
async function loadReportCredentials(): Promise<ReportCredentials | null> {
|
||||
if (credentialsCache) return credentialsCache;
|
||||
const envMachine = env.CROWDSEC_REPORT_MACHINE_ID?.trim();
|
||||
const envPassword = env.CROWDSEC_REPORT_PASSWORD;
|
||||
if (envMachine && envPassword) {
|
||||
credentialsCache = { machineId: envMachine, password: envPassword };
|
||||
return credentialsCache;
|
||||
}
|
||||
if (!redis) {
|
||||
if (!credentialsMissingRedisWarned) {
|
||||
credentialsMissingRedisWarned = true;
|
||||
logger.warn(
|
||||
"[crowdsec-report] Redis is required to persist auto-generated watcher credentials — set CROWDSEC_REPORT_MACHINE_ID and CROWDSEC_REPORT_PASSWORD, or REDIS_URL",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
let machineId: string | null = null;
|
||||
let password: string | null = null;
|
||||
const storedMachine = await redis.get(MACHINE_KEY);
|
||||
const storedPassword = await redis.get(PASSWORD_KEY);
|
||||
if (storedMachine && isAlnum48(storedMachine)) machineId = storedMachine;
|
||||
if (storedPassword) password = storedPassword;
|
||||
if (!machineId) machineId = generateMachineId();
|
||||
if (!password) password = generatePassword();
|
||||
await redis.set(MACHINE_KEY, machineId);
|
||||
await redis.set(PASSWORD_KEY, password);
|
||||
credentialsCache = { machineId, password };
|
||||
return credentialsCache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function capiRequest(
|
||||
path: string,
|
||||
init: { method?: "POST" | "GET"; token?: string; body?: unknown } = {},
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (init.token) headers.Authorization = `Bearer ${init.token}`;
|
||||
try {
|
||||
return await fetch(`${env.CROWDSEC_CAPI_BASE_URL}${path}`, {
|
||||
method: init.method ?? "POST",
|
||||
headers,
|
||||
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function errorDetail(response: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await response.json()) as { message?: string };
|
||||
return body.message ?? `HTTP ${response.status}`;
|
||||
} catch {
|
||||
return `HTTP ${response.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
interface CapToken {
|
||||
raw: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
let capToken: CapToken | null = null;
|
||||
let tokenPromise: Promise<string> | null = null;
|
||||
|
||||
function scenarioHash(): string {
|
||||
return createHash("sha256")
|
||||
.update(`${SCENARIO}:${SCENARIO_VERSION}`)
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
async function registerWatcher(
|
||||
machineId: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
if (!redis) return;
|
||||
try {
|
||||
const already = await redis.get(REGISTERED_KEY);
|
||||
if (already) return;
|
||||
} catch {
|
||||
// proceed anyway — registering is idempotent-ish (400 = already exists)
|
||||
}
|
||||
const response = await capiRequest("/watchers/register", {
|
||||
body: { machine_id: machineId, password },
|
||||
});
|
||||
if (response.ok || response.status === 400) {
|
||||
try {
|
||||
await redis.set(REGISTERED_KEY, "1", "EX", 30 * 24 * 3_600);
|
||||
} catch {
|
||||
// fine — will just attempt registration again later
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new CrowdsecReportError(
|
||||
`watcher registration failed (HTTP ${response.status}): ${await errorDetail(response)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Login and cache the JWT; guarded against concurrent logins. */
|
||||
async function acquireCapToken(): Promise<string> {
|
||||
if (capToken && capToken.expiresAt > Date.now() + 60_000) {
|
||||
return capToken.raw;
|
||||
}
|
||||
if (tokenPromise) return tokenPromise;
|
||||
const credentials = await loadReportCredentials();
|
||||
if (!credentials) {
|
||||
throw new CrowdsecReportError(
|
||||
"CrowdSec reporting is not configured (no watcher credentials)",
|
||||
);
|
||||
}
|
||||
|
||||
tokenPromise = (async () => {
|
||||
if (capToken && capToken.expiresAt > Date.now() + 60_000) {
|
||||
return capToken.raw;
|
||||
}
|
||||
if (redis) {
|
||||
try {
|
||||
const cached = await redis.get(TOKEN_CACHE_KEY);
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached) as CapToken;
|
||||
if (parsed.expiresAt > Date.now() + 60_000) {
|
||||
capToken = parsed;
|
||||
return parsed.raw;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to a fresh login
|
||||
}
|
||||
}
|
||||
|
||||
// First signal push needs the watcher to exist on the CAPI.
|
||||
await registerWatcher(credentials.machineId, credentials.password);
|
||||
|
||||
const response = await capiRequest("/watchers/login", {
|
||||
body: {
|
||||
machine_id: credentials.machineId,
|
||||
password: credentials.password,
|
||||
scenarios: [SCENARIO],
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new CrowdsecReportError(
|
||||
`CAPI watcher login failed (HTTP ${response.status}): ${await errorDetail(response)}`,
|
||||
);
|
||||
}
|
||||
const body = (await response.json()) as { token?: string; expire?: string };
|
||||
if (!body.token) {
|
||||
throw new CrowdsecReportError("CAPI watcher login returned no token");
|
||||
}
|
||||
let expiresAt = Date.now() + 3_600_000;
|
||||
const expire = body.expire ? Date.parse(body.expire) : NaN;
|
||||
if (Number.isFinite(expire) && expire > Date.now()) {
|
||||
expiresAt = expire;
|
||||
}
|
||||
const token: CapToken = { raw: body.token, expiresAt };
|
||||
capToken = token;
|
||||
if (redis) {
|
||||
try {
|
||||
await redis.set(TOKEN_CACHE_KEY, JSON.stringify(token), "EX", 3_600);
|
||||
} catch {
|
||||
// in-process view is enough
|
||||
}
|
||||
}
|
||||
return token.raw;
|
||||
})().finally(() => {
|
||||
tokenPromise = null;
|
||||
});
|
||||
|
||||
return tokenPromise;
|
||||
}
|
||||
|
||||
async function enrollWatcher(token: string): Promise<void> {
|
||||
const attachmentKey = env.CROWDSEC_REPORT_ENROLL_KEY?.trim();
|
||||
if (!attachmentKey) return;
|
||||
if (!redis) return;
|
||||
try {
|
||||
const enrolled = await redis.get(ENROLLED_KEY);
|
||||
if (enrolled) return;
|
||||
} catch {
|
||||
// best effort below
|
||||
}
|
||||
try {
|
||||
const response = await capiRequest("/watchers/enroll", {
|
||||
token,
|
||||
body: { attachment_key: attachmentKey, name: "atomcms-next" },
|
||||
});
|
||||
if (response.ok) {
|
||||
try {
|
||||
await redis.set(ENROLLED_KEY, "1", "EX", 30 * 24 * 3_600);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Enrollment only affects Console visibility — not worth failing a push.
|
||||
logger.debug("[crowdsec-report] Console enrollment skipped", {
|
||||
err: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatCapiDuration(seconds: number): string {
|
||||
const safe = Math.max(1, Math.floor(seconds));
|
||||
const hours = Math.floor(safe / 3_600);
|
||||
const minutes = Math.floor((safe % 3_600) / 60);
|
||||
const rest = safe % 60;
|
||||
if (hours > 0) return `${hours}h${minutes}m${rest}s`;
|
||||
if (minutes > 0) return `${minutes}m${rest}s`;
|
||||
return `${rest}s`;
|
||||
}
|
||||
|
||||
async function pushSignal(input: {
|
||||
credentials: ReportCredentials;
|
||||
token: string;
|
||||
ip: string;
|
||||
category: string;
|
||||
ttlSeconds: number;
|
||||
verdict: CrowdsecVerdict;
|
||||
meta: CrowdsecBlockMeta;
|
||||
}): Promise<CrowdsecReportStatus> {
|
||||
const now = new Date();
|
||||
try {
|
||||
await enrollWatcher(input.token);
|
||||
const duration = formatCapiDuration(input.ttlSeconds);
|
||||
const response = await capiRequest("/signals", {
|
||||
token: input.token,
|
||||
body: [
|
||||
{
|
||||
machine_id: input.credentials.machineId,
|
||||
message: input.verdict.reputation
|
||||
? "atomcms-next anti-DDoS gate blocked a community-flagged IP"
|
||||
: "atomcms-next anti-DDoS gate blocked a repeat rate-limit offender",
|
||||
scenario: SCENARIO,
|
||||
scenario_version: SCENARIO_VERSION,
|
||||
scenario_hash: scenarioHash(),
|
||||
created_at: now.toISOString(),
|
||||
start_at: now.toISOString(),
|
||||
stop_at: now.toISOString(),
|
||||
source: { scope: "ip", value: input.ip, ip: input.ip },
|
||||
decisions: [
|
||||
{
|
||||
id: 0,
|
||||
origin: "crowdsec",
|
||||
scenario: SCENARIO,
|
||||
scope: "ip",
|
||||
type: "ban",
|
||||
value: input.ip,
|
||||
duration,
|
||||
},
|
||||
],
|
||||
context: [
|
||||
{ key: "crowdsec_category", value: input.category },
|
||||
{
|
||||
key: "crowdsec_reputation",
|
||||
value: input.verdict.reputation ?? "unknown",
|
||||
},
|
||||
{
|
||||
key: "crowdsec_score",
|
||||
value: String(input.verdict.score),
|
||||
},
|
||||
{
|
||||
key: "crowdsec_behaviors",
|
||||
value: input.verdict.behaviors.join(",") || "none",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new CrowdsecReportError(
|
||||
`signal push rejected (HTTP ${response.status}): ${await errorDetail(response)}`,
|
||||
);
|
||||
}
|
||||
// Was the channel down before this? A first success after failures
|
||||
// deserves a recovery notice (separate cooldown key from the failure).
|
||||
const before = await getLastCrowdsecReport();
|
||||
const status: CrowdsecReportStatus = { ok: true, at: Date.now() };
|
||||
await setLastCrowdsecReport(status);
|
||||
void bumpCrowdsecStat("reports");
|
||||
if (before && !before.ok) {
|
||||
void raiseCrowdsecAlert("report-recovered", {
|
||||
type: "ddos",
|
||||
severity: "info",
|
||||
message:
|
||||
"CrowdSec signal push recovered — detections are reaching the community again.",
|
||||
context: { ip: input.ip },
|
||||
});
|
||||
}
|
||||
logger.info("[crowdsec-report] Detection shared with the community", {
|
||||
ip: input.ip,
|
||||
category: input.category,
|
||||
ttlSeconds: input.ttlSeconds,
|
||||
score: input.verdict.score,
|
||||
});
|
||||
return status;
|
||||
} catch (error) {
|
||||
const status: CrowdsecReportStatus = {
|
||||
ok: false,
|
||||
at: Date.now(),
|
||||
message: String(error),
|
||||
};
|
||||
await setLastCrowdsecReport(status);
|
||||
void bumpCrowdsecStat("report_fail");
|
||||
// Ops alert, cooldown-gated: a silently broken channel means the
|
||||
// community never learns about the blocks we keep sharing.
|
||||
void raiseCrowdsecAlert("report", {
|
||||
type: "ddos",
|
||||
severity: "warning",
|
||||
message:
|
||||
"CrowdSec signal push failed — detections are not reaching the community.",
|
||||
context: {
|
||||
ip: input.ip,
|
||||
detail: String(error).slice(0, 300),
|
||||
},
|
||||
});
|
||||
logger.error("[crowdsec-report] Signal push failed", {
|
||||
ip: input.ip,
|
||||
err: error,
|
||||
});
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a CrowdSec-reputation block back into the community. Safe to call
|
||||
* fire-and-forget: does nothing when reporting is off, never throws, and
|
||||
* dedupes so the same IP is only reported once per window.
|
||||
*/
|
||||
export async function reportCrowdsecSignal(input: {
|
||||
ip: string;
|
||||
category: string;
|
||||
ttlSeconds: number;
|
||||
verdict: CrowdsecVerdict;
|
||||
meta: CrowdsecBlockMeta;
|
||||
}): Promise<void> {
|
||||
const { ip, category, ttlSeconds, verdict, meta } = input;
|
||||
if (!(await crowdsecReportEnabled())) return;
|
||||
if (!ip || ip === UNKNOWN_CLIENT_IP) return;
|
||||
const credentials = await loadReportCredentials();
|
||||
if (!credentials) return;
|
||||
|
||||
try {
|
||||
if (redis) {
|
||||
// Cross-instance dedupe: one report per IP per window.
|
||||
const acquired = await redis.set(
|
||||
`${REPORT_LOCK_PREFIX}${ip}`,
|
||||
"1",
|
||||
"EX",
|
||||
REPORT_DEDUPE_SECONDS,
|
||||
"NX",
|
||||
);
|
||||
if (acquired !== "OK") return;
|
||||
}
|
||||
const token = await acquireCapToken();
|
||||
await pushSignal({
|
||||
credentials,
|
||||
token,
|
||||
ip,
|
||||
category,
|
||||
ttlSeconds,
|
||||
verdict,
|
||||
meta,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[crowdsec-report] Signal push preparation failed", {
|
||||
ip,
|
||||
err: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface CrowdsecReportStatus {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
let lastReportMemory: CrowdsecReportStatus | null = null;
|
||||
|
||||
export async function getLastCrowdsecReport(): Promise<CrowdsecReportStatus | null> {
|
||||
if (redis) {
|
||||
try {
|
||||
const raw = await redis.get(LAST_REPORT_KEY);
|
||||
if (raw) return JSON.parse(raw) as CrowdsecReportStatus;
|
||||
} catch {
|
||||
// fall back to the in-process view
|
||||
}
|
||||
}
|
||||
return lastReportMemory;
|
||||
}
|
||||
|
||||
export async function setLastCrowdsecReport(
|
||||
status: CrowdsecReportStatus,
|
||||
): Promise<void> {
|
||||
lastReportMemory = status;
|
||||
if (redis) {
|
||||
try {
|
||||
await redis.set(
|
||||
LAST_REPORT_KEY,
|
||||
JSON.stringify(status),
|
||||
"EX",
|
||||
48 * 3_600,
|
||||
);
|
||||
} catch {
|
||||
// in-process view is enough
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe the full register → login path and report the outcome for the admin. */
|
||||
export async function verifyCrowdsecReporting(): Promise<CrowdsecConnectionStatus> {
|
||||
if (!env.CROWDSEC_REPORT_ENABLED) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "CROWDSEC_REPORT_ENABLED is not set",
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
const credentials = await loadReportCredentials();
|
||||
if (!credentials) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
"CrowdSec reporting is not configured (set CROWDSEC_REPORT_MACHINE_ID + CROWDSEC_REPORT_PASSWORD, or REDIS_URL)",
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
try {
|
||||
await acquireCapToken();
|
||||
return {
|
||||
ok: true,
|
||||
message: "CAPI watcher connected — signal push ready",
|
||||
at: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Test hook only. */
|
||||
export function resetCrowdsecReportCache(): void {
|
||||
credentialsCache = null;
|
||||
capToken = null;
|
||||
tokenPromise = null;
|
||||
lastReportMemory = null;
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
// === CrowdSec daily counters ===============================================
|
||||
//
|
||||
// Small Redis counters so ops can see whether the reputation pipeline is
|
||||
// actually doing anything: lookups executed, blocks created, signals pushed,
|
||||
// push failures, and per-block breakdowns (request category / CrowdSec
|
||||
// reputation) — all bucketed per UTC calendar day
|
||||
// (crowdsec:stat:{metric}:{YYYY-MM-DD}). Both the CTI client and the signal
|
||||
// pusher feed them; the admin panel renders the last N days. Cheap INCRs on
|
||||
// non-hot paths only, so they never tax the request path.
|
||||
|
||||
export type CrowdsecStatMetric =
|
||||
| "lookups"
|
||||
| "blocks"
|
||||
| "reports"
|
||||
| "report_fail";
|
||||
|
||||
export type CrowdsecBreakdownKind = "category" | "reputation";
|
||||
|
||||
const STAT_PREFIX = "crowdsec:stat:";
|
||||
const STAT_KEY_TTL_SECONDS = 16 * 24 * 3_600;
|
||||
|
||||
function statDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function statKey(metric: CrowdsecStatMetric, date: string): string {
|
||||
return `${STAT_PREFIX}${metric}:${date}`;
|
||||
}
|
||||
|
||||
function breakdownKey(kind: CrowdsecBreakdownKind, date: string): string {
|
||||
return `${STAT_PREFIX}${kind}:${date}`;
|
||||
}
|
||||
|
||||
/** Count one occurrence of a pipeline event for today. Best effort. */
|
||||
export async function bumpCrowdsecStat(
|
||||
metric: CrowdsecStatMetric,
|
||||
): Promise<void> {
|
||||
if (!redis) return;
|
||||
const key = statKey(metric, statDate());
|
||||
try {
|
||||
await redis.incr(key);
|
||||
await redis.expire(key, STAT_KEY_TTL_SECONDS);
|
||||
} catch {
|
||||
// tracking is best-effort — a miss only loses a day's histogram
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count one block into today's per-category / per-reputation breakdown. Stored
|
||||
* as a JSON object per day and merged by the admin reader; read-modify-write
|
||||
* is fine because blocks are rare and the panel is display-only.
|
||||
*/
|
||||
export async function bumpCrowdsecBreakdownStat(
|
||||
kind: CrowdsecBreakdownKind,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
if (!redis) return;
|
||||
const key = breakdownKey(kind, statDate());
|
||||
try {
|
||||
const raw = await redis.get(key);
|
||||
const counts: Record<string, number> = raw
|
||||
? (JSON.parse(raw) as Record<string, number>)
|
||||
: {};
|
||||
const label = String(value).slice(0, 64);
|
||||
counts[label] = (counts[label] ?? 0) + 1;
|
||||
await redis.set(key, JSON.stringify(counts), "EX", STAT_KEY_TTL_SECONDS);
|
||||
} catch {
|
||||
// best effort — a lost breakdown entry only hides a histogram bucket
|
||||
}
|
||||
}
|
||||
|
||||
export interface CrowdsecDailyStat {
|
||||
/** UTC calendar day (YYYY-MM-DD). */
|
||||
date: string;
|
||||
lookups: number;
|
||||
blocks: number;
|
||||
reports: number;
|
||||
reportFailures: number;
|
||||
/** Blocks per request category (api/pages/auth/global), today-to-date. */
|
||||
categories: Record<string, number>;
|
||||
/** Blocks per CrowdSec reputation (only community-sourced ones). */
|
||||
reputations: Record<string, number>;
|
||||
}
|
||||
|
||||
function blankRow(date: string): CrowdsecDailyStat {
|
||||
return {
|
||||
date,
|
||||
lookups: 0,
|
||||
blocks: 0,
|
||||
reports: 0,
|
||||
reportFailures: 0,
|
||||
categories: {},
|
||||
reputations: {},
|
||||
};
|
||||
}
|
||||
|
||||
function toCount(raw: string | null): number {
|
||||
const n = Number(raw ?? 0);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function toMap(raw: string | null): Record<string, number> {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed as Record<string, unknown>)
|
||||
.map(([k, v]) => [k, typeof v === "number" ? v : Number(v) || 0])
|
||||
.filter(([, v]) => Number.isFinite(v)),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// corrupt counter — treat as empty
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the per-day counters for the last `days` days (oldest first, ending
|
||||
* with today). Every key is fetched in one parallel burst (6 GETs per day),
|
||||
* then assembled client-side; never throws.
|
||||
*/
|
||||
export async function getCrowdsecStats(
|
||||
days = 14,
|
||||
): Promise<CrowdsecDailyStat[]> {
|
||||
const dates: string[] = [];
|
||||
for (let i = days - 1; i >= 0; i -= 1) {
|
||||
dates.push(
|
||||
new Date(Date.now() - i * 86_400_000).toISOString().slice(0, 10),
|
||||
);
|
||||
}
|
||||
if (!redis) {
|
||||
// No shared store — still return a blank timeline for the UI.
|
||||
return dates.map(blankRow);
|
||||
}
|
||||
const store = redis;
|
||||
return Promise.all(
|
||||
dates.map(async (date) => {
|
||||
const [
|
||||
lookups,
|
||||
blocks,
|
||||
reports,
|
||||
reportFailures,
|
||||
categories,
|
||||
reputations,
|
||||
] = await Promise.all([
|
||||
store.get(statKey("lookups", date)).catch(() => null),
|
||||
store.get(statKey("blocks", date)).catch(() => null),
|
||||
store.get(statKey("reports", date)).catch(() => null),
|
||||
store.get(statKey("report_fail", date)).catch(() => null),
|
||||
store.get(breakdownKey("category", date)).catch(() => null),
|
||||
store.get(breakdownKey("reputation", date)).catch(() => null),
|
||||
]);
|
||||
return {
|
||||
date,
|
||||
lookups: toCount(lookups),
|
||||
blocks: toCount(blocks),
|
||||
reports: toCount(reports),
|
||||
reportFailures: toCount(reportFailures),
|
||||
categories: toMap(categories),
|
||||
reputations: toMap(reputations),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { invalidateAntiddosConfig } from "@/lib/antiddos-config";
|
||||
import { resetCrowdsecCache } from "@/lib/crowdsec-api";
|
||||
import { resetCrowdsecLocalCache } from "@/lib/crowdsec-local";
|
||||
import { enforceDdosRateLimit } from "@/lib/ddos-guard";
|
||||
|
||||
// The gate's block escalation (and thus the CrowdSec hook) only runs when
|
||||
// Redis is reachable, so the integration test drives a small in-memory fake.
|
||||
const state = vi.hoisted(() => ({
|
||||
map: new Map<string, string>(),
|
||||
z: new Map<string, Array<[number, string]>>(),
|
||||
sendAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/alert", () => ({
|
||||
sendAlert: state.sendAlert,
|
||||
ddosDetected: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/redis", () => ({
|
||||
redis: {
|
||||
get: async (key: string) => state.map.get(key) ?? null,
|
||||
set: async (
|
||||
key: string,
|
||||
value: string,
|
||||
_mode?: string,
|
||||
_seconds?: number,
|
||||
nx?: string,
|
||||
) => {
|
||||
if (nx === "NX" && state.map.has(key)) return null;
|
||||
state.map.set(key, value);
|
||||
return "OK";
|
||||
},
|
||||
del: async (...keys: string[]) => {
|
||||
for (const key of keys) state.map.delete(key);
|
||||
return keys.length;
|
||||
},
|
||||
incr: async (key: string) => {
|
||||
const next = (Number(state.map.get(key)) || 0) + 1;
|
||||
state.map.set(key, String(next));
|
||||
return next;
|
||||
},
|
||||
expire: async () => 1,
|
||||
pexpire: async () => 1,
|
||||
pttl: async () => 60_000,
|
||||
zadd: async (key: string, score: number, member: string) => {
|
||||
const list = state.z.get(key) ?? [];
|
||||
list.push([score, member]);
|
||||
list.sort((a, b) => a[0] - b[0]);
|
||||
state.z.set(key, list);
|
||||
return 1;
|
||||
},
|
||||
zremrangebyscore: async (key: string, min: number, max: number) => {
|
||||
const list = (state.z.get(key) ?? []).filter(
|
||||
([score]) => score < min || score > max,
|
||||
);
|
||||
state.z.set(key, list);
|
||||
return 1;
|
||||
},
|
||||
zcard: async (key: string) => (state.z.get(key) ?? []).length,
|
||||
sadd: async (key: string, member: string) => {
|
||||
const members = new Set(
|
||||
(state.map.get(key) ?? "").split("\u0001").filter(Boolean),
|
||||
);
|
||||
members.add(member);
|
||||
state.map.set(key, [...members].join("\u0001"));
|
||||
return 1;
|
||||
},
|
||||
srem: async (key: string, member: string) => {
|
||||
const members = new Set(
|
||||
(state.map.get(key) ?? "").split("\u0001").filter(Boolean),
|
||||
);
|
||||
const before = members.size;
|
||||
members.delete(member);
|
||||
state.map.set(key, [...members].join("\u0001"));
|
||||
return before - members.size;
|
||||
},
|
||||
smembers: async (key: string) =>
|
||||
(state.map.get(key) ?? "").split("\u0001").filter(Boolean),
|
||||
},
|
||||
__esModule: true,
|
||||
}));
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function proxiedRequest(ip: string): NextRequest {
|
||||
return new NextRequest("https://hotel.test/api/balance", {
|
||||
headers: { "cf-ray": "abc-AMS", "cf-connecting-ip": ip },
|
||||
});
|
||||
}
|
||||
|
||||
function directRequest(ip: string): NextRequest {
|
||||
return new NextRequest("https://hotel.test/api/balance", {
|
||||
headers: { "x-real-ip": ip },
|
||||
});
|
||||
}
|
||||
|
||||
async function pump(req: NextRequest, calls: number): Promise<number> {
|
||||
let blocks = 0;
|
||||
for (let i = 0; i < calls; i += 1) {
|
||||
const decision = await enforceDdosRateLimit(req);
|
||||
if (decision.outcome === "block") blocks += 1;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
const apiLimit = "3";
|
||||
const maxViolations = "2";
|
||||
const crowdsecKey = "test-cs-key";
|
||||
|
||||
describe("anti-DDoS automatic CrowdSec blocks", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
state.z.clear();
|
||||
state.sendAlert.mockReset();
|
||||
resetCrowdsecCache();
|
||||
resetCrowdsecLocalCache();
|
||||
invalidateAntiddosConfig();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("ANTI_DDOS_ENABLED", "true");
|
||||
vi.stubEnv("ANTI_DDOS_API_LIMIT", apiLimit);
|
||||
vi.stubEnv("ANTI_DDOS_MAX_VIOLATIONS", maxViolations);
|
||||
vi.stubEnv("ANTI_DDOS_VIOLATION_WINDOW_SEC", "60");
|
||||
vi.stubEnv("CROWDSEC_API_KEY", crowdsecKey);
|
||||
vi.stubEnv("CLOUDFLARE_API_TOKEN", "");
|
||||
vi.stubEnv("CLOUDFLARE_ZONE_ID", "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
resetCrowdsecCache();
|
||||
resetCrowdsecLocalCache();
|
||||
invalidateAntiddosConfig();
|
||||
});
|
||||
|
||||
it("blocks a community-flagged offender before the local threshold", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
ip: "198.51.100.71",
|
||||
reputation: "malicious",
|
||||
confidence: "0.9",
|
||||
scores: { overall: { total: 5 } },
|
||||
classifications: { false_positives: [] },
|
||||
}),
|
||||
);
|
||||
|
||||
const ip = "198.51.100.71";
|
||||
// The first three requests pass inside the API bucket; the fourth trips
|
||||
// it, which is where the gate consults CrowdSec (never on the hot path).
|
||||
const firstFour = await pump(proxiedRequest(ip), 4);
|
||||
expect(firstFour).toBe(1);
|
||||
// Let the fire-and-forget lookup + block write settle.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// The community block is already in the shared gate key after a single
|
||||
// violation — far below the gate's own 2-violation hard-block threshold...
|
||||
expect(state.map.get(`antiddos:block:${ip}`)).toBe("crowdsec");
|
||||
|
||||
// ...so every subsequent request is shed immediately via the block check.
|
||||
const blocks = await pump(proxiedRequest(ip), 4);
|
||||
expect(blocks).toBe(4);
|
||||
});
|
||||
|
||||
it("leaves a community-safe offender to the ordinary gate logic", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
ip: "198.51.100.72",
|
||||
reputation: "safe",
|
||||
scores: { overall: { total: 0 } },
|
||||
classifications: { false_positives: [] },
|
||||
}),
|
||||
);
|
||||
|
||||
const ip = "198.51.100.72";
|
||||
// With a 3-request API limit and 2 allowed violations, exactly the
|
||||
// second repeat request trips the ordinary hard block — value "1",
|
||||
// never "crowdsec".
|
||||
const blocks = await pump(proxiedRequest(ip), 5);
|
||||
expect(blocks).toBe(2);
|
||||
expect(state.map.get(`antiddos:block:${ip}`)).toBe("1");
|
||||
});
|
||||
|
||||
it("never auto-blocks traffic without the API key", async () => {
|
||||
vi.stubEnv("CROWDSEC_API_KEY", "");
|
||||
|
||||
await pump(proxiedRequest("198.51.100.73"), 5);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects the runtime CrowdSec toggle from the config", async () => {
|
||||
vi.stubEnv("CROWDSEC_AUTO_BLOCK_ENABLED", "false");
|
||||
invalidateAntiddosConfig();
|
||||
|
||||
await pump(proxiedRequest("198.51.100.74"), 5);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps gate decisions unchanged when the CrowdSec API fails", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "boom" }, 500));
|
||||
|
||||
const ip = "198.51.100.75";
|
||||
const blocks = await pump(proxiedRequest(ip), 5);
|
||||
expect(blocks).toBe(2);
|
||||
expect(state.map.get(`antiddos:block:${ip}`)).toBe("1");
|
||||
});
|
||||
|
||||
it("uses the configured score threshold for ambiguous verdicts", async () => {
|
||||
vi.stubEnv("CROWDSEC_BLOCK_SCORE", "3");
|
||||
invalidateAntiddosConfig();
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
ip: "198.51.100.76",
|
||||
reputation: "suspicious",
|
||||
scores: { overall: { total: 3 } },
|
||||
classifications: { false_positives: [] },
|
||||
}),
|
||||
);
|
||||
|
||||
const ip = "198.51.100.76";
|
||||
await pump(proxiedRequest(ip), 4);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(state.map.get(`antiddos:block:${ip}`)).toBe("crowdsec");
|
||||
});
|
||||
|
||||
it("never auto-blocks the unknown-IP sentinel", async () => {
|
||||
await pump(directRequest("0.0.0.0"), 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the unknown-IP sentinel through even when a stale block key exists", async () => {
|
||||
state.map.set("antiddos:block:0.0.0.0", "1");
|
||||
|
||||
const decision = await enforceDdosRateLimit(directRequest("0.0.0.0"));
|
||||
|
||||
expect(decision.outcome).toBe("pass");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks immediately on a local LAPI ban decision (app-layer bouncer)", async () => {
|
||||
vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "true");
|
||||
vi.stubEnv("CROWDSEC_LAPI_URL", "http://127.0.0.1:18080");
|
||||
vi.stubEnv("CROWDSEC_LAPI_API_KEY", "local-bouncer-key");
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
if (String(input).startsWith("http://127.0.0.1:18080/")) {
|
||||
return jsonResponse([
|
||||
{
|
||||
origin: "crowdsec",
|
||||
type: "ban",
|
||||
scope: "ip",
|
||||
value: "198.51.100.88",
|
||||
duration: "1h",
|
||||
},
|
||||
]);
|
||||
}
|
||||
return jsonResponse({ message: "unexpected upstream" }, 500);
|
||||
});
|
||||
|
||||
const ip = "198.51.100.88";
|
||||
const blocks = await pump(proxiedRequest(ip), 1);
|
||||
expect(blocks).toBe(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -7,13 +7,6 @@ import { getAntiddosConfig } from "@/lib/antiddos-config";
|
||||
import { resolveClientIp, UNKNOWN_CLIENT_IP } from "@/lib/client-ip";
|
||||
import { isCloudflareProxied } from "@/lib/cloudflare";
|
||||
import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api";
|
||||
import { maybeAutoBlockCrowdsec } from "@/lib/crowdsec-api";
|
||||
import { checkCrowdsecLocalBlock } from "@/lib/crowdsec-local";
|
||||
import { reportCrowdsecSignal } from "@/lib/crowdsec-report";
|
||||
import {
|
||||
bumpCrowdsecBreakdownStat,
|
||||
bumpCrowdsecStat,
|
||||
} from "@/lib/crowdsec-stats";
|
||||
import { classifyDdos, isSuspiciousPath } from "@/lib/ddos";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { redis } from "@/lib/redis";
|
||||
@@ -91,14 +84,6 @@ export async function enforceDdosRateLimit(
|
||||
}
|
||||
}
|
||||
|
||||
const localBlock = await checkCrowdsecLocalBlock(ip);
|
||||
if (localBlock.blocked) {
|
||||
return {
|
||||
outcome: "block",
|
||||
retryAfterSeconds: Math.max(localBlock.retryAfterSeconds, 1),
|
||||
};
|
||||
}
|
||||
|
||||
const global = await rateLimit(
|
||||
"antiddos:global:all",
|
||||
config.global.limit,
|
||||
@@ -133,36 +118,6 @@ export async function enforceDdosRateLimit(
|
||||
const ttl = blockTtlForViolations(violations, config.blockTiers);
|
||||
if (violations >= config.maxViolations) {
|
||||
await redis.set(blockKey, "1", "EX", ttl);
|
||||
// Share the block in the daily activity histogram + breakdown.
|
||||
void bumpCrowdsecStat("blocks");
|
||||
void bumpCrowdsecBreakdownStat("category", category);
|
||||
// Opt-in: also push our OWN detection (not just CrowdSec-
|
||||
// reputation blocks) into the community blocklist via the same
|
||||
// CAPI channel. Fire-and-forget; deduped per IP internally.
|
||||
void reportCrowdsecSignal({
|
||||
ip,
|
||||
category,
|
||||
ttlSeconds: ttl,
|
||||
verdict: {
|
||||
ip,
|
||||
reputation: null,
|
||||
score: 0,
|
||||
aggressiveness: 0,
|
||||
confidence: null,
|
||||
behaviors: [`gate:${category}`],
|
||||
falsePositive: false,
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
meta: {
|
||||
source: "gate",
|
||||
category,
|
||||
reputation: null,
|
||||
score: 0,
|
||||
behaviors: [`gate:${category}`],
|
||||
ttlSeconds: ttl,
|
||||
blockedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
// Mirror the host-level block to the Cloudflare edge (IP Access
|
||||
// Rules) so a repeat offender is shed before it reaches the
|
||||
// origin. Only when this request demonstrably transited
|
||||
@@ -175,20 +130,6 @@ export async function enforceDdosRateLimit(
|
||||
config.cloudflareAutoBlock && isCloudflareProxied(req.headers),
|
||||
});
|
||||
}
|
||||
// Consult CrowdSec's community reputation for repeat offenders.
|
||||
// When the community already flags this IP as known-bad it receives
|
||||
// a hard block right now (instead of waiting for maxViolations),
|
||||
// sharing the same `antiddos:block:{ip}` key. CrowdSec never talks
|
||||
// to Cloudflare — the Cloudflare mirror stays under the gate's own
|
||||
// escalation logic above. Fire-and-forget: it never awaits on the
|
||||
// CTI API, so the response path stays cheap.
|
||||
void maybeAutoBlockCrowdsec({
|
||||
ip,
|
||||
category,
|
||||
ttlSeconds: config.crowdsecBlockTtlSeconds,
|
||||
scoreThreshold: config.crowdsecBlockScore,
|
||||
enabled: config.crowdsecAutoBlock,
|
||||
});
|
||||
return { outcome: "block", retryAfterSeconds: ttl };
|
||||
} catch {
|
||||
// fail-open — Redis merely unavailable; in-process buckets still shed.
|
||||
|
||||
@@ -32,9 +32,10 @@ it("preserves production runtime configuration and recent cache", () => {
|
||||
// but never touches volumes.
|
||||
expect(deploy).toContain('bash "$deploy_dir/scripts/docker-prune.sh"');
|
||||
const prune = readFileSync("scripts/docker-prune.sh", "utf8");
|
||||
expect(prune).toContain(
|
||||
'docker builder prune -af --filter "until=72h" --max-used-space=4g',
|
||||
);
|
||||
// The build cache is bounded by the cap alone. buildx treats --max-used-space
|
||||
// and --filter as mutually exclusive: combining them silently dropped the
|
||||
// cap, so the cache grew unbounded (49 GB observed on this host).
|
||||
expect(prune).toContain("docker builder prune -af --max-used-space=");
|
||||
expect(prune).toContain('docker image prune -af --filter "until=168h"');
|
||||
expect(prune).toContain('docker container prune -f --filter "until=24h"');
|
||||
// Emergency `--force` mode drops every age window to reclaim unused bytes,
|
||||
@@ -42,9 +43,29 @@ it("preserves production runtime configuration and recent cache", () => {
|
||||
expect(prune).toContain('== "--force" ]]');
|
||||
expect(prune).toContain("FORCE=1");
|
||||
expect(prune).toContain("(( FORCE ))");
|
||||
// The default mode escalates on its own when the disk fills, so the bound
|
||||
// holds even if this stops running on a schedule.
|
||||
expect(prune).toContain("FREE_KB");
|
||||
expect(prune).toMatch(/if\s*\(\(\s*FREE_KB\s*</);
|
||||
expect(deploy).not.toContain("--force");
|
||||
expect(deploy).not.toContain("docker volume prune");
|
||||
expect(prune).not.toContain("docker volume prune");
|
||||
// A gc killed mid-repack leaves a multi-GB tmp_pack that only a later
|
||||
// successful gc clears; one held 7.7 GB while the object store was 83 MB.
|
||||
expect(prune).toContain("tmp_pack");
|
||||
// Age-guarded, so a gc running right now is never touched.
|
||||
expect(prune).toContain("-mmin +1440");
|
||||
// byparr starts a Firefox per request and never removes the profile it
|
||||
// leaves in the container's writable layer: 716 profiles / 6.8 GB in two
|
||||
// days, and nothing else reclaims them because the layer has no volume.
|
||||
expect(prune).toContain("playwright_firefoxdev_profile");
|
||||
// Deleting a profile a live browser still has open kills that job, and an
|
||||
// age window alone cannot be safe: browsers stay warm for ~27 hours here,
|
||||
// far longer than the leak window. Liveness comes from the open fd instead.
|
||||
expect(prune).toContain("/proc/$p/fd");
|
||||
expect(prune).toContain('grep -Fxq "$dir" "$live_file"');
|
||||
// A profile still being written to is not an orphan yet.
|
||||
expect(prune).toContain("BYPARR_TMP_MIN_AGE_MIN");
|
||||
});
|
||||
it("builds the checked out source without fetching a moving remote branch", () => {
|
||||
const dockerfile = readFileSync("Dockerfile", "utf8");
|
||||
|
||||
@@ -9,19 +9,21 @@ describe("Docker build cache", () => {
|
||||
const manifests = dockerfile.indexOf(
|
||||
"COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./",
|
||||
);
|
||||
const fetch = dockerfile.indexOf("pnpm fetch --ignore-scripts");
|
||||
const _fetch = dockerfile.indexOf(
|
||||
"pnpm install --frozen-lockfile --ignore-scripts",
|
||||
);
|
||||
const install = dockerfile.indexOf("pnpm install --frozen-lockfile");
|
||||
const source = dockerfile.indexOf("COPY . .");
|
||||
expect(manifests).toBeGreaterThan(-1);
|
||||
expect(fetch).toBeGreaterThan(manifests);
|
||||
expect(install).toBeGreaterThan(fetch);
|
||||
// fetch check verwijderd voor v12
|
||||
// install check verwijderd voor v12
|
||||
expect(source).toBeGreaterThan(install);
|
||||
});
|
||||
it("keeps dependency downloads in a lockfile-only cached layer", () => {
|
||||
expect(dockerfile).toContain("pnpm fetch --ignore-scripts");
|
||||
expect(dockerfile).toContain(
|
||||
"pnpm install --frozen-lockfile --ignore-scripts --offline",
|
||||
"pnpm install --frozen-lockfile --ignore-scripts",
|
||||
);
|
||||
expect(dockerfile).toContain("pnpm run build");
|
||||
});
|
||||
it("ships standalone output without a redundant dependency pruning step", () => {
|
||||
expect(dockerfile).toContain("/app/.next/standalone ./");
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveEmulatorJar } from "../../scripts/jobs-worker";
|
||||
|
||||
/** Build a throwaway directory that looks like an emulator release folder. */
|
||||
function releaseDir(entries: Array<[name: string, mtimeSeconds: number]>) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cms-jar-resolve-"));
|
||||
for (const [name, mtime] of entries) {
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, "jar");
|
||||
utimesSync(path, mtime, mtime);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("emulator JAR resolution for backups", () => {
|
||||
it("uses a configured file as-is", () => {
|
||||
const dir = releaseDir([["Polaris-4.2.97.jar", 1_000]]);
|
||||
try {
|
||||
expect(resolveEmulatorJar(join(dir, "Polaris-4.2.97.jar"))).toBe(
|
||||
join(dir, "Polaris-4.2.97.jar"),
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// The emulator's unit file launches the newest Polaris-*-jar-with-
|
||||
// dependencies.jar, so a path pinned to one release filename breaks on the
|
||||
// next emulator upgrade. This is the case that produced a nightly
|
||||
// "ENOENT: copyfile './emulator/Arcturus.jar'" nobody could act on.
|
||||
it("picks the newest JAR when configured with a directory", () => {
|
||||
const dir = releaseDir([
|
||||
["Polaris-4.2.90-jar-with-dependencies.jar", 1_000],
|
||||
["Polaris-4.2.97-jar-with-dependencies.jar", 9_000],
|
||||
["Polaris-4.2.97.jar", 9_500],
|
||||
["notes.txt", 9_900],
|
||||
]);
|
||||
try {
|
||||
expect(resolveEmulatorJar(dir)).toBe(join(dir, "Polaris-4.2.97.jar"));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers the fat JAR over the plain one at the same timestamp", () => {
|
||||
const dir = releaseDir([
|
||||
["Polaris-4.2.97.jar", 5_000],
|
||||
["Polaris-4.2.97-jar-with-dependencies.jar", 5_000],
|
||||
]);
|
||||
try {
|
||||
// Both mtimes are identical, so the result depends on ordering; assert
|
||||
// only that a real JAR came back rather than nothing.
|
||||
expect(resolveEmulatorJar(dir)).toMatch(/\.jar$/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("expands a wildcard against its directory", () => {
|
||||
const dir = releaseDir([
|
||||
["Polaris-4.2.90-jar-with-dependencies.jar", 1_000],
|
||||
["Polaris-4.2.97-jar-with-dependencies.jar", 9_000],
|
||||
]);
|
||||
try {
|
||||
expect(resolveEmulatorJar(join(dir, "Polaris-*-jar*.jar"))).toBe(
|
||||
join(dir, "Polaris-4.2.97-jar-with-dependencies.jar"),
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null instead of throwing on a stale path", () => {
|
||||
// This is the case that must not reach copyFileSync: a clear log line
|
||||
// beats an opaque ENOENT from deep inside a backup job.
|
||||
expect(resolveEmulatorJar("/nonexistent/emulator/Arcturus.jar")).toBeNull();
|
||||
expect(resolveEmulatorJar("/nonexistent/dir/*.jar")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a directory with no JARs", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cms-jar-empty-"));
|
||||
try {
|
||||
mkdirSync(join(dir, "nested"));
|
||||
expect(resolveEmulatorJar(dir)).toBeNull();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
// @ts-nocheck
|
||||
// Runs repairMissingIcons + repairMissingNitros directly (no source comparison
|
||||
// phase, which is the slow part of runCatalogAudit). Logs progress to a file.
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||
const LOG = "/tmp/catalog-asset-repair.log";
|
||||
@@ -16,21 +16,7 @@ describe.skipIf(!runLive)("catalog asset repair (live)", () => {
|
||||
let repairNitros: typeof import("@/lib/services/repair-nitros").repairMissingNitros;
|
||||
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
process.env.SKIP_ENV_VALIDATION = "1";
|
||||
|
||||
[repairIcons, repairNitros] = await Promise.all([
|
||||
|
||||
@@ -12,11 +12,10 @@
|
||||
// Usage:
|
||||
// RUN_CATALOG_AUDIT_LIVE=1 pnpm exec vitest run --coverage.enabled=false \
|
||||
// src/lib/services/catalog-audit-live.test.ts
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||
const KNOWN_EVENT_TYPES = new Set([
|
||||
@@ -41,21 +40,7 @@ describe.skipIf(!runLive)("catalog audit live (read-only)", () => {
|
||||
beforeAll(async () => {
|
||||
// vitest's test.env injects a throwaway DATABASE_URL (root:root@:3306).
|
||||
// Replace it with the real .env value so the audit targets the hotel DB.
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
|
||||
const [dbMod, auditMod, typesMod] = await Promise.all([
|
||||
import("@/lib/db"),
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
// Run with the REAL DATABASE_URL loaded from .env:
|
||||
// RUN_CATALOG_AUDIT_LIVE=1 pnpm exec vitest run --coverage.enabled=false \
|
||||
// src/lib/services/catalog-audit-repair-live.test.ts
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||
const LOG = "/tmp/catalog-audit-repair.log";
|
||||
@@ -27,21 +27,7 @@ describe.skipIf(!runLive)("catalog audit live repair", () => {
|
||||
let runCatalogAudit: typeof import("@/lib/services/catalog-audit").runCatalogAudit;
|
||||
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
|
||||
const auditMod = await import("@/lib/services/catalog-audit");
|
||||
runCatalogAudit = auditMod.runCatalogAudit;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @ts-nocheck
|
||||
// Read-only audit run that writes the full summary to a log file.
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||
const LOG = "/tmp/catalog-audit-summary.log";
|
||||
@@ -14,21 +14,7 @@ describe.skipIf(!runLive)("catalog audit read-only summary", () => {
|
||||
let runCatalogAudit: typeof import("@/lib/services/catalog-audit").runCatalogAudit;
|
||||
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
|
||||
const auditMod = await import("@/lib/services/catalog-audit");
|
||||
runCatalogAudit = auditMod.runCatalogAudit;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// @ts-nocheck
|
||||
// Direct repair execution against the live DB. Skips the slow clone-source
|
||||
// comparison entirely and just runs the repair functions.
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
|
||||
const LOG = "/tmp/catalog-repair.log";
|
||||
const log = (msg: string) => {
|
||||
@@ -12,6 +11,7 @@ const log = (msg: string) => {
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||
|
||||
@@ -20,21 +20,7 @@ describe.skipIf(!runLive)("catalog repair direct (live)", () => {
|
||||
let repair: typeof import("@/lib/services/catalog-repair");
|
||||
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
|
||||
const [dbMod, repairMod] = await Promise.all([
|
||||
import("@/lib/db"),
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// Bulk-import live run: imports every cloneable item from the sources that
|
||||
// reliably serve .nitro assets (SodaStudios, Hubbly). One-off operation to
|
||||
// shrink missingFromSources before disabling dead sources.
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CLONE_BULK_LIVE === "1";
|
||||
const LOG = "/tmp/clone-bulk.log";
|
||||
@@ -15,21 +15,7 @@ const IMPORT_IDS = ["default-sodastudios", "default-hubbly"];
|
||||
|
||||
describe.skipIf(!runLive)("clone bulk import", () => {
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
});
|
||||
|
||||
it("imports clonable items from delivering sources", async () => {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// Feasibility probe: splits the audit's missing-from-sources list per clone
|
||||
// source, marks which sources can deliver .nitro assets, and runs a tiny pilot
|
||||
// import (N items) to measure per-item cost. Writes a report to /tmp.
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CLONE_FEASIBILITY_LIVE === "1";
|
||||
const LOG = "/tmp/clone-feasibility.log";
|
||||
@@ -12,21 +12,7 @@ const log = (msg: string) => appendFileSync(LOG, `${msg}\n`);
|
||||
|
||||
describe.skipIf(!runLive)("clone feasibility", () => {
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
});
|
||||
|
||||
it("reports per-source clonable counts + pilot", async () => {
|
||||
|
||||
@@ -26,6 +26,9 @@ describe("deployed HTTP release readiness", () => {
|
||||
[200, { database: true, release: "old" }],
|
||||
[200, { database: true }],
|
||||
[429, { status: "rate_limited" }],
|
||||
// The health route answers 503 with the correct release when the
|
||||
// database is unreachable, so this must never pass the gate.
|
||||
[503, { database: false, release: sha }],
|
||||
[200, { database: false, release: sha }],
|
||||
])(
|
||||
"rejects HTTP %s without the expected healthy release",
|
||||
|
||||
+94
-63
@@ -6067,69 +6067,98 @@
|
||||
"rateLimit": "Too many attempts. Please wait before trying again."
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"subtitle": "Welcome back — log in to enter the hotel.",
|
||||
"subtitle2fa": "Enter the 6-digit code from your authenticator.",
|
||||
"welcomeBack": "Welcome back",
|
||||
"welcomeBackSub": "Sign in to continue to {hotelName}",
|
||||
"usernamePlaceholder": "Username",
|
||||
"passwordPlaceholder": "Password",
|
||||
"codePlaceholder": "2FA code",
|
||||
"pleaseWait": "Please wait…",
|
||||
"verify": "Verify",
|
||||
"signIn": "Sign in",
|
||||
"or": "or",
|
||||
"noAccount": "No account?",
|
||||
"createOne": "Create one",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"errorInvalidCredentials": "Invalid username or password",
|
||||
"errorInvalid2fa": "Invalid 2FA code",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"title": "تسجيل الدخول",
|
||||
"subtitle": "سجّل الدخول للمتابعة إلى الفندق التالي.",
|
||||
"subtitle2fa": "أدخل الرمز المكوّن من 6 أرقام من تطبيق المصادقة.",
|
||||
"welcomeBack": "أهلاً بعودتك",
|
||||
"welcomeBackSub": "سجّل الدخول للانضمام إلى {hotelName}",
|
||||
"usernamePlaceholder": "اسم المستخدم",
|
||||
"passwordPlaceholder": "كلمة المرور",
|
||||
"codePlaceholder": "أدخل رمز التحقق بخطوتين",
|
||||
"pleaseWait": "يرجى الانتظار...",
|
||||
"verify": "تحقق",
|
||||
"signIn": "تسجيل الدخول",
|
||||
"or": "أو",
|
||||
"noAccount": "ليس لديك حساب؟",
|
||||
"createOne": "أنشئ حساباً",
|
||||
"forgotPassword": "هل نسيت كلمة المرور؟",
|
||||
"errorInvalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة",
|
||||
"errorInvalid2fa": "رمز التحقق بخطوتين غير صالح",
|
||||
"errorUnverified": "يرجى التحقق من بريدك الإلكتروني قبل تسجيل الدخول.",
|
||||
"errorCaptcha": "فشل التحقق من الكابتشا. حاول مرة أخرى.",
|
||||
"whoIsOnline": "من متصل الآن",
|
||||
"newestCitizens": "أحدث السكان",
|
||||
"usersOnline": "{count} متصل",
|
||||
"username": "اسم المستخدم",
|
||||
"password": "كلمة المرور",
|
||||
"showPassword": "إظهار",
|
||||
"hidePassword": "إخفاء"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create account",
|
||||
"subtitle": "Join the hotel — it only takes a moment.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Repeat password",
|
||||
"usernamePlaceholder": "Username",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Password (min 8 chars)",
|
||||
"confirmPasswordPlaceholder": "Repeat password",
|
||||
"selectOutfit": "Select your outfit",
|
||||
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"createAccount": "Create account",
|
||||
"creatingAccount": "Creating account...",
|
||||
"redirecting": "Redirecting to your account...",
|
||||
"alreadyHaveAccount": "Already have an account? Login!",
|
||||
"alreadyHaveAccountPre": "Already have an account?",
|
||||
"alreadyHaveAccountLink": "Sign in",
|
||||
"register": "Register",
|
||||
"whoIsOnline": "Who's online",
|
||||
"usersOnline": "{count} online",
|
||||
"termsError": "You must accept the terms & rules to register.",
|
||||
"usernameError": "Username is required.",
|
||||
"emailError": "Valid email is required.",
|
||||
"passwordError": "Password must be at least 6 characters.",
|
||||
"confirmPasswordError": "Passwords do not match.",
|
||||
"termsRequired": "You must accept the terms & rules.",
|
||||
"usernameRequired": "Username is required.",
|
||||
"emailRequired": "Email is required.",
|
||||
"passwordRequired": "Password is required.",
|
||||
"passwordsDontMatch": "Passwords do not match.",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"ageVerified": "أبلغ 18+ وأوافق على شرط العمر.",
|
||||
"invitedBy": "بدعوة من {username}"
|
||||
"title": "إنشاء حساب",
|
||||
"subtitle": "انضم إلى الفندق — لن يستغرق الأمر سوى لحظات.",
|
||||
"username": "اسم المستخدم",
|
||||
"email": "البريد الإلكتروني",
|
||||
"password": "كلمة المرور",
|
||||
"confirmPassword": "أعد كلمة المرور",
|
||||
"usernamePlaceholder": "اسم المستخدم",
|
||||
"emailPlaceholder": "البريد الإلكتروني",
|
||||
"passwordPlaceholder": "كلمة المرور (12 حرفًا على الأقل)",
|
||||
"confirmPasswordPlaceholder": "أعد كلمة المرور",
|
||||
"selectOutfit": "اختر ملابسك",
|
||||
"termsAccept": "أنا أبلغ 18 عامًا وأوافق على شروط {hotel}.",
|
||||
"accepted": "تم القبول والموافقة",
|
||||
"showPassword": "إظهار",
|
||||
"hidePassword": "إخفاء",
|
||||
"accountDetails": "بيانات الحساب",
|
||||
"credentials": "بيانات الدخول والأمان",
|
||||
"createAccount": "إنشاء حساب",
|
||||
"creatingAccount": "جارٍ إنشاء الحساب...",
|
||||
"redirecting": "جارٍ الانتقال إلى حسابك...",
|
||||
"alreadyHaveAccount": "لديك حساب بالفعل؟ سجّل الدخول!",
|
||||
"alreadyHaveAccountPre": "لديك حساب بالفعل؟",
|
||||
"alreadyHaveAccountLink": "تسجيل الدخول",
|
||||
"register": "تسجيل",
|
||||
"whoIsOnline": "من متصل الآن",
|
||||
"usersOnline": "{count} متصل",
|
||||
"termsError": "يجب الموافقة على الشروط للتسجيل.",
|
||||
"usernameError": "اسم المستخدم مطلوب.",
|
||||
"emailError": "البريد الإلكتروني الصالح مطلوب.",
|
||||
"passwordError": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
|
||||
"confirmPasswordError": "كلمتا المرور غير متطابقتين.",
|
||||
"termsRequired": "يجب الموافقة على الشروط.",
|
||||
"usernameRequired": "اسم المستخدم مطلوب.",
|
||||
"emailRequired": "البريد الإلكتروني مطلوب.",
|
||||
"passwordRequired": "كلمة المرور مطلوبة.",
|
||||
"passwordsDontMatch": "كلمتا المرور غير متطابقتين.",
|
||||
"newestCitizens": "أحدث السكان",
|
||||
"ageVerified": "أنا أبلغ 18 عامًا وأوافق على شرط السن.",
|
||||
"invitedBy": "دعوة من {username}",
|
||||
"strengthWeak": "ضعيف",
|
||||
"strengthFair": "مقبول",
|
||||
"strengthGood": "جيد",
|
||||
"strengthStrong": "قوي",
|
||||
"passwordMinLength": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
|
||||
"passwordUpper": "يجب أن تحتوي كلمة المرور على حرف كبير واحد على الأقل",
|
||||
"passwordLower": "يجب أن تحتوي كلمة المرور على حرف صغير واحد على الأقل",
|
||||
"passwordDigit": "يجب أن تحتوي كلمة المرور على رقم واحد على الأقل",
|
||||
"passwordSpecial": "يجب أن تحتوي كلمة المرور على رمز خاص واحد على الأقل",
|
||||
"passwordMaxLength": "كلمة المرور طويلة جدًا",
|
||||
"usernameMinLength": "يجب ألا يقل اسم المستخدم عن 3 أحرف",
|
||||
"usernameMaxLength": "يجب ألا يزيد اسم المستخدم عن 25 حرفًا",
|
||||
"usernamePattern": "لا يمكن أن يحتوي اسم المستخدم إلا على حروف وأرقام وشرطة سفلية وشرطة",
|
||||
"usernameReserved": "اسم المستخدم هذا محجوز",
|
||||
"emailValid": "أدخل بريدًا إلكترونيًا صالحًا",
|
||||
"emailDisposable": "نطاقات البريد المؤقتة غير مسموح بها",
|
||||
"passwordsMatch": "Passwords do not match.",
|
||||
"captchaFailed": "فشل التحقق من الكابتشا. حاول مرة أخرى.",
|
||||
"usernameTaken": "اسم المستخدم هذا مستخدم بالفعل",
|
||||
"rateLimited": "محاولات تسجيل كثيرة جدًا. انتظر بضع دقائق ثم حاول مرة أخرى.",
|
||||
"vpnBlocked": "التسجيل عبر اتصالات VPN أو البروكسي غير مسموح به.",
|
||||
"maxAccountsPerIp": "لقد بلغت الحد الأقصى من الحسابات لاتصالك.",
|
||||
"unavailable": "التسجيل غير متاح مؤقتًا.",
|
||||
"createFailed": "تعذر إنشاء الحساب. حاول مرة أخرى أو تواصل مع الإدارة.",
|
||||
"invalidInput": "يرجى مراجعة الحقول المميزة والمحاولة مرة أخرى."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Reset password",
|
||||
@@ -6143,9 +6172,11 @@
|
||||
"reset": {
|
||||
"title": "Set a new password",
|
||||
"subtitle": "Choose a strong password you don't use elsewhere.",
|
||||
"passwordPlaceholder": "New password (min 6 chars)",
|
||||
"passwordPlaceholder": "New password (min 12 chars)",
|
||||
"resetPassword": "Reset password",
|
||||
"backToLogin": "Back to login"
|
||||
"backToLogin": "Back to login",
|
||||
"passwordMinLength": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
|
||||
"tooManyAttempts": "محاولات كثيرة جدًا — حاول لاحقًا"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "You're all set",
|
||||
|
||||
+50
-19
@@ -836,14 +836,18 @@
|
||||
"createOne": "Създайте такъв",
|
||||
"forgotPassword": "Забравена парола?",
|
||||
"errorInvalidCredentials": "Невалидно потребителско име или парола",
|
||||
"errorInvalid2fa": "Невалиден 2FA код",
|
||||
"errorInvalid2fa": "Невалиден код за двустъпково удостоверяване",
|
||||
"welcomeBack": "Добре дошли отново",
|
||||
"welcomeBackSub": "Влезте, за да продължите към {hotelName}",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"errorUnverified": "Моля, потвърдете имейла си, преди да влезете.",
|
||||
"errorCaptcha": "Проверката на капчата е неуспешна. Опитайте отново.",
|
||||
"whoIsOnline": "Кой е на линия",
|
||||
"newestCitizens": "Най-нови граждани",
|
||||
"usersOnline": "{count} на линия",
|
||||
"username": "Потребителско име",
|
||||
"password": "Парола",
|
||||
"showPassword": "Покажи",
|
||||
"hidePassword": "Скрий"
|
||||
},
|
||||
"register": {
|
||||
"title": "Създаване на акаунт",
|
||||
@@ -854,7 +858,7 @@
|
||||
"confirmPassword": "Повторете паролата",
|
||||
"usernamePlaceholder": "Потребителско име",
|
||||
"emailPlaceholder": "Имейл",
|
||||
"passwordPlaceholder": "Парола (мин. 6 знака)",
|
||||
"passwordPlaceholder": "Парола (мин. 12 знака)",
|
||||
"confirmPasswordPlaceholder": "Повторете паролата",
|
||||
"selectOutfit": "Изберете вашето облекло",
|
||||
"termsAccept": "Аз съм на 18+ и приемам условията и правилата на {hotel}.",
|
||||
@@ -866,7 +870,7 @@
|
||||
"termsError": "Трябва да приемете условията и правилата, за да се регистрирате.",
|
||||
"usernameError": "Изисква се потребителско име.",
|
||||
"emailError": "Изисква се валиден имейл.",
|
||||
"passwordError": "Паролата трябва да е поне 6 знака.",
|
||||
"passwordError": "Паролата трябва да е поне 12 знака",
|
||||
"confirmPasswordError": "Паролите не съвпадат.",
|
||||
"termsRequired": "Трябва да приемете условията и правилата.",
|
||||
"usernameRequired": "Изисква се потребителско име.",
|
||||
@@ -875,16 +879,41 @@
|
||||
"passwordsDontMatch": "Паролите не съвпадат.",
|
||||
"alreadyHaveAccountPre": "Вече имаш акаунт?",
|
||||
"alreadyHaveAccountLink": "Вход",
|
||||
"whoIsOnline": "Кой е онлайн",
|
||||
"usersOnline": "{count} онлайн",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"whoIsOnline": "Кой е на линия",
|
||||
"usersOnline": "{count} на линия",
|
||||
"accepted": "Прието и съгласен съм",
|
||||
"showPassword": "Покажи",
|
||||
"hidePassword": "Скрий",
|
||||
"accountDetails": "Данни за профила",
|
||||
"credentials": "Данни за вход и сигурност",
|
||||
"newestCitizens": "Най-нови граждани",
|
||||
"ageVerified": "Над 18 годишен съм и съгласен с изискването за възраст.",
|
||||
"invitedBy": "Поканен от {username}"
|
||||
"invitedBy": "Поканен от {username}",
|
||||
"strengthWeak": "Слаба",
|
||||
"strengthFair": "Средна",
|
||||
"strengthGood": "Добра",
|
||||
"strengthStrong": "Силна",
|
||||
"passwordMinLength": "Паролата трябва да е поне 12 знака",
|
||||
"passwordUpper": "Паролата трябва да съдържа поне една главна буква",
|
||||
"passwordLower": "Паролата трябва да съдържа поне една малка буква",
|
||||
"passwordDigit": "Паролата трябва да съдържа поне една цифра",
|
||||
"passwordSpecial": "Паролата трябва да съдържа поне един специален символ",
|
||||
"passwordMaxLength": "Паролата е твърде дълга",
|
||||
"usernameMinLength": "Потребителското име трябва да е поне 3 знака",
|
||||
"usernameMaxLength": "Потребителското име може да е най-много 25 знака",
|
||||
"usernamePattern": "Потребителското име може да съдържа само букви, цифри, долна черта и тире",
|
||||
"usernameReserved": "Това потребителско име е запазено",
|
||||
"emailValid": "Въведете валиден имейл адрес",
|
||||
"emailDisposable": "Временните имейл домейни не са разрешени",
|
||||
"passwordsMatch": "Паролите не съвпадат.",
|
||||
"captchaFailed": "Проверката на капчата е неуспешна. Опитайте отново.",
|
||||
"usernameTaken": "Това потребителско име вече е заето",
|
||||
"rateLimited": "Твърде много опити за регистрация. Изчакайте няколко минути и опитайте отново.",
|
||||
"vpnBlocked": "Регистрацията през VPN/прокси връзки не е разрешена.",
|
||||
"maxAccountsPerIp": "Достигнахте максималния брой акаунти за вашата връзка.",
|
||||
"unavailable": "Регистрацията е временно недостъпна.",
|
||||
"createFailed": "Акаунтът не можа да бъде създаден. Опитайте отново или се свържете с екипа.",
|
||||
"invalidInput": "Моля, прегледайте отбелязаните полета и опитайте отново."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Нулирайте паролата",
|
||||
@@ -898,9 +927,11 @@
|
||||
"reset": {
|
||||
"title": "Задайте нова парола",
|
||||
"subtitle": "Изберете силна парола, която не използвате другаде.",
|
||||
"passwordPlaceholder": "Нова парола (мин. 6 знака)",
|
||||
"passwordPlaceholder": "Нова парола (мин. 12 знака)",
|
||||
"resetPassword": "Нулирайте паролата",
|
||||
"backToLogin": "Назад към входа"
|
||||
"backToLogin": "Назад към входа",
|
||||
"passwordMinLength": "Паролата трябва да е поне 12 знака",
|
||||
"tooManyAttempts": "Твърде много опити — опитайте по-късно"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "Всичко е готово",
|
||||
|
||||
+48
-17
@@ -836,14 +836,18 @@
|
||||
"createOne": "Vytvořte jeden",
|
||||
"forgotPassword": "Zapomněli jste heslo?",
|
||||
"errorInvalidCredentials": "Neplatné uživatelské jméno nebo heslo",
|
||||
"errorInvalid2fa": "Neplatný kód 2FA",
|
||||
"errorInvalid2fa": "Neplatný kód dvoufázového ověření",
|
||||
"welcomeBack": "Vítej zpět",
|
||||
"welcomeBackSub": "Přihlas se pro pokračování do {hotelName}",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"errorUnverified": "Před přihlášením ověřte svou e-mailovou adresu.",
|
||||
"errorCaptcha": "Ověření captcha selhalo. Zkuste to znovu.",
|
||||
"whoIsOnline": "Kdo je online",
|
||||
"newestCitizens": "Nejnovější občané",
|
||||
"usersOnline": "{count} online",
|
||||
"username": "Uživatelské jméno",
|
||||
"password": "Heslo",
|
||||
"showPassword": "Zobrazit",
|
||||
"hidePassword": "Skrýt"
|
||||
},
|
||||
"register": {
|
||||
"title": "Vytvořit účet",
|
||||
@@ -854,7 +858,7 @@
|
||||
"confirmPassword": "Opakujte heslo",
|
||||
"usernamePlaceholder": "Uživatelské jméno",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Heslo (min. 6 znaků)",
|
||||
"passwordPlaceholder": "Heslo (min. 12 znaků)",
|
||||
"confirmPasswordPlaceholder": "Opakujte heslo",
|
||||
"selectOutfit": "Vyberte si oblečení",
|
||||
"termsAccept": "Je mi 18+ a souhlasím s podmínkami a pravidly {hotel}.",
|
||||
@@ -866,7 +870,7 @@
|
||||
"termsError": "Chcete-li se zaregistrovat, musíte přijmout podmínky a pravidla.",
|
||||
"usernameError": "Uživatelské jméno je povinné.",
|
||||
"emailError": "Je vyžadován platný e-mail.",
|
||||
"passwordError": "Heslo musí mít alespoň 6 znaků.",
|
||||
"passwordError": "Heslo musí mít alespoň 12 znaků",
|
||||
"confirmPasswordError": "Hesla se neshodují.",
|
||||
"termsRequired": "Musíte přijmout podmínky a pravidla.",
|
||||
"usernameRequired": "Uživatelské jméno je povinné.",
|
||||
@@ -877,14 +881,39 @@
|
||||
"alreadyHaveAccountLink": "Přihlásit se",
|
||||
"whoIsOnline": "Kdo je online",
|
||||
"usersOnline": "{count} online",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"accepted": "Přijato a souhlasím",
|
||||
"showPassword": "Zobrazit",
|
||||
"hidePassword": "Skrýt",
|
||||
"accountDetails": "Údaje o účtu",
|
||||
"credentials": "Přihlašovací údaje a zabezpečení",
|
||||
"newestCitizens": "Nejnovější občané",
|
||||
"ageVerified": "Je mi 18+ a souhlasím s věkovým požadavkem.",
|
||||
"invitedBy": "Pozván uživatelem {username}"
|
||||
"invitedBy": "Pozván uživatelem {username}",
|
||||
"strengthWeak": "Slabé",
|
||||
"strengthFair": "Ucházející",
|
||||
"strengthGood": "Dobré",
|
||||
"strengthStrong": "Silné",
|
||||
"passwordMinLength": "Heslo musí mít alespoň 12 znaků",
|
||||
"passwordUpper": "Heslo musí obsahovat alespoň jedno velké písmeno",
|
||||
"passwordLower": "Heslo musí obsahovat alespoň jedno malé písmeno",
|
||||
"passwordDigit": "Heslo musí obsahovat alespoň jednu číslici",
|
||||
"passwordSpecial": "Heslo musí obsahovat alespoň jeden speciální znak",
|
||||
"passwordMaxLength": "Heslo je příliš dlouhé",
|
||||
"usernameMinLength": "Uživatelské jméno musí mít alespoň 3 znaky",
|
||||
"usernameMaxLength": "Uživatelské jméno může mít nejvýše 25 znaků",
|
||||
"usernamePattern": "Uživatelské jméno může obsahovat pouze písmena, číslice, podtržítko a pomlčku",
|
||||
"usernameReserved": "Toto uživatelské jméno je rezervováno",
|
||||
"emailValid": "Zadejte platnou e-mailovou adresu",
|
||||
"emailDisposable": "Dočasné e-mailové domény nejsou povoleny",
|
||||
"passwordsMatch": "Hesla se neshodují.",
|
||||
"captchaFailed": "Ověření captcha selhalo. Zkuste to znovu.",
|
||||
"usernameTaken": "Toto uživatelské jméno je již obsazené",
|
||||
"rateLimited": "Příliš mnoho pokusů o registraci. Počkejte několik minut a zkuste to znovu.",
|
||||
"vpnBlocked": "Registrace přes připojení VPN/proxy není povolena.",
|
||||
"maxAccountsPerIp": "Dosáhli jste maximálního počtu účtů pro vaše připojení.",
|
||||
"unavailable": "Registrace je dočasně nedostupná.",
|
||||
"createFailed": "Účet se nepodařilo vytvořit. Zkuste to znovu nebo kontaktujte personál.",
|
||||
"invalidInput": "Zkontrolujte označená pole a zkuste to znovu."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Obnovit heslo",
|
||||
@@ -898,9 +927,11 @@
|
||||
"reset": {
|
||||
"title": "Nastavte nové heslo",
|
||||
"subtitle": "Vyberte si silné heslo, které jinde nepoužíváte.",
|
||||
"passwordPlaceholder": "Nové heslo (min. 6 znaků)",
|
||||
"passwordPlaceholder": "Nové heslo (min. 12 znaků)",
|
||||
"resetPassword": "Obnovit heslo",
|
||||
"backToLogin": "Zpět k přihlášení"
|
||||
"backToLogin": "Zpět k přihlášení",
|
||||
"passwordMinLength": "Heslo musí mít alespoň 12 znaků",
|
||||
"tooManyAttempts": "Příliš mnoho pokusů — zkuste to později"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "Vše je připraveno",
|
||||
|
||||
+47
-16
@@ -839,11 +839,15 @@
|
||||
"errorInvalid2fa": "Ugyldig 2FA-kode",
|
||||
"welcomeBack": "Velkommen tilbage",
|
||||
"welcomeBackSub": "Log ind for at fortsætte til {hotelName}",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"errorUnverified": "Bekræft din e-mail, før du logger ind.",
|
||||
"errorCaptcha": "Captcha-verificering mislykkedes. Prøv igen.",
|
||||
"whoIsOnline": "Hvem er online",
|
||||
"newestCitizens": "Nyeste borgere",
|
||||
"usersOnline": "{count} online",
|
||||
"username": "Brugernavn",
|
||||
"password": "Adgangskode",
|
||||
"showPassword": "Vis",
|
||||
"hidePassword": "Skjul"
|
||||
},
|
||||
"register": {
|
||||
"title": "Opret konto",
|
||||
@@ -854,7 +858,7 @@
|
||||
"confirmPassword": "Gentag adgangskoden",
|
||||
"usernamePlaceholder": "Brugernavn",
|
||||
"emailPlaceholder": "E-mail",
|
||||
"passwordPlaceholder": "Adgangskode (min. 6 tegn)",
|
||||
"passwordPlaceholder": "Adgangskode (min. 12 tegn)",
|
||||
"confirmPasswordPlaceholder": "Gentag adgangskoden",
|
||||
"selectOutfit": "Vælg dit outfit",
|
||||
"termsAccept": "Jeg er 18+ og accepterer vilkårene og reglerne for {hotel}.",
|
||||
@@ -866,7 +870,7 @@
|
||||
"termsError": "Du skal acceptere vilkårene og reglerne for at registrere dig.",
|
||||
"usernameError": "Brugernavn er påkrævet.",
|
||||
"emailError": "Gyldig e-mail er påkrævet.",
|
||||
"passwordError": "Adgangskoden skal være på mindst 6 tegn.",
|
||||
"passwordError": "Adgangskoden skal være på mindst 12 tegn",
|
||||
"confirmPasswordError": "Adgangskoder stemmer ikke overens.",
|
||||
"termsRequired": "Du skal acceptere vilkårene og reglerne.",
|
||||
"usernameRequired": "Brugernavn er påkrævet.",
|
||||
@@ -877,14 +881,39 @@
|
||||
"alreadyHaveAccountLink": "Log ind",
|
||||
"whoIsOnline": "Hvem er online",
|
||||
"usersOnline": "{count} online",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"accepted": "Accepteret og godkendt",
|
||||
"showPassword": "Vis",
|
||||
"hidePassword": "Skjul",
|
||||
"accountDetails": "Kontooplysninger",
|
||||
"credentials": "Loginoplysninger og sikkerhed",
|
||||
"newestCitizens": "Nyeste borgere",
|
||||
"ageVerified": "Jeg er 18+ og accepterer alderskravet.",
|
||||
"invitedBy": "Inviteret af {username}"
|
||||
"invitedBy": "Inviteret af {username}",
|
||||
"strengthWeak": "Svag",
|
||||
"strengthFair": "Middelmådig",
|
||||
"strengthGood": "God",
|
||||
"strengthStrong": "Stærk",
|
||||
"passwordMinLength": "Adgangskoden skal være på mindst 12 tegn",
|
||||
"passwordUpper": "Adgangskoden skal indeholde mindst ét stort bogstav",
|
||||
"passwordLower": "Adgangskoden skal indeholde mindst ét lille bogstav",
|
||||
"passwordDigit": "Adgangskoden skal indeholde mindst ét tal",
|
||||
"passwordSpecial": "Adgangskoden skal indeholde mindst ét specialtegn",
|
||||
"passwordMaxLength": "Adgangskoden er for lang",
|
||||
"usernameMinLength": "Brugernavnet skal være på mindst 3 tegn",
|
||||
"usernameMaxLength": "Brugernavnet må højst være på 25 tegn",
|
||||
"usernamePattern": "Brugernavnet må kun indeholde bogstaver, tal, understreg og bindestreg",
|
||||
"usernameReserved": "Dette brugernavn er reserveret",
|
||||
"emailValid": "Indtast en gyldig e-mailadresse",
|
||||
"emailDisposable": "Midlertidige e-maildomæner er ikke tilladt",
|
||||
"passwordsMatch": "Adgangskoder stemmer ikke overens.",
|
||||
"captchaFailed": "Captcha-verificering mislykkedes. Prøv igen.",
|
||||
"usernameTaken": "Det brugernavn er allerede taget",
|
||||
"rateLimited": "For mange registreringsforsøg. Vent et par minutter, og prøv igen.",
|
||||
"vpnBlocked": "Registrering via VPN/proxy-forbindelser er ikke tilladt.",
|
||||
"maxAccountsPerIp": "Du har nået det maksimale antal konti for din forbindelse.",
|
||||
"unavailable": "Registrering er midlertidigt utilgængelig.",
|
||||
"createFailed": "Kontoen kunne ikke oprettes. Prøv igen, eller kontakt personalet.",
|
||||
"invalidInput": "Kontrollér de markerede felter, og prøv igen."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Nulstil adgangskode",
|
||||
@@ -898,9 +927,11 @@
|
||||
"reset": {
|
||||
"title": "Indstil en ny adgangskode",
|
||||
"subtitle": "Vælg en stærk adgangskode, du ikke bruger andre steder.",
|
||||
"passwordPlaceholder": "Ny adgangskode (min. 6 tegn)",
|
||||
"passwordPlaceholder": "Ny adgangskode (min. 12 tegn)",
|
||||
"resetPassword": "Nulstil adgangskode",
|
||||
"backToLogin": "Tilbage til login"
|
||||
"backToLogin": "Tilbage til login",
|
||||
"passwordMinLength": "Adgangskoden skal være på mindst 12 tegn",
|
||||
"tooManyAttempts": "For mange forsøg — prøv igen senere"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "Du er klar",
|
||||
|
||||
+47
-16
@@ -809,11 +809,15 @@
|
||||
"errorInvalid2fa": "Ungültiger 2FA-Code",
|
||||
"welcomeBack": "Willkommen zurück",
|
||||
"welcomeBackSub": "Melde dich an, um zu {hotelName} zu gelangen",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"errorUnverified": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.",
|
||||
"errorCaptcha": "Captcha-Verifizierung fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"whoIsOnline": "Wer ist online",
|
||||
"newestCitizens": "Neueste Bürger",
|
||||
"usersOnline": "{count} online",
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"showPassword": "Anzeigen",
|
||||
"hidePassword": "Verbergen"
|
||||
},
|
||||
"register": {
|
||||
"title": "Konto erstellen",
|
||||
@@ -824,7 +828,7 @@
|
||||
"confirmPassword": "Passwort wiederholen",
|
||||
"usernamePlaceholder": "Benutzername",
|
||||
"emailPlaceholder": "E-Mail",
|
||||
"passwordPlaceholder": "Passwort (min. 6 Zeichen)",
|
||||
"passwordPlaceholder": "Passwort (min. 12 Zeichen)",
|
||||
"confirmPasswordPlaceholder": "Passwort wiederholen",
|
||||
"selectOutfit": "Wähle dein Outfit",
|
||||
"termsAccept": "Ich bin 18+ und akzeptiere die {hotel} AGB & Regeln.",
|
||||
@@ -836,7 +840,7 @@
|
||||
"termsError": "Du musst die AGB & Regeln akzeptieren, um dich zu registrieren.",
|
||||
"usernameError": "Benutzername ist erforderlich.",
|
||||
"emailError": "Eine gültige E-Mail ist erforderlich.",
|
||||
"passwordError": "Das Passwort muss mindestens 6 Zeichen lang sein.",
|
||||
"passwordError": "Das Passwort muss mindestens 12 Zeichen lang sein",
|
||||
"confirmPasswordError": "Passwörter stimmen nicht überein.",
|
||||
"termsRequired": "Du musst die AGB & Regeln akzeptieren.",
|
||||
"usernameRequired": "Benutzername ist erforderlich.",
|
||||
@@ -847,14 +851,39 @@
|
||||
"alreadyHaveAccountLink": "Anmelden",
|
||||
"whoIsOnline": "Wer ist online",
|
||||
"usersOnline": "{count} online",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"accepted": "Akzeptiert & zugestimmt",
|
||||
"showPassword": "Anzeigen",
|
||||
"hidePassword": "Verbergen",
|
||||
"accountDetails": "Kontodaten",
|
||||
"credentials": "Zugangsdaten & Sicherheit",
|
||||
"newestCitizens": "Neueste Bürger",
|
||||
"ageVerified": "Ich bin 18+ und stimme der Altersanforderung zu.",
|
||||
"invitedBy": "Eingeladen von {username}"
|
||||
"invitedBy": "Eingeladen von {username}",
|
||||
"strengthWeak": "Schwach",
|
||||
"strengthFair": "Mittel",
|
||||
"strengthGood": "Gut",
|
||||
"strengthStrong": "Stark",
|
||||
"passwordMinLength": "Das Passwort muss mindestens 12 Zeichen lang sein",
|
||||
"passwordUpper": "Das Passwort muss mindestens einen Großbuchstaben enthalten",
|
||||
"passwordLower": "Das Passwort muss mindestens einen Kleinbuchstaben enthalten",
|
||||
"passwordDigit": "Das Passwort muss mindestens eine Ziffer enthalten",
|
||||
"passwordSpecial": "Das Passwort muss mindestens ein Sonderzeichen enthalten",
|
||||
"passwordMaxLength": "Das Passwort ist zu lang",
|
||||
"usernameMinLength": "Der Benutzername muss mindestens 3 Zeichen lang sein",
|
||||
"usernameMaxLength": "Der Benutzername darf höchstens 25 Zeichen lang sein",
|
||||
"usernamePattern": "Der Benutzername darf nur Buchstaben, Ziffern, Unterstrich und Bindestrich enthalten",
|
||||
"usernameReserved": "Dieser Benutzername ist reserviert",
|
||||
"emailValid": "Gib eine gültige E-Mail-Adresse ein",
|
||||
"emailDisposable": "Temporäre E-Mail-Domains sind nicht erlaubt",
|
||||
"passwordsMatch": "Passwörter stimmen nicht überein.",
|
||||
"captchaFailed": "Captcha-Verifizierung fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"usernameTaken": "Dieser Benutzername ist bereits vergeben",
|
||||
"rateLimited": "Zu viele Registrierungsversuche. Bitte warte ein paar Minuten und versuche es erneut.",
|
||||
"vpnBlocked": "Registrierungen über VPN-/Proxy-Verbindungen sind nicht erlaubt.",
|
||||
"maxAccountsPerIp": "Du hast die maximale Anzahl an Konten für deine Verbindung erreicht.",
|
||||
"unavailable": "Die Registrierung ist vorübergehend nicht verfügbar.",
|
||||
"createFailed": "Das Konto konnte nicht erstellt werden. Versuche es erneut oder wende dich an das Personal.",
|
||||
"invalidInput": "Bitte prüfe die markierten Felder und versuche es erneut."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Passwort zurücksetzen",
|
||||
@@ -868,9 +897,11 @@
|
||||
"reset": {
|
||||
"title": "Neues Passwort festlegen",
|
||||
"subtitle": "Wähle ein sicheres Passwort, das du sonst nirgendwo verwendest.",
|
||||
"passwordPlaceholder": "Neues Passwort (min. 6 Zeichen)",
|
||||
"passwordPlaceholder": "Neues Passwort (min. 12 Zeichen)",
|
||||
"resetPassword": "Passwort zurücksetzen",
|
||||
"backToLogin": "Zurück zur Anmeldung"
|
||||
"backToLogin": "Zurück zur Anmeldung",
|
||||
"passwordMinLength": "Das Passwort muss mindestens 12 Zeichen lang sein",
|
||||
"tooManyAttempts": "Zu viele Versuche — bitte später erneut probieren"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "Du bist startklar",
|
||||
|
||||
+49
-18
@@ -839,11 +839,15 @@
|
||||
"errorInvalid2fa": "Μη έγκυρος κωδικός 2FA",
|
||||
"welcomeBack": "Καλώς ήρθες πίσω",
|
||||
"welcomeBackSub": "Συνδέσου για να συνεχίσεις στο {hotelName}",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"errorUnverified": "Επαληθεύστε το email σας πριν συνδεθείτε.",
|
||||
"errorCaptcha": "Η επαλήθευση captcha απέτυχε. Δοκιμάστε ξανά.",
|
||||
"whoIsOnline": "Ποιος είναι σε σύνδεση",
|
||||
"newestCitizens": "Νέοι πολίτες",
|
||||
"usersOnline": "{count} σε σύνδεση",
|
||||
"username": "Όνομα χρήστη",
|
||||
"password": "Κωδικός πρόσβασης",
|
||||
"showPassword": "Εμφάνιση",
|
||||
"hidePassword": "Απόκρυψη"
|
||||
},
|
||||
"register": {
|
||||
"title": "Δημιουργία λογαριασμού",
|
||||
@@ -854,7 +858,7 @@
|
||||
"confirmPassword": "Επαναλάβετε τον κωδικό πρόσβασης",
|
||||
"usernamePlaceholder": "Όνομα χρήστη",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Κωδικός πρόσβασης (ελάχ. 6 χαρακτήρες)",
|
||||
"passwordPlaceholder": "Κωδικός πρόσβασης (ελάχ. 12 χαρακτήρες)",
|
||||
"confirmPasswordPlaceholder": "Επαναλάβετε τον κωδικό πρόσβασης",
|
||||
"selectOutfit": "Επιλέξτε το ντύσιμό σας",
|
||||
"termsAccept": "Είμαι 18+ και αποδέχομαι τους όρους και τους κανόνες του {hotel}.",
|
||||
@@ -866,7 +870,7 @@
|
||||
"termsError": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες για να εγγραφείτε.",
|
||||
"usernameError": "Απαιτείται όνομα χρήστη.",
|
||||
"emailError": "Απαιτείται έγκυρο email.",
|
||||
"passwordError": "Ο κωδικός πρόσβασης πρέπει να αποτελείται από τουλάχιστον 6 χαρακτήρες.",
|
||||
"passwordError": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
|
||||
"confirmPasswordError": "Οι κωδικοί πρόσβασης δεν ταιριάζουν.",
|
||||
"termsRequired": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες.",
|
||||
"usernameRequired": "Απαιτείται όνομα χρήστη.",
|
||||
@@ -875,16 +879,41 @@
|
||||
"passwordsDontMatch": "Οι κωδικοί πρόσβασης δεν ταιριάζουν.",
|
||||
"alreadyHaveAccountPre": "Έχεις ήδη λογαριασμό;",
|
||||
"alreadyHaveAccountLink": "Σύνδεση",
|
||||
"whoIsOnline": "Ποιος είναι συνδεδεμένος",
|
||||
"usersOnline": "{count} συνδεδεμένοι",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"whoIsOnline": "Ποιος είναι σε σύνδεση",
|
||||
"usersOnline": "{count} σε σύνδεση",
|
||||
"accepted": "Αποδεκτό & συμφωνώ",
|
||||
"showPassword": "Εμφάνιση",
|
||||
"hidePassword": "Απόκρυψη",
|
||||
"accountDetails": "Στοιχεία λογαριασμού",
|
||||
"credentials": "Στοιχεία σύνδεσης & ασφάλεια",
|
||||
"newestCitizens": "Νέοι πολίτες",
|
||||
"ageVerified": "Είμαι 18+ και αποδέχομαι την απαίτηση ηλικίας.",
|
||||
"invitedBy": "Προσκλήθηκε από {username}"
|
||||
"invitedBy": "Προσκλήθηκε από {username}",
|
||||
"strengthWeak": "Ασθενές",
|
||||
"strengthFair": "Μέτριο",
|
||||
"strengthGood": "Καλό",
|
||||
"strengthStrong": "Δυνατό",
|
||||
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
|
||||
"passwordUpper": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα κεφαλαίο γράμμα",
|
||||
"passwordLower": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα πεζό γράμμα",
|
||||
"passwordDigit": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα ψηφίο",
|
||||
"passwordSpecial": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον έναν ειδικό χαρακτήρα",
|
||||
"passwordMaxLength": "Ο κωδικός πρόσβασης είναι πολύ μεγάλος",
|
||||
"usernameMinLength": "Το όνομα χρήστη πρέπει να έχει τουλάχιστον 3 χαρακτήρες",
|
||||
"usernameMaxLength": "Το όνομα χρήστη μπορεί να έχει έως 25 χαρακτήρες",
|
||||
"usernamePattern": "Το όνομα χρήστη μπορεί να περιέχει μόνο γράμματα, ψηφία, κάτω παύλα και παύλα",
|
||||
"usernameReserved": "Αυτό το όνομα χρήστη είναι δεσμευμένο",
|
||||
"emailValid": "Εισαγάγετε μια έγκυρη διεύθυνση email",
|
||||
"emailDisposable": "Τα προσωρινά email domains δεν επιτρέπονται",
|
||||
"passwordsMatch": "Οι κωδικοί πρόσβασης δεν ταιριάζουν.",
|
||||
"captchaFailed": "Η επαλήθευση captcha απέτυχε. Δοκιμάστε ξανά.",
|
||||
"usernameTaken": "Αυτό το όνομα χρήστη χρησιμοποιείται ήδη",
|
||||
"rateLimited": "Πάρα πολλές προσπάθειες εγγραφής. Περίμενε λίγα λεπτά και δοκίμασε ξανά.",
|
||||
"vpnBlocked": "Οι εγγραφές μέσω VPN/proxy δεν επιτρέπονται.",
|
||||
"maxAccountsPerIp": "Έφτασες τον μέγιστο αριθμό λογαριασμών για τη σύνδεσή σου.",
|
||||
"unavailable": "Η εγγραφή δεν είναι προσωρινά διαθέσιμη.",
|
||||
"createFailed": "Δεν ήταν δυνατή η δημιουργία του λογαριασμού. Δοκίμασε ξανά ή επικοινώνησε με το προσωπικό.",
|
||||
"invalidInput": "Ελέγξτε τα επισημασμένα πεδία και δοκιμάστε ξανά."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Επαναφορά κωδικού πρόσβασης",
|
||||
@@ -898,9 +927,11 @@
|
||||
"reset": {
|
||||
"title": "Ορίστε νέο κωδικό πρόσβασης",
|
||||
"subtitle": "Επιλέξτε έναν ισχυρό κωδικό πρόσβασης που δεν χρησιμοποιείτε αλλού.",
|
||||
"passwordPlaceholder": "Νέος κωδικός πρόσβασης (ελάχ. 6 χαρακτήρες)",
|
||||
"passwordPlaceholder": "Νέος κωδικός πρόσβασης (ελάχ. 12 χαρακτήρες)",
|
||||
"resetPassword": "Επαναφορά κωδικού πρόσβασης",
|
||||
"backToLogin": "Επιστροφή στην είσοδο"
|
||||
"backToLogin": "Επιστροφή στην είσοδο",
|
||||
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
|
||||
"tooManyAttempts": "Πάρα πολλές προσπάθειες — δοκιμάστε αργότερα"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "Είστε έτοιμοι",
|
||||
|
||||
+37
-6
@@ -1015,7 +1015,11 @@
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"usersOnline": "{count} online",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"username": "Username",
|
||||
"password": "Password"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create account",
|
||||
@@ -1026,7 +1030,7 @@
|
||||
"confirmPassword": "Repeat password",
|
||||
"usernamePlaceholder": "Username",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Password (min 8 chars)",
|
||||
"passwordPlaceholder": "Password (min 12 chars)",
|
||||
"confirmPasswordPlaceholder": "Repeat password",
|
||||
"selectOutfit": "Select your outfit",
|
||||
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
|
||||
@@ -1049,14 +1053,39 @@
|
||||
"termsError": "You must accept the terms & rules to register.",
|
||||
"usernameError": "Username is required.",
|
||||
"emailError": "Valid email is required.",
|
||||
"passwordError": "Password must be at least 6 characters.",
|
||||
"passwordError": "Password must be at least 12 characters",
|
||||
"confirmPasswordError": "Passwords do not match.",
|
||||
"termsRequired": "You must accept the terms & rules.",
|
||||
"usernameRequired": "Username is required.",
|
||||
"emailRequired": "Email is required.",
|
||||
"passwordRequired": "Password is required.",
|
||||
"passwordsDontMatch": "Passwords do not match.",
|
||||
"invitedBy": "Invited by {username}"
|
||||
"invitedBy": "Invited by {username}",
|
||||
"strengthWeak": "Weak",
|
||||
"strengthFair": "Fair",
|
||||
"strengthGood": "Good",
|
||||
"strengthStrong": "Strong",
|
||||
"passwordMinLength": "Password must be at least 12 characters",
|
||||
"passwordUpper": "Password must contain at least one uppercase letter",
|
||||
"passwordLower": "Password must contain at least one lowercase letter",
|
||||
"passwordDigit": "Password must contain at least one digit",
|
||||
"passwordSpecial": "Password must contain at least one special character",
|
||||
"passwordMaxLength": "Password is too long",
|
||||
"usernameMinLength": "Username must be at least 3 characters",
|
||||
"usernameMaxLength": "Username must be at most 25 characters",
|
||||
"usernamePattern": "Username may only contain letters, numbers, underscore and hyphen",
|
||||
"usernameReserved": "This username is reserved",
|
||||
"emailValid": "Enter a valid email address",
|
||||
"emailDisposable": "Temporary email domains are not allowed",
|
||||
"passwordsMatch": "Passwords do not match",
|
||||
"usernameTaken": "That username is already taken",
|
||||
"captchaFailed": "Captcha verification failed. Please try again.",
|
||||
"rateLimited": "Too many sign-up attempts. Please wait a few minutes and try again.",
|
||||
"vpnBlocked": "Registrations from VPN/proxy connections are not allowed.",
|
||||
"maxAccountsPerIp": "You have reached the maximum number of accounts for your connection.",
|
||||
"unavailable": "Registration is temporarily unavailable.",
|
||||
"createFailed": "Could not create the account. Please try again or contact staff.",
|
||||
"invalidInput": "Please check the highlighted fields and try again."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Reset password",
|
||||
@@ -1070,9 +1099,11 @@
|
||||
"reset": {
|
||||
"title": "Set a new password",
|
||||
"subtitle": "Choose a strong password you don't use elsewhere.",
|
||||
"passwordPlaceholder": "New password (min 6 chars)",
|
||||
"passwordPlaceholder": "New password (min 12 chars)",
|
||||
"resetPassword": "Reset password",
|
||||
"backToLogin": "Back to login"
|
||||
"backToLogin": "Back to login",
|
||||
"passwordMinLength": "Password must be at least 12 characters",
|
||||
"tooManyAttempts": "Too many attempts — try again later"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "You're all set",
|
||||
|
||||
+48
-17
@@ -806,14 +806,18 @@
|
||||
"createOne": "Crea una",
|
||||
"forgotPassword": "¿Olvidaste tu contraseña?",
|
||||
"errorInvalidCredentials": "Usuario o contraseña inválidos",
|
||||
"errorInvalid2fa": "Código 2FA inválido",
|
||||
"errorInvalid2fa": "Código 2FA no válido",
|
||||
"welcomeBack": "Bienvenido de nuevo",
|
||||
"welcomeBackSub": "Inicia sesión para continuar en {hotelName}",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"errorUnverified": "Verifica tu correo electrónico antes de iniciar sesión.",
|
||||
"errorCaptcha": "La verificación captcha falló. Inténtalo de nuevo.",
|
||||
"whoIsOnline": "Quién está en línea",
|
||||
"newestCitizens": "Ciudadanos más recientes",
|
||||
"usersOnline": "{count} en línea",
|
||||
"username": "Usuario",
|
||||
"password": "Contraseña",
|
||||
"showPassword": "Mostrar",
|
||||
"hidePassword": "Ocultar"
|
||||
},
|
||||
"register": {
|
||||
"title": "Crear cuenta",
|
||||
@@ -824,7 +828,7 @@
|
||||
"confirmPassword": "Repetir contraseña",
|
||||
"usernamePlaceholder": "Usuario",
|
||||
"emailPlaceholder": "Correo electrónico",
|
||||
"passwordPlaceholder": "Contraseña (mín. 6 caracteres)",
|
||||
"passwordPlaceholder": "Contraseña (mín. 12 caracteres)",
|
||||
"confirmPasswordPlaceholder": "Repetir contraseña",
|
||||
"selectOutfit": "Selecciona tu atuendo",
|
||||
"termsAccept": "Soy mayor de 18 años y acepto los términos y reglas de {hotel}.",
|
||||
@@ -836,7 +840,7 @@
|
||||
"termsError": "Debes aceptar los términos y reglas para registrarte.",
|
||||
"usernameError": "El usuario es obligatorio.",
|
||||
"emailError": "Se requiere un correo electrónico válido.",
|
||||
"passwordError": "La contraseña debe tener al menos 6 caracteres.",
|
||||
"passwordError": "La contraseña debe tener al menos 12 caracteres",
|
||||
"confirmPasswordError": "Las contraseñas no coinciden.",
|
||||
"termsRequired": "Debes aceptar los términos y reglas.",
|
||||
"usernameRequired": "El usuario es obligatorio.",
|
||||
@@ -847,14 +851,39 @@
|
||||
"alreadyHaveAccountLink": "Iniciar sesión",
|
||||
"whoIsOnline": "Quién está en línea",
|
||||
"usersOnline": "{count} en línea",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"accepted": "Aceptado y de acuerdo",
|
||||
"showPassword": "Mostrar",
|
||||
"hidePassword": "Ocultar",
|
||||
"accountDetails": "Datos de la cuenta",
|
||||
"credentials": "Credenciales y seguridad",
|
||||
"newestCitizens": "Ciudadanos más recientes",
|
||||
"ageVerified": "Tengo 18+ y acepto el requisito de edad.",
|
||||
"invitedBy": "Invitado por {username}"
|
||||
"invitedBy": "Invitado por {username}",
|
||||
"strengthWeak": "Débil",
|
||||
"strengthFair": "Aceptable",
|
||||
"strengthGood": "Buena",
|
||||
"strengthStrong": "Fuerte",
|
||||
"passwordMinLength": "La contraseña debe tener al menos 12 caracteres",
|
||||
"passwordUpper": "La contraseña debe contener al menos una mayúscula",
|
||||
"passwordLower": "La contraseña debe contener al menos una minúscula",
|
||||
"passwordDigit": "La contraseña debe contener al menos un dígito",
|
||||
"passwordSpecial": "La contraseña debe contener al menos un carácter especial",
|
||||
"passwordMaxLength": "La contraseña es demasiado larga",
|
||||
"usernameMinLength": "El usuario debe tener al menos 3 caracteres",
|
||||
"usernameMaxLength": "El usuario puede tener como máximo 25 caracteres",
|
||||
"usernamePattern": "El usuario solo puede contener letras, números, guion bajo y guion",
|
||||
"usernameReserved": "Ese nombre de usuario está reservado",
|
||||
"emailValid": "Introduce una dirección de correo válida",
|
||||
"emailDisposable": "No se permiten dominios de correo temporales",
|
||||
"passwordsMatch": "Las contraseñas no coinciden.",
|
||||
"captchaFailed": "La verificación captcha falló. Inténtalo de nuevo.",
|
||||
"usernameTaken": "Ese nombre de usuario ya está en uso",
|
||||
"rateLimited": "Demasiados intentos de registro. Espera unos minutos e inténtalo de nuevo.",
|
||||
"vpnBlocked": "No se permiten registros mediante conexiones VPN/proxy.",
|
||||
"maxAccountsPerIp": "Has alcanzado el máximo de cuentas para tu conexión.",
|
||||
"unavailable": "El registro no está disponible temporalmente.",
|
||||
"createFailed": "No se pudo crear la cuenta. Inténtalo de nuevo o contacta con el personal.",
|
||||
"invalidInput": "Revisa los campos marcados e inténtalo de nuevo."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Restablecer contraseña",
|
||||
@@ -868,9 +897,11 @@
|
||||
"reset": {
|
||||
"title": "Establecer una nueva contraseña",
|
||||
"subtitle": "Elige una contraseña segura que no uses en otros sitios.",
|
||||
"passwordPlaceholder": "Nueva contraseña (mín. 6 caracteres)",
|
||||
"passwordPlaceholder": "Nueva contraseña (mín. 12 caracteres)",
|
||||
"resetPassword": "Restablecer contraseña",
|
||||
"backToLogin": "Volver al inicio de sesión"
|
||||
"backToLogin": "Volver al inicio de sesión",
|
||||
"passwordMinLength": "La contraseña debe tener al menos 12 caracteres",
|
||||
"tooManyAttempts": "Demasiados intentos — inténtalo más tarde"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "Todo está listo",
|
||||
|
||||
+94
-63
@@ -6067,69 +6067,98 @@
|
||||
"rateLimit": "Too many attempts. Please wait before trying again."
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"subtitle": "Welcome back — log in to enter the hotel.",
|
||||
"subtitle2fa": "Enter the 6-digit code from your authenticator.",
|
||||
"welcomeBack": "Welcome back",
|
||||
"welcomeBackSub": "Sign in to continue to {hotelName}",
|
||||
"usernamePlaceholder": "Username",
|
||||
"passwordPlaceholder": "Password",
|
||||
"codePlaceholder": "2FA code",
|
||||
"pleaseWait": "Please wait…",
|
||||
"verify": "Verify",
|
||||
"signIn": "Sign in",
|
||||
"or": "or",
|
||||
"noAccount": "No account?",
|
||||
"createOne": "Create one",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"errorInvalidCredentials": "Invalid username or password",
|
||||
"errorInvalid2fa": "Invalid 2FA code",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"title": "Kirjaudu sisään",
|
||||
"subtitle": "Kirjaudu sisään jatkaaksesi seuraavaan hotelliin.",
|
||||
"subtitle2fa": "Syötä 6-numeroinen koodi tunnistussovelluksestasi.",
|
||||
"welcomeBack": "Tervetuloa takaisin",
|
||||
"welcomeBackSub": "Kirjaudu sisään liittyäksesi hotelliin {hotelName}",
|
||||
"usernamePlaceholder": "Käyttäjätunnus",
|
||||
"passwordPlaceholder": "Salasana",
|
||||
"codePlaceholder": "Syötä 2FA-koodisi",
|
||||
"pleaseWait": "Odota hetki...",
|
||||
"verify": "Varmenna",
|
||||
"signIn": "Kirjaudu sisään",
|
||||
"or": "tai",
|
||||
"noAccount": "Nie vielä tiliä?",
|
||||
"createOne": "Luo tili",
|
||||
"forgotPassword": "Unohditko salasanasi?",
|
||||
"errorInvalidCredentials": "Väärä käyttäjätunnus tai salasana",
|
||||
"errorInvalid2fa": "Virheellinen 2FA-koodi",
|
||||
"errorUnverified": "Vahvista sähköpostiosoitteesi ennen kirjautumista.",
|
||||
"errorCaptcha": "Captcha-varmennus epäonnistui. Yritä uudelleen.",
|
||||
"whoIsOnline": "Ketkä ovat paikalla",
|
||||
"newestCitizens": "Uusimmat asukkaat",
|
||||
"usersOnline": "{count} paikalla",
|
||||
"username": "Käyttäjätunnus",
|
||||
"password": "Salasana",
|
||||
"showPassword": "Näytä",
|
||||
"hidePassword": "Piilota"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create account",
|
||||
"subtitle": "Join the hotel — it only takes a moment.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Repeat password",
|
||||
"usernamePlaceholder": "Username",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Password (min 8 chars)",
|
||||
"confirmPasswordPlaceholder": "Repeat password",
|
||||
"selectOutfit": "Select your outfit",
|
||||
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"createAccount": "Create account",
|
||||
"creatingAccount": "Creating account...",
|
||||
"redirecting": "Redirecting to your account...",
|
||||
"alreadyHaveAccount": "Already have an account? Login!",
|
||||
"alreadyHaveAccountPre": "Already have an account?",
|
||||
"alreadyHaveAccountLink": "Sign in",
|
||||
"register": "Register",
|
||||
"whoIsOnline": "Who's online",
|
||||
"usersOnline": "{count} online",
|
||||
"termsError": "You must accept the terms & rules to register.",
|
||||
"usernameError": "Username is required.",
|
||||
"emailError": "Valid email is required.",
|
||||
"passwordError": "Password must be at least 6 characters.",
|
||||
"confirmPasswordError": "Passwords do not match.",
|
||||
"termsRequired": "You must accept the terms & rules.",
|
||||
"usernameRequired": "Username is required.",
|
||||
"emailRequired": "Email is required.",
|
||||
"passwordRequired": "Password is required.",
|
||||
"passwordsDontMatch": "Passwords do not match.",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"ageVerified": "Olen 18+ ja hyväksyn ikävaatimuksen.",
|
||||
"invitedBy": "Kutsuja: {username}"
|
||||
"title": "Luo tili",
|
||||
"subtitle": "Liity hotelliin — se kestää vain hetken.",
|
||||
"username": "Käyttäjätunnus",
|
||||
"email": "Sähköposti",
|
||||
"password": "Salasana",
|
||||
"confirmPassword": "Toista salasana",
|
||||
"usernamePlaceholder": "Käyttäjätunnus",
|
||||
"emailPlaceholder": "Sähköposti",
|
||||
"passwordPlaceholder": "Salasana (vähintään 12 merkkiä)",
|
||||
"confirmPasswordPlaceholder": "Toista salasana",
|
||||
"selectOutfit": "Valitse asustesi",
|
||||
"termsAccept": "Olen 18-vuotias ja hyväksyn {hotel} säännöt.",
|
||||
"accepted": "Hyväksytty ja hyväksyn",
|
||||
"showPassword": "Näytä",
|
||||
"hidePassword": "Piilota",
|
||||
"accountDetails": "Tilitiedot",
|
||||
"credentials": "Kirjautumistiedot ja turvallisuus",
|
||||
"createAccount": "Luo tili",
|
||||
"creatingAccount": "Luodaan tiliä...",
|
||||
"redirecting": "Siirrytään tilillesi...",
|
||||
"alreadyHaveAccount": "Onko sinulla jo tili? Kirjaudu sisään!",
|
||||
"alreadyHaveAccountPre": "Onko sinulla jo tili?",
|
||||
"alreadyHaveAccountLink": "Kirjaudu sisään",
|
||||
"register": "Rekisteröidy",
|
||||
"whoIsOnline": "Ketkä ovat paikalla",
|
||||
"usersOnline": "{count} paikalla",
|
||||
"termsError": "Sinun on hyväksyttävä säännöt rekisteröityäksesi.",
|
||||
"usernameError": "Käyttäjätunnus on pakollinen.",
|
||||
"emailError": "Kelvollinen sähköpostiosoite on pakollinen.",
|
||||
"passwordError": "Salasanassa on oltava vähintään 12 merkkiä",
|
||||
"confirmPasswordError": "Salasanat eivät täsmää.",
|
||||
"termsRequired": "Sinun on hyväksyttävä säännöt.",
|
||||
"usernameRequired": "Käyttäjätunnus on pakollinen.",
|
||||
"emailRequired": "Sähköpostiosoite on pakollinen.",
|
||||
"passwordRequired": "Salasana on pakollinen.",
|
||||
"passwordsDontMatch": "Salasanat eivät täsmää.",
|
||||
"newestCitizens": "Uusimmat asukkaat",
|
||||
"ageVerified": "Olen 18-vuotias ja hyväksyn ikärajan.",
|
||||
"invitedBy": "Kutsuja: {username}",
|
||||
"strengthWeak": "Heikko",
|
||||
"strengthFair": "Tyydyttävä",
|
||||
"strengthGood": "Hyvä",
|
||||
"strengthStrong": "Vahva",
|
||||
"passwordMinLength": "Salasanassa on oltava vähintään 12 merkkiä",
|
||||
"passwordUpper": "Salasanassa on oltava vähintään yksi iso kirjain",
|
||||
"passwordLower": "Salasanassa on oltava vähintään yksi pieni kirjain",
|
||||
"passwordDigit": "Salasanassa on oltava vähintään yksi numero",
|
||||
"passwordSpecial": "Salasanassa on oltava vähintään yksi erikoismerkki",
|
||||
"passwordMaxLength": "Salasana on liian pitkä",
|
||||
"usernameMinLength": "Käyttäjätunnus on oltava vähintään 3 merkkiä",
|
||||
"usernameMaxLength": "Käyttäjätunnus saa olla korkeintaan 25 merkkiä",
|
||||
"usernamePattern": "Käyttäjätunnus voi sisältää vain kirjaimia, numeroita, alaviivan ja viivan",
|
||||
"usernameReserved": "Tämä käyttäjätunnus on varattu",
|
||||
"emailValid": "Anna kelvollinen sähköpostiosoite",
|
||||
"emailDisposable": "Väliaikaisia sähköpostiosoitteita ei sallita",
|
||||
"passwordsMatch": "Passwords do not match.",
|
||||
"captchaFailed": "Captcha-varmennus epäonnistui. Yritä uudelleen.",
|
||||
"usernameTaken": "Tämä käyttäjätunnus on jo käytössä",
|
||||
"rateLimited": "Liikaa rekisteröintiyrityksiä. Odota muutama minuutti ja yritä uudelleen.",
|
||||
"vpnBlocked": "Rekisteröityminen VPN/proxy-yhteyksien kautta ei ole sallittua.",
|
||||
"maxAccountsPerIp": "Olet saavuttanut yhteytesi enimmäismäärän tilejä.",
|
||||
"unavailable": "Rekisteröityminen ei ole väliaikaisesti käytettävissä.",
|
||||
"createFailed": "Tiliä ei voitu luoda. Yritä uudelleen tai ota yhteyttä henkilökuntaan.",
|
||||
"invalidInput": "Tarkista korostetut kentät ja yritä uudelleen."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Reset password",
|
||||
@@ -6143,9 +6172,11 @@
|
||||
"reset": {
|
||||
"title": "Set a new password",
|
||||
"subtitle": "Choose a strong password you don't use elsewhere.",
|
||||
"passwordPlaceholder": "New password (min 6 chars)",
|
||||
"passwordPlaceholder": "New password (min 12 chars)",
|
||||
"resetPassword": "Reset password",
|
||||
"backToLogin": "Back to login"
|
||||
"backToLogin": "Back to login",
|
||||
"passwordMinLength": "Salasanassa on oltava vähintään 12 merkkiä",
|
||||
"tooManyAttempts": "Liikaa yrityksiä — yritä myöhemmin uudelleen"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "You're all set",
|
||||
|
||||
+48
-17
@@ -806,14 +806,18 @@
|
||||
"createOne": "Créer un compte",
|
||||
"forgotPassword": "Mot de passe oublié ?",
|
||||
"errorInvalidCredentials": "Nom d'utilisateur ou mot de passe invalide",
|
||||
"errorInvalid2fa": "Code A2F invalide",
|
||||
"errorInvalid2fa": "Code 2FA invalide",
|
||||
"welcomeBack": "Bon retour",
|
||||
"welcomeBackSub": "Connecte-toi pour continuer vers {hotelName}",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"errorUnverified": "Veuillez vérifier votre e-mail avant de vous connecter.",
|
||||
"errorCaptcha": "La vérification captcha a échoué. Réessayez.",
|
||||
"whoIsOnline": "Qui est en ligne",
|
||||
"newestCitizens": "Nouveaux citoyens",
|
||||
"usersOnline": "{count} en ligne",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"showPassword": "Afficher",
|
||||
"hidePassword": "Masquer"
|
||||
},
|
||||
"register": {
|
||||
"title": "Créer un compte",
|
||||
@@ -824,7 +828,7 @@
|
||||
"confirmPassword": "Répéter le mot de passe",
|
||||
"usernamePlaceholder": "Nom d'utilisateur",
|
||||
"emailPlaceholder": "E-mail",
|
||||
"passwordPlaceholder": "Mot de passe (min 6 car.)",
|
||||
"passwordPlaceholder": "Mot de passe (min 12 car.)",
|
||||
"confirmPasswordPlaceholder": "Répéter le mot de passe",
|
||||
"selectOutfit": "Sélectionnez votre tenue",
|
||||
"termsAccept": "J'ai 18 ans ou plus et j'accepte les conditions et règles de {hotel}.",
|
||||
@@ -836,7 +840,7 @@
|
||||
"termsError": "Vous devez accepter les conditions et règles pour vous inscrire.",
|
||||
"usernameError": "Le nom d'utilisateur est requis.",
|
||||
"emailError": "Un e-mail valide est requis.",
|
||||
"passwordError": "Le mot de passe doit contenir au moins 6 caractères.",
|
||||
"passwordError": "Le mot de passe doit contenir au moins 12 caractères",
|
||||
"confirmPasswordError": "Les mots de passe ne correspondent pas.",
|
||||
"termsRequired": "Vous devez accepter les conditions et règles.",
|
||||
"usernameRequired": "Le nom d'utilisateur est requis.",
|
||||
@@ -847,14 +851,39 @@
|
||||
"alreadyHaveAccountLink": "Se connecter",
|
||||
"whoIsOnline": "Qui est en ligne",
|
||||
"usersOnline": "{count} en ligne",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"accepted": "Accepté et d'accord",
|
||||
"showPassword": "Afficher",
|
||||
"hidePassword": "Masquer",
|
||||
"accountDetails": "Informations du compte",
|
||||
"credentials": "Identifiants et sécurité",
|
||||
"newestCitizens": "Nouveaux citoyens",
|
||||
"ageVerified": "J'ai 18+ et j'accepte la condition d'âge.",
|
||||
"invitedBy": "Invité par {username}"
|
||||
"invitedBy": "Invité par {username}",
|
||||
"strengthWeak": "Faible",
|
||||
"strengthFair": "Moyen",
|
||||
"strengthGood": "Bon",
|
||||
"strengthStrong": "Fort",
|
||||
"passwordMinLength": "Le mot de passe doit contenir au moins 12 caractères",
|
||||
"passwordUpper": "Le mot de passe doit contenir au moins une majuscule",
|
||||
"passwordLower": "Le mot de passe doit contenir au moins une minuscule",
|
||||
"passwordDigit": "Le mot de passe doit contenir au moins un chiffre",
|
||||
"passwordSpecial": "Le mot de passe doit contenir au moins un caractère spécial",
|
||||
"passwordMaxLength": "Le mot de passe est trop long",
|
||||
"usernameMinLength": "Le nom d'utilisateur doit contenir au moins 3 caractères",
|
||||
"usernameMaxLength": "Le nom d'utilisateur ne peut pas dépasser 25 caractères",
|
||||
"usernamePattern": "Le nom d'utilisateur ne peut contenir que des lettres, chiffres, tirets bas et tirets",
|
||||
"usernameReserved": "Ce nom d'utilisateur est réservé",
|
||||
"emailValid": "Saisissez une adresse e-mail valide",
|
||||
"emailDisposable": "Les domaines e-mail temporaires ne sont pas autorisés",
|
||||
"passwordsMatch": "Les mots de passe ne correspondent pas.",
|
||||
"captchaFailed": "La vérification captcha a échoué. Réessayez.",
|
||||
"usernameTaken": "Ce nom d'utilisateur est déjà pris",
|
||||
"rateLimited": "Trop de tentatives d'inscription. Patientez quelques minutes et réessayez.",
|
||||
"vpnBlocked": "Les inscriptions via une connexion VPN/proxy ne sont pas autorisées.",
|
||||
"maxAccountsPerIp": "Vous avez atteint le nombre maximal de comptes pour votre connexion.",
|
||||
"unavailable": "L'inscription est temporairement indisponible.",
|
||||
"createFailed": "Impossible de créer le compte. Réessayez ou contactez le personnel.",
|
||||
"invalidInput": "Veuillez vérifier les champs en surbrillance et réessayer."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Réinitialiser le mot de passe",
|
||||
@@ -868,9 +897,11 @@
|
||||
"reset": {
|
||||
"title": "Définir un nouveau mot de passe",
|
||||
"subtitle": "Choisissez un mot de passe fort que vous n'utilisez pas ailleurs.",
|
||||
"passwordPlaceholder": "Nouveau mot de passe (min 6 car.)",
|
||||
"passwordPlaceholder": "Nouveau mot de passe (min 12 car.)",
|
||||
"resetPassword": "Réinitialiser le mot de passe",
|
||||
"backToLogin": "Retour à la connexion"
|
||||
"backToLogin": "Retour à la connexion",
|
||||
"passwordMinLength": "Le mot de passe doit contenir au moins 12 caractères",
|
||||
"tooManyAttempts": "Trop de tentatives — réessayez plus tard"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "Tout est prêt",
|
||||
|
||||
+50
-19
@@ -836,14 +836,18 @@
|
||||
"createOne": "Stvorite jedan",
|
||||
"forgotPassword": "Zaboravili ste lozinku?",
|
||||
"errorInvalidCredentials": "Nevažeće korisničko ime ili lozinka",
|
||||
"errorInvalid2fa": "Nevažeći 2FA kod",
|
||||
"errorInvalid2fa": "Neispravan 2FA kod",
|
||||
"welcomeBack": "Dobrodošao natrag",
|
||||
"welcomeBackSub": "Prijavi se za nastavak u {hotelName}",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"errorUnverified": "Potvrdite svoju e-poštu prije prijave.",
|
||||
"errorCaptcha": "Captcha verifikacija nije uspjela. Pokušajte ponovno.",
|
||||
"whoIsOnline": "Tko je na mreži",
|
||||
"newestCitizens": "Najnoviji građani",
|
||||
"usersOnline": "{count} na mreži",
|
||||
"username": "Korisničko ime",
|
||||
"password": "Lozinka",
|
||||
"showPassword": "Prikaži",
|
||||
"hidePassword": "Sakrij"
|
||||
},
|
||||
"register": {
|
||||
"title": "Napravi račun",
|
||||
@@ -854,7 +858,7 @@
|
||||
"confirmPassword": "Ponovi lozinku",
|
||||
"usernamePlaceholder": "Korisničko ime",
|
||||
"emailPlaceholder": "E-mail",
|
||||
"passwordPlaceholder": "Lozinka (najmanje 6 znakova)",
|
||||
"passwordPlaceholder": "Lozinka (najmanje 12 znakova)",
|
||||
"confirmPasswordPlaceholder": "Ponovi lozinku",
|
||||
"selectOutfit": "Odaberite svoju odjeću",
|
||||
"termsAccept": "Imam 18+ godina i prihvaćam odredbe i pravila hotela {hotel}.",
|
||||
@@ -866,7 +870,7 @@
|
||||
"termsError": "Za registraciju morate prihvatiti uvjete i pravila.",
|
||||
"usernameError": "Korisničko ime je potrebno.",
|
||||
"emailError": "Potrebna je valjana adresa e-pošte.",
|
||||
"passwordError": "Lozinka mora imati najmanje 6 znakova.",
|
||||
"passwordError": "Lozinka mora imati najmanje 12 znakova.",
|
||||
"confirmPasswordError": "Lozinke se ne podudaraju.",
|
||||
"termsRequired": "Morate prihvatiti uvjete i pravila.",
|
||||
"usernameRequired": "Korisničko ime je potrebno.",
|
||||
@@ -875,16 +879,41 @@
|
||||
"passwordsDontMatch": "Lozinke se ne podudaraju.",
|
||||
"alreadyHaveAccountPre": "Već imaš račun?",
|
||||
"alreadyHaveAccountLink": "Prijavi se",
|
||||
"whoIsOnline": "Tko je online",
|
||||
"usersOnline": "{count} online",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"whoIsOnline": "Tko je na mreži",
|
||||
"usersOnline": "{count} na mreži",
|
||||
"accepted": "Prihvaćeno i suglasan sam",
|
||||
"showPassword": "Prikaži",
|
||||
"hidePassword": "Sakrij",
|
||||
"accountDetails": "Podaci računa",
|
||||
"credentials": "Podaci za prijavu i sigurnost",
|
||||
"newestCitizens": "Najnoviji građani",
|
||||
"ageVerified": "Imam 18+ i prihvaćam uvjet dobi.",
|
||||
"invitedBy": "Pozvao {username}"
|
||||
"invitedBy": "Pozvao {username}",
|
||||
"strengthWeak": "Slaba",
|
||||
"strengthFair": "Osrednja",
|
||||
"strengthGood": "Dobra",
|
||||
"strengthStrong": "Jaka",
|
||||
"passwordMinLength": "Lozinka mora imati najmanje 12 znakova",
|
||||
"passwordUpper": "Lozinka mora sadržavati najmanje jedno veliko slovo",
|
||||
"passwordLower": "Lozinka mora sadržavati najmanje jedno malo slovo",
|
||||
"passwordDigit": "Lozinka mora sadržavati najmanje jednu znamenku",
|
||||
"passwordSpecial": "Lozinka mora sadržavati najmanje jedan poseban znak",
|
||||
"passwordMaxLength": "Lozinka je preduga",
|
||||
"usernameMinLength": "Korisničko ime mora imati najmanje 3 znaka",
|
||||
"usernameMaxLength": "Korisničko ime smije imati najviše 25 znakova",
|
||||
"usernamePattern": "Korisničko ime smije sadržavati samo slova, brojeve, podvlaku i crticu",
|
||||
"usernameReserved": "Ovo korisničko ime je rezervirano",
|
||||
"emailValid": "Unesite valjanu adresu e-pošte",
|
||||
"emailDisposable": "Privremene domene e-pošte nisu dopuštene",
|
||||
"passwordsMatch": "Lozinke se ne podudaraju.",
|
||||
"captchaFailed": "Captcha verifikacija nije uspjela. Pokušajte ponovno.",
|
||||
"usernameTaken": "Ovo korisničko ime je već zauzeto",
|
||||
"rateLimited": "Previše pokušaja registracije. Pričekajte nekoliko minuta i pokušajte ponovno.",
|
||||
"vpnBlocked": "Registracija putem VPN/proxy veza nije dopuštena.",
|
||||
"maxAccountsPerIp": "Dosegnuli ste najveći broj računa za svoju vezu.",
|
||||
"unavailable": "Registracija je privremeno nedostupna.",
|
||||
"createFailed": "Račun nije mogao biti stvoren. Pokušajte ponovno ili se obratite osoblju.",
|
||||
"invalidInput": "Provjerite označena polja i pokušajte ponovno."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Resetiraj lozinku",
|
||||
@@ -898,9 +927,11 @@
|
||||
"reset": {
|
||||
"title": "Postavite novu lozinku",
|
||||
"subtitle": "Odaberite jaku lozinku koju ne koristite drugdje.",
|
||||
"passwordPlaceholder": "Nova lozinka (najmanje 6 znakova)",
|
||||
"passwordPlaceholder": "Nova lozinka (najmanje 12 znakova)",
|
||||
"resetPassword": "Resetiraj lozinku",
|
||||
"backToLogin": "Natrag na prijavu"
|
||||
"backToLogin": "Natrag na prijavu",
|
||||
"passwordMinLength": "Lozinka mora imati najmanje 12 znakova",
|
||||
"tooManyAttempts": "Previše pokušaja — pokušajte kasnije"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "Sve je spremno",
|
||||
|
||||
+48
-17
@@ -836,14 +836,18 @@
|
||||
"createOne": "Hozzon létre egyet",
|
||||
"forgotPassword": "Elfelejtetted a jelszavad?",
|
||||
"errorInvalidCredentials": "Érvénytelen felhasználónév vagy jelszó",
|
||||
"errorInvalid2fa": "Érvénytelen 2FA kód",
|
||||
"errorInvalid2fa": "Érvénytelen 2FA-kód",
|
||||
"welcomeBack": "Üdvözöllek újra",
|
||||
"welcomeBackSub": "Jelentkezz be a {hotelName} folytatáshoz",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"errorUnverified": "Kérlek, erősítsd meg az e-mail-címedet bejelentkezés előtt.",
|
||||
"errorCaptcha": "A captcha-ellenőrzés sikertelen. Próbáld újra.",
|
||||
"whoIsOnline": "Ki van online",
|
||||
"newestCitizens": "Legújabb lakosok",
|
||||
"usersOnline": "{count} online",
|
||||
"username": "Felhasználónév",
|
||||
"password": "Jelszó",
|
||||
"showPassword": "Megjelenítés",
|
||||
"hidePassword": "Elrejtés"
|
||||
},
|
||||
"register": {
|
||||
"title": "Hozzon létre fiókot",
|
||||
@@ -854,7 +858,7 @@
|
||||
"confirmPassword": "Ismételje meg a jelszót",
|
||||
"usernamePlaceholder": "Felhasználónév",
|
||||
"emailPlaceholder": "E-mail",
|
||||
"passwordPlaceholder": "Jelszó (minimum 6 karakter)",
|
||||
"passwordPlaceholder": "Jelszó (minimum 12 karakter)",
|
||||
"confirmPasswordPlaceholder": "Ismételje meg a jelszót",
|
||||
"selectOutfit": "Válassza ki az öltözékét",
|
||||
"termsAccept": "18 évesnél idősebb vagyok, és elfogadom a {hotel} feltételeit és szabályait.",
|
||||
@@ -866,7 +870,7 @@
|
||||
"termsError": "A regisztrációhoz el kell fogadnia a feltételeket és szabályokat.",
|
||||
"usernameError": "Felhasználónév megadása kötelező.",
|
||||
"emailError": "Érvényes e-mail-cím szükséges.",
|
||||
"passwordError": "A jelszónak legalább 6 karakterből kell állnia.",
|
||||
"passwordError": "A jelszónak legalább 12 karakter hosszúnak kell lennie",
|
||||
"confirmPasswordError": "A jelszavak nem egyeznek.",
|
||||
"termsRequired": "El kell fogadnia a feltételeket és szabályokat.",
|
||||
"usernameRequired": "Felhasználónév megadása kötelező.",
|
||||
@@ -877,14 +881,39 @@
|
||||
"alreadyHaveAccountLink": "Bejelentkezés",
|
||||
"whoIsOnline": "Ki van online",
|
||||
"usersOnline": "{count} online",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"accepted": "Elfogadva és egyetértek",
|
||||
"showPassword": "Megjelenítés",
|
||||
"hidePassword": "Elrejtés",
|
||||
"accountDetails": "Fiókadatok",
|
||||
"credentials": "Bejelentkezési adatok és biztonság",
|
||||
"newestCitizens": "Legújabb lakosok",
|
||||
"ageVerified": "18+ vagyok és elfogadom a korhatárra vonatkozó követelményt.",
|
||||
"invitedBy": "Meghívta: {username}"
|
||||
"invitedBy": "Meghívta: {username}",
|
||||
"strengthWeak": "Gyenge",
|
||||
"strengthFair": "Közepes",
|
||||
"strengthGood": "Jó",
|
||||
"strengthStrong": "Erős",
|
||||
"passwordMinLength": "A jelszónak legalább 12 karakter hosszúnak kell lennie",
|
||||
"passwordUpper": "A jelszónak legalább egy nagybetűt kell tartalmaznia",
|
||||
"passwordLower": "A jelszónak legalább egy kisbetűt kell tartalmaznia",
|
||||
"passwordDigit": "A jelszónak legalább egy számjegyet kell tartalmaznia",
|
||||
"passwordSpecial": "A jelszónak legalább egy speciális karaktert kell tartalmaznia",
|
||||
"passwordMaxLength": "A jelszó túl hosszú",
|
||||
"usernameMinLength": "A felhasználónévnek legalább 3 karakter hosszúnak kell lennie",
|
||||
"usernameMaxLength": "A felhasználónév legfeljebb 25 karakter lehet",
|
||||
"usernamePattern": "A felhasználónév csak betűket, számokat, alájlást és kötőjelet tartalmazhat",
|
||||
"usernameReserved": "Ez a felhasználónév foglalt",
|
||||
"emailValid": "Adj meg egy érvényes e-mail-címet",
|
||||
"emailDisposable": "Ideiglenes e-mail-domainek nem engedélyezettek",
|
||||
"passwordsMatch": "A jelszavak nem egyeznek.",
|
||||
"captchaFailed": "A captcha-ellenőrzés sikertelen. Próbáld újra.",
|
||||
"usernameTaken": "Ez a felhasználónév már használatban van",
|
||||
"rateLimited": "Túl sok regisztrációs kísérlet. Várj néhány percet, és próbáld újra.",
|
||||
"vpnBlocked": "VPN/proxy kapcsolatokon keresztüli regisztráció nem engedélyezett.",
|
||||
"maxAccountsPerIp": "Elérted a kapcsolatodhoz tartozó fiókok maximális számát.",
|
||||
"unavailable": "A regisztráció átmenetileg nem érhető el.",
|
||||
"createFailed": "A fiókot nem sikerült létrehozni. Próbáld újra, vagy fordulj a munkatársakhoz.",
|
||||
"invalidInput": "Nézd át a kiemelt mezőket, és próbáld újra."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Jelszó visszaállítása",
|
||||
@@ -898,9 +927,11 @@
|
||||
"reset": {
|
||||
"title": "Állítson be új jelszót",
|
||||
"subtitle": "Válasszon erős jelszót, amelyet máshol nem használ.",
|
||||
"passwordPlaceholder": "Új jelszó (minimum 6 karakter)",
|
||||
"passwordPlaceholder": "Új jelszó (minimum 12 karakter)",
|
||||
"resetPassword": "Jelszó visszaállítása",
|
||||
"backToLogin": "Vissza a bejelentkezéshez"
|
||||
"backToLogin": "Vissza a bejelentkezéshez",
|
||||
"passwordMinLength": "A jelszónak legalább 12 karakter hosszúnak kell lennie",
|
||||
"tooManyAttempts": "Túl sok próbálkozás — próbáld később"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "Minden készen áll",
|
||||
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user