Compare commits
46
Commits
2249303dbd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9560ddc212 | ||
|
|
c0a5079ede | ||
|
|
a8172ce88e | ||
|
|
9416317c5b | ||
|
|
e17576856f | ||
|
|
201296e837 | ||
|
|
2ae8127476 | ||
|
|
95bc7da83e | ||
|
|
9fd675a6a0 | ||
|
|
5080ca586d | ||
|
|
76493a9df8 | ||
|
|
9cff32a04b | ||
|
|
79b0b8894d | ||
|
|
6b9ed8d15e | ||
|
|
6c89a7ed46 | ||
|
|
fbf98cd2b5 | ||
|
|
cf448d6ce9 | ||
|
|
4768b2b406 | ||
|
|
af67a28639 | ||
|
|
a4bc25b6d6 | ||
|
|
fb724a35cc | ||
|
|
a878d831aa | ||
|
|
1ad4f9ea28 | ||
|
|
4f6b352691 | ||
|
|
42fa988d9c | ||
|
|
be4dc08c31 | ||
|
|
6ef92f57fc | ||
|
|
a16e9da018 | ||
|
|
3cb5baabfb | ||
|
|
e96c408ac1 | ||
|
|
e4564abc5e | ||
|
|
30583b8e2e | ||
|
|
97454f361b | ||
|
|
ecd8e5852c | ||
|
|
8ca538f99f | ||
|
|
1e81cac504 | ||
|
|
3bc218ba16 | ||
|
|
61f4db2a00 | ||
|
|
597287fce5 | ||
|
|
5c428c4526 | ||
|
|
e7a6b2b602 | ||
|
|
4b25292ecc | ||
|
|
b40293bd06 | ||
|
|
9cf1eb615c | ||
|
|
aef0939ee9 | ||
|
|
c6e5e0b1a9 |
No files matched your search
@@ -26,6 +26,25 @@ bun.lockb
|
||||
public/nitro-assets
|
||||
public/swf
|
||||
|
||||
# Testbronnen draaien op de host (pnpm test:ui / test:integration / test:e2e),
|
||||
# de image heeft ze niet nodig. In de image zouden ze alleen de typecheck van
|
||||
# `next build` meebepalen en de image van @playwright/test, vitest, msw,
|
||||
# testcontainers en drizzle-kit afhankelijk maken.
|
||||
e2e/
|
||||
integration/
|
||||
|
||||
# Databasebackups (~232MB, dagelijkse rotatie): horen niet in de buildcontext.
|
||||
# Ze maakten elke context groot en invalideerden bij elke rotatie de
|
||||
# `COPY . .`-laag, waardoor de Next-build volledig opnieuw draaide.
|
||||
backups/
|
||||
|
||||
# Wordt tijdens de build opnieuw gegenereerd door `pnpm assets:editor`
|
||||
# (bron: node_modules/tinymce) en staat niet in git.
|
||||
public/vendor/
|
||||
|
||||
# Lokale buildcache voor `docker compose build` (cache_from/cache_to).
|
||||
.docker-cache/
|
||||
|
||||
.deploy.lock
|
||||
logs
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
# directly (drizzle-kit, scripts). Keep both in step and keep them well below
|
||||
# MariaDB's `max_connections` (151 by default) or the emulator and any second
|
||||
# CMS instance get "Too many connections". README §performance: pool size 25.
|
||||
# DATABASE_URL supports both MySQL/MariaDB ("mysql://...") and PostgreSQL ("postgres://...").
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=25&connect_timeout=5"
|
||||
DATABASE_POOL_SIZE=25
|
||||
DATABASE_IDLE_TIMEOUT_MS=60000
|
||||
@@ -64,6 +65,10 @@ AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
||||
APP_KEY=base64:your-app-key-here=
|
||||
# Bcrypt cost factor for new password hashes.
|
||||
BCRYPT_COST=12
|
||||
# Password-length policy for every new password (register, password reset and
|
||||
# staff-created accounts). Minimum: 5-30. Maximum: 30-128.
|
||||
PASSWORD_MIN_LENGTH=12
|
||||
PASSWORD_MAX_LENGTH=128
|
||||
|
||||
# --- ANTI-DDOS (app-layer gate, production only) ---
|
||||
# On by default in production. Set to "false" to disable (not recommended).
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -57,6 +57,9 @@ blob-report/
|
||||
build-reports/
|
||||
!/docs/performance-budgets.md
|
||||
|
||||
# Local buildcache (docker compose cache_from/cache_to)
|
||||
.docker-cache/
|
||||
|
||||
# One-off local snapshots (o.a. catalog-integrity/pre-fix.json): runtime-werk,
|
||||
# geen bron. Per map opgeslagen om een incident terug te kunnen lezen.
|
||||
backups/
|
||||
+1
-1
@@ -27,7 +27,7 @@ This source change alone does not configure or deploy the production service.
|
||||
## Exported content
|
||||
|
||||
| Source | Repository destination |
|
||||
| --- | --- |
|
||||
| ------------------------------------------------------------------------ | --------------------------------------------------- |
|
||||
| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` |
|
||||
| Configured furni icons | `Gamedata/icons` |
|
||||
| Existing badge/catalog images | `Gamedata/c_images` |
|
||||
|
||||
+29
-7
@@ -1,23 +1,45 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:26.10.0-alpine AS migrations
|
||||
FROM node:26.12.1-alpine AS migrations
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Installeer git, bash en pnpm v12. bash is nodig voor
|
||||
# Installeer git, bash en corepack. bash is nodig voor
|
||||
# scripts/with-memory-cap.sh (gebruikt bashisme zoals arrays en BASH_REMATCH);
|
||||
# Alpine levert geen bash mee.
|
||||
#
|
||||
# pnpm komt niet meer uit een losgepind npm-pakket, maar uit corepack: die
|
||||
# leest de versie én de sha512-hash uit de `packageManager`-pin van
|
||||
# package.json. Daarmee kan de image nooit uit sync raken met het lockfile
|
||||
# (vroeger stond hier [email protected] terwijl package.json 12.11.2 voorschreef)
|
||||
# en wordt de gedownloade pnpm-binary ook in de runtime-image bewaard.
|
||||
RUN --mount=type=cache,target=/var/cache/apk \
|
||||
apk add --no-cache git bash \
|
||||
&& npm install -g [email protected]
|
||||
--mount=type=cache,target=/root/.npm \
|
||||
apk add --no-cache git bash util-linux \
|
||||
&& npm install -g [email protected] \
|
||||
&& corepack enable pnpm
|
||||
|
||||
# corepack bewaart de gedownloade pnpm buiten de cache-mount, zodat de runtime
|
||||
# (`docker run --entrypoint pnpm <migrations-image> db:migrate`) pnpm ook
|
||||
# zonder netwerk kan gebruiken.
|
||||
ENV COREPACK_HOME="/usr/local/share/corepack"
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
|
||||
# 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"
|
||||
|
||||
# Alleen package.json: de `packageManager`-pin bepaalt welke pnpm corepack
|
||||
# installeert. Deze laag blijft gecachet wanneer alleen de lockfile verandert.
|
||||
COPY package.json ./
|
||||
RUN corepack install
|
||||
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
||||
|
||||
# Voer de installatie uit met de pnpm v12 store cache-mount
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
# Voer de installatie uit met de pnpm v12 store cache-mount. De mount moet op
|
||||
# de store staan die pnpm ook echt gebruikt ($PNPM_HOME/store/v11); met het
|
||||
# oude pad (/root/.local/share/pnpm/store) cache'te de mount niets en
|
||||
# downloadde elke build alle packages opnieuw.
|
||||
RUN --mount=type=cache,target=/usr/local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
COPY . .
|
||||
@@ -52,7 +74,7 @@ RUN --mount=type=cache,target=/app/.next/cache \
|
||||
pnpm run build \
|
||||
&& PERFORMANCE_COMMIT_SHA="$NEXT_DEPLOYMENT_ID" node scripts/performance-report.mjs --output-dir build-reports
|
||||
|
||||
FROM node:26.10.0-alpine AS runner
|
||||
FROM node:26.12.1-alpine AS runner
|
||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
||||
WORKDIR /app
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
},
|
||||
"style": {
|
||||
"noDescendingSpecificity": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noComponentHookFactories": "off",
|
||||
"usePlaywrightValidDescribeCallback": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,4 +6,10 @@ services:
|
||||
HOSTNAME: 127.0.0.1
|
||||
PORT: "3002"
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
|
||||
]
|
||||
@@ -39,6 +39,13 @@ http {
|
||||
server_tokens off;
|
||||
keepalive_timeout 30s;
|
||||
|
||||
# Gamedata/icons are served from disk by alias locations. Caching the
|
||||
# stat/open results avoids a syscall storm on every room load.
|
||||
open_file_cache max=10000 inactive=30s;
|
||||
open_file_cache_valid 60s;
|
||||
open_file_cache_min_uses 2;
|
||||
open_file_cache_errors off;
|
||||
|
||||
client_max_body_size 64m;
|
||||
client_body_buffer_size 16k;
|
||||
client_header_buffer_size 1k;
|
||||
|
||||
+46
-17
@@ -1,5 +1,20 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Next.js CMS — blue/green
|
||||
# Next.js CMS — single slot on port 3002
|
||||
#
|
||||
# ── Eigenaar van poort 3002 ─────────────────────────────────────────────────
|
||||
# Deze repo kent twee deploy-paden die allebei op poort 3002 willen:
|
||||
# * `scripts/ci-deploy.sh` (blue/green, docker run) — de eigenaar op een
|
||||
# CI-host. Elke push naar main bouwt daar een nieuwe image, draait de
|
||||
# migrations en zet nginx pas om na health + e2e + imaging.
|
||||
# * deze compose-`cms`-service — bedoeld voor een LOSSE host zonder CI.
|
||||
#
|
||||
# Ze kunnen niet naast elkaar draaien: wie op 3002 zit, blokkeert de ander met
|
||||
# `EADDRINUSE`. Daarom staat `cms` achter een profile. Een bloot
|
||||
# `docker compose up -d` start dus alleen byparr en kan nooit de live CI-release
|
||||
# van de site drukken. De service start alleen als je hem expliciet noemt
|
||||
# (`docker compose up -d cms`, `docker compose build cms`, ...): Compose schakelt
|
||||
# het profile van een expliciet genoemde service vanzelf in. Op een CI-host wil
|
||||
# je dat dus juist NOOIT doen.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
x-cms: &cms
|
||||
image: epicnext-cms:${CMS_RELEASE:-local}
|
||||
@@ -14,8 +29,20 @@ x-cms: &cms
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
|
||||
# Runtime host networking IS required: blue/green needs per-release host ports
|
||||
# (3002/3003) and nginx reaches the slot over 127.0.0.1.
|
||||
# Lokale buildcache op schijf. `scripts/docker-prune.sh` brengt de
|
||||
# daemon-cache na elke deploy terug tot 4GB, waardoor de lagen van de
|
||||
# vorige release verdwijnen. Deze export overleeft die prune: een rebuild
|
||||
# na een lockfile-wijziging hergebruikt dan de al gedownloade packages
|
||||
# (pnpm store) in plaats van ze opnieuw te downloaden.
|
||||
# De map ligt binnen de buildcontext, zodat buildx een ontbrekende
|
||||
# cachemap stilzwijgend negeert i.p.v. de build te laten falen; zowel
|
||||
# .dockerignore als .gitignore sluiten de map uit.
|
||||
cache_from:
|
||||
- type=local,src=.docker-cache/cms
|
||||
cache_to:
|
||||
- type=local,dest=.docker-cache/cms,mode=max
|
||||
# Runtime host networking IS required: nginx reaches the CMS over 127.0.0.1
|
||||
# on the single port that is actually served (3002).
|
||||
network_mode: host
|
||||
stop_grace_period: 15s
|
||||
restart: unless-stopped
|
||||
@@ -28,13 +55,17 @@ x-cms: &cms
|
||||
- /var/www/Gamedata:/var/www/Gamedata
|
||||
|
||||
# ── Resource limits ──
|
||||
mem_limit: 6g
|
||||
memswap_limit: 7g
|
||||
cpus: 2.0
|
||||
pids_limit: 512
|
||||
mem_limit: 4g
|
||||
memswap_limit: 5g
|
||||
|
||||
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))"]
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -43,21 +74,19 @@ x-cms: &cms
|
||||
services:
|
||||
cms:
|
||||
<<: *cms
|
||||
# Alleen starten als je deze host bewust compose-only beheert.
|
||||
profiles: ["cms"]
|
||||
container_name: epicnext-cms
|
||||
environment:
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PORT=3002
|
||||
|
||||
cms-green:
|
||||
<<: *cms
|
||||
container_name: epicnext-cms-green
|
||||
profiles: ["green"]
|
||||
environment:
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PORT=3003
|
||||
|
||||
byparr:
|
||||
image: ghcr.io/thephaseless/byparr:latest
|
||||
image: ghcr.io/thephaseless/byparr:v3.0.4
|
||||
# `latest` is een drijvende tag. Zonder pull_policy haalt compose de
|
||||
# bestaande image nooit opnieuw op en blijft byparr hangen op de eerste
|
||||
# versie die ooit werd binnengehaald.
|
||||
pull_policy: always
|
||||
container_name: byparr
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Operator changes
|
||||
|
||||
| Area | Behavior and location |
|
||||
| --- | --- |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Release | Deployment checks HTTP health, release identity and Chromium pages before marking the running image verified. Registry publication reuses that verified digest on the shared runner; independent hosts build and verify their own image. |
|
||||
| Shared HK | Dialogs scroll within the available viewport. Table column preferences persist per page in the current browser session; filters already remain in the URL. No favorites added. |
|
||||
| Catalog Studio | Dedicated detail drawer and five separate completeness states: SQL, offers, furnidata, icon and Nitro. Sprite/type conflicts are consistently excluded from import and linked to audit. Missing source files are never represented as available. |
|
||||
@@ -37,7 +37,6 @@
|
||||
## Verification
|
||||
|
||||
Local verification on 2026-09-09:
|
||||
|
||||
- Production build succeeded with fixture configuration and an intentionally unavailable database. Build-time fallback logs are expected in this check.
|
||||
- Full Vitest run: 254 files passed, 4 skipped; 1,466 tests passed, 6 skipped. Coverage thresholds passed (19.89% lines); this does not imply exhaustive coverage.
|
||||
- Global Biome rules/import checks passed across 1,328 files; modified source/locales were formatted separately to avoid unrelated Windows line-ending changes.
|
||||
|
||||
@@ -81,7 +81,7 @@ The direct template intentionally records the CDN/edge socket address when place
|
||||
|
||||
### Local verification and deployment boundary
|
||||
|
||||
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
|
||||
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. The `cms` service is behind the `cms` profile (so a bare `docker compose up -d` cannot claim port 3002 on a CI-managed host), so the test renders the configuration with `--profile cms`. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
@@ -14,12 +14,11 @@ For each configured App Router route, resolve its exact app path using `app-path
|
||||
|
||||
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every client chunk that route's client-reference manifest lists. This includes layout, page and boundary/loading entries.
|
||||
|
||||
The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk *path* still fails rather than being dropped, so a broken manifest cannot quietly under-report a route.
|
||||
The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk _path_ still fails rather than being dropped, so a broken manifest cannot quietly under-report a route.
|
||||
|
||||
> The build runs webpack (`next build --webpack`), so the `clientModules` path is the live one. An earlier revision only read `entryJSFiles`, and after the switch to webpack every route reported `unavailable` while the command still exited 0 — the budgets were silently not being measured. When a bundler switch changes the manifest layout again, re-check this section rather than trusting a clean exit.
|
||||
|
||||
The definition follows the data exposed by the installed Next 16.3.8 build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
|
||||
|
||||
- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope.
|
||||
- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes.
|
||||
- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total.
|
||||
@@ -35,7 +34,7 @@ Baselines were last recalibrated against a local webpack production build (Next
|
||||
The image build compiles the app, then runs `scripts/performance-report.mjs` in the same `RUN` layer (`Dockerfile:48-53`). In `mode: enforce` a failing route therefore fails the image build, which blocks both the PR `preflight` job and the `deploy` job. Recalibrate the table and `scripts/performance-budgets.json` **together** in the same commit whenever the envelope legitimately grows (new route dependency, Next upgrade).
|
||||
|
||||
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| --------------------- | -----------------: | ------------------: | --------: | ---------: |
|
||||
| `/me` | 621460 | 195411 | 716800 | 225280 |
|
||||
| `/news` | 616712 | 193199 | 716800 | 225280 |
|
||||
| `/events` | 617121 | 193450 | 716800 | 225280 |
|
||||
@@ -46,4 +45,3 @@ The image build compiles the app, then runs `scripts/performance-report.mjs` in
|
||||
Configured limits are positive integer bytes; `null` explicitly means observe-only. `scripts/performance-budgets.json` runs `mode: enforce`: an `over-budget` route prints `Performance budgets enforced: <route> (over-budget)` and exits non-zero. Missing production `BUILD_ID`, unsupported manifests, missing routes or missing referenced assets produce `unavailable` with a reason and **no partial/zero total**; under `enforce` an `unavailable` route also fails the command, so a bundler change that breaks measurement cannot pass quietly. Malformed budget configuration or an unwritable output directory always fails the command. Set `mode` back to `informational` only while recalibrating, never as a permanent state.
|
||||
|
||||
Synthetic tests cover shared-chunk deduplication, exact byte/gzip calculations, route bootstrap selection, missing data, safe parsing and CLI exit behavior in both modes. Run `pnpm exec vitest run --coverage.enabled=false scripts/performance-report.test.mjs`.
|
||||
|
||||
+6
-2
@@ -6,12 +6,16 @@ import { defineConfig } from "drizzle-kit";
|
||||
// This DB is shared with the Arcturus emulator — NEVER run `drizzle-kit migrate`
|
||||
// or `push` against it. CMS DDL stays in drizzle/migrations/*.sql applied by
|
||||
// `pnpm db:migrate`. Use `pnpm db:generate` only for draft SQL under drizzle/drafts/.
|
||||
const dbUrl = process.env.DATABASE_URL ?? "";
|
||||
const isPostgres =
|
||||
dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://");
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "mysql",
|
||||
dialect: isPostgres ? "postgresql" : "mysql",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle/drafts",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "",
|
||||
url: dbUrl,
|
||||
},
|
||||
strict: true,
|
||||
verbose: true,
|
||||
|
||||
+19
-17
@@ -3,7 +3,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=26.10.0 <27"
|
||||
"node": ">=26.12.1 <27"
|
||||
},
|
||||
"packageManager": "[email protected]+sha512.25a51652a196785af5f28bda6dfe8dc582ebefa4ac1d180221ce1390231c5fb76637822ea4b6e4633ce74acdcb0a77f725af11bad38177fdeddaee818a955cb9",
|
||||
"scripts": {
|
||||
@@ -55,10 +55,10 @@
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.21",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@tanstack/react-query": "5.104.3",
|
||||
"@tanstack/react-virtual": "3.14.19",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.22",
|
||||
"@hookform/resolvers": "6.0.0",
|
||||
"@tanstack/react-query": "5.104.5",
|
||||
"@tanstack/react-virtual": "3.14.22",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
@@ -66,24 +66,25 @@
|
||||
"drizzle-orm": "0.45.4",
|
||||
"hash-wasm": "4.12.0",
|
||||
"ioredis": "6.0.0",
|
||||
"isomorphic-dompurify": "^4.5.0",
|
||||
"isomorphic-dompurify": "^4.6.0",
|
||||
"jpeg-js": "0.4.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"jszip": "3.10.2",
|
||||
"lucide-react": "1.57.0",
|
||||
"lucide-react": "1.61.0",
|
||||
"lzma-wasm": "1.0.7",
|
||||
"motion": "14.2.0",
|
||||
"music-metadata": "12.2.1",
|
||||
"motion": "14.6.0",
|
||||
"music-metadata": "12.2.3",
|
||||
"mysql2": "3.24.6",
|
||||
"next": "16.4.1",
|
||||
"next": "16.4.2",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "4.14.9",
|
||||
"otplib": "13.5.0",
|
||||
"pg": "^8.23.1",
|
||||
"pino": "10.4.0",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"react-hook-form": "7.90.0",
|
||||
"resend": "6.33.0",
|
||||
"resend": "6.34.1",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "^0.35.5",
|
||||
"sonner": "2.0.8",
|
||||
@@ -92,27 +93,28 @@
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@axe-core/playwright": "4.14.0",
|
||||
"@babel/parser": "8.0.7",
|
||||
"@biomejs/biome": "2.5.15",
|
||||
"@biomejs/biome": "2.6.2",
|
||||
"@playwright/test": "1.64.1",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@types/node": "26.6.5",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitest/coverage-v8": "5.0.4",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"drizzle-kit": "0.31.11",
|
||||
"esbuild": "0.28.2",
|
||||
"msw": "^3.0.3",
|
||||
"pino-pretty": "13.2.0",
|
||||
"postcss": "8.5.30",
|
||||
"postcss": "8.5.31",
|
||||
"tailwindcss": "4.3.3",
|
||||
"testcontainers": "12.2.1",
|
||||
"tsx": "4.23.15",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.3.4",
|
||||
"vitest": "5.0.4"
|
||||
"vite": "8.3.5",
|
||||
"vitest": "5.0.5"
|
||||
}
|
||||
}
|
||||
+26
-9
@@ -1,22 +1,39 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
// Deploy gate: keep this suite deterministic and light. One worker, one browser
|
||||
// engine, no live dev server and no parallelism — that is what keeps the host
|
||||
// from pinning at 100% CPU while the specs run.
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testIgnore: ["**/ui/**", "**/news-real/**", "**/quality/**"],
|
||||
testIgnore: ["**/ui/**", "**/imaging/**", "**/news-real/**", "**/quality/**"],
|
||||
forbidOnly: !!process.env.CI,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 1,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
timeout: 30000,
|
||||
expect: { timeout: 10000 },
|
||||
globalTimeout: 10 * 60 * 1000,
|
||||
maxFailures: process.env.CI ? 10 : undefined,
|
||||
reporter: [["list"], ["html", { open: "never" }]],
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
animations: "disabled",
|
||||
caret: "hide",
|
||||
maxDiffPixelRatio: 0.02,
|
||||
},
|
||||
},
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "off",
|
||||
launchOptions: {
|
||||
args: [
|
||||
"--disable-gpu",
|
||||
"--disable-software-rasterizer",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-background-networking",
|
||||
"--disable-default-apps",
|
||||
"--disable-extensions",
|
||||
"--disable-sync",
|
||||
"--metrics-recording-only",
|
||||
"--mute-audio",
|
||||
"--no-first-run",
|
||||
],
|
||||
},
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
});
|
||||
+20
-4
@@ -1,8 +1,24 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const port = Number(process.env.UI_TEST_PORT || 3107);
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
||||
throw Error("Invalid UI_TEST_PORT");
|
||||
// The fixture server binds a loopback port. The CI runner packs several jobs
|
||||
// onto the shared host network (capacity > 1), so a fixed port makes concurrent
|
||||
// runs fight over the bind: one wins, the other's browser then gets
|
||||
// "connection refused". Pick one random high port for the whole run and pass it
|
||||
// to every worker through the environment, unless a port is pinned explicitly
|
||||
// (e.g. a developer reusing a manually started fixture server).
|
||||
function fixturePort() {
|
||||
const requested = Number(process.env.UI_TEST_PORT);
|
||||
if (Number.isInteger(requested) && requested >= 1024 && requested <= 65535)
|
||||
return requested;
|
||||
const shared = Number(process.env.PW_UI_TEST_PORT);
|
||||
if (Number.isInteger(shared) && shared >= 1024 && shared <= 65535)
|
||||
return shared;
|
||||
const port = randomInt(30000, 50000);
|
||||
process.env.PW_UI_TEST_PORT = String(port);
|
||||
return port;
|
||||
}
|
||||
const port = fixturePort();
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
export default defineConfig({
|
||||
testDir: "./e2e/ui",
|
||||
@@ -65,7 +81,7 @@ export default defineConfig({
|
||||
command: "node e2e/ui/fixture-server.mjs",
|
||||
url: `${baseURL}/health`,
|
||||
timeout: 120000,
|
||||
reuseExistingServer: true,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: { UI_TEST_PORT: String(port) },
|
||||
},
|
||||
});
|
||||
Generated
+4788
-2549
File diff suppressed because it is too large.
Load diff
+8
-8
@@ -1,9 +1,9 @@
|
||||
packages:
|
||||
- '.'
|
||||
- "."
|
||||
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
'@swc/core': true
|
||||
"@parcel/watcher": true
|
||||
"@swc/core": true
|
||||
cpu-features: true
|
||||
esbuild: true
|
||||
msw: true
|
||||
@@ -13,12 +13,12 @@ allowBuilds:
|
||||
minimumReleaseAge: 60
|
||||
|
||||
overrides:
|
||||
glob: '^11.0.0'
|
||||
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
|
||||
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
|
||||
source-map-js: '1.2.2'
|
||||
glob: "^11.0.0"
|
||||
"@esbuild-kit/core-utils": "npm:tsx@^4.23.15"
|
||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.23.15"
|
||||
source-map-js: "1.2.2"
|
||||
# GHSA advisory: postcss-selector-parser <7.1.6. Pulled in only by
|
||||
# @tailwindcss/typography (build-time prose selectors), which pins 6.0.10
|
||||
# exactly and has no upstream bump. The API subset it uses is unchanged in
|
||||
# v7, so force the patched major.
|
||||
postcss-selector-parser: '^7.1.6'
|
||||
postcss-selector-parser: "^7.1.6"
|
||||
+1
-1
@@ -61,7 +61,7 @@
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": "De pnpm packageManager-pin is afgestemd op Docker en de runner-toolchain; niet automatisch bumpen",
|
||||
"description": "De pnpm packageManager-pin blijft handmatig: Docker (corepack) en de runner volgen de pin automatisch, maar een bump raakt ook de lockfile en de README-versietabel (scripts/sync-readme-versions.mjs)",
|
||||
"matchPackageNames": ["pnpm"],
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
+4
-4
@@ -5,7 +5,7 @@ This directory contains utility scripts for managing the EpicNext-Cms project.
|
||||
## Scripts Overview
|
||||
|
||||
| Script | Description |
|
||||
| :--- | :--- |
|
||||
| :------------------------- | :---------------------------------------------------------------- |
|
||||
| `dashboard.sh` | Main interactive menu to run all other scripts. |
|
||||
| `deploy.sh` | Safely redeploys the application by freeing the port first. |
|
||||
| `logs.sh` | View logs for cms and mariadb services. |
|
||||
@@ -28,6 +28,7 @@ This directory contains utility scripts for managing the EpicNext-Cms project.
|
||||
| `catalog-sync.sh` | Interactive catalog update against a clone source (see below). |
|
||||
|
||||
## Usage
|
||||
|
||||
Run any script directly, or use the dashboard:
|
||||
```bash
|
||||
./scripts/dashboard.sh
|
||||
@@ -69,7 +70,7 @@ menu, so it is usable from cron.
|
||||
Every step the engine exposes is available as a flag:
|
||||
|
||||
| Flag | What it does |
|
||||
| :--- | :--- |
|
||||
| :--------------------------------------------- | :------------------------------------------------------------------------- |
|
||||
| `--sources=<a,b>` | Compare against these sources (id or display name) |
|
||||
| `--icons` | Re-download missing furniture icons |
|
||||
| `--nitros` | Re-download missing bundles (stored as `.hab`) |
|
||||
@@ -94,7 +95,6 @@ catalog page plus the offer, exactly like the app's own import. It skips
|
||||
anything already present, so it is safe to re-run.
|
||||
|
||||
Safety notes:
|
||||
|
||||
- **An audit writes nothing** — no repair flag means read-only.
|
||||
- `--dry-run` together with `--import-new` lists what would be imported and
|
||||
imports nothing; use it first on a source before importing thousands of items.
|
||||
@@ -106,7 +106,7 @@ Safety notes:
|
||||
- `--apply-sql` is the only flag that changes the catalog; `--apply` authorises
|
||||
the two heavyweight child scripts (`align-db-ids`, `migrate-nitro-to-hab`).
|
||||
Both only report without it.
|
||||
- The engine is the same one the admin *Catalog audit* page streams
|
||||
- The engine is the same one the admin _Catalog audit_ page streams
|
||||
(`runCatalogAudit`), so a terminal run and an in-admin run agree.
|
||||
- `--json` prints one machine-readable report; `--fail-on-errors` exits `3` when
|
||||
error-severity issues remain, which is what a scheduled check should use.
|
||||
|
||||
+19
-6
@@ -42,13 +42,10 @@ old_container=""
|
||||
new_container=""
|
||||
blue_green=0
|
||||
|
||||
# Resource limits voor de container. Zonder deze limieten kan één geheugenlek de
|
||||
# hele host vullen, met MariaDB, nginx en Traefik als slachtoffer. 2 CPU laat
|
||||
# achtergrondwerk toe zonder de hele kernel aan één release te geven.
|
||||
# CPU en pids zijn bewust verwijderd; memory blijft gelimiteerd op 4g/5g swap
|
||||
# om een geheugenlek niet de host te laten vullen (MariaDB, nginx, Traefik).
|
||||
mem_limit="4g"
|
||||
mem_swap_limit="5g"
|
||||
cpu_limit="2"
|
||||
pids_limit="512"
|
||||
|
||||
is_current() {
|
||||
local head
|
||||
@@ -300,7 +297,6 @@ start_candidate() {
|
||||
done < "$deploy_dir/.env"
|
||||
docker run -d --name "$name" --restart always --net=host \
|
||||
--memory="$mem_limit" --memory-swap="$mem_swap_limit" \
|
||||
--cpus="$cpu_limit" --pids-limit="$pids_limit" \
|
||||
"${ENV_ARGS[@]}" -e "PORT=$port" -e HOSTNAME=0.0.0.0 \
|
||||
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
|
||||
-v "$deploy_dir/public/swf:/app/public/swf" \
|
||||
@@ -416,9 +412,26 @@ pnpm install --frozen-lockfile
|
||||
pnpm exec playwright install chromium firefox webkit
|
||||
|
||||
echo "Building $image"
|
||||
# Duurzame buildcache buiten de werkboom. De CI-werkboom wordt na elke job
|
||||
# gewist en `docker builder prune` gooit de buildcache weg, waarna de hele
|
||||
# pnpm-laag opnieuw gedownload moet worden. Een lokale cache-export overleeft
|
||||
# allebei. Een lege CMS_BUILD_CACHE_DIR schakelt de cache uit.
|
||||
build_cache_dir="${CMS_BUILD_CACHE_DIR-/var/cache/epicnext-cms-buildcache}"
|
||||
build_cache_flags=()
|
||||
if [ -n "$build_cache_dir" ]; then
|
||||
if mkdir -p "$build_cache_dir" 2>/dev/null && [ -w "$build_cache_dir" ]; then
|
||||
build_cache_flags=(
|
||||
--cache-from "type=local,src=$build_cache_dir"
|
||||
--cache-to "type=local,dest=$build_cache_dir,mode=max"
|
||||
)
|
||||
else
|
||||
echo "Warning: buildcache $build_cache_dir is niet beschrijfbaar; build zonder hergebruikte cache." >&2
|
||||
fi
|
||||
fi
|
||||
# No --network=host: BuildKit only grants it via --allow=network.host, and the
|
||||
# build needs nothing but outbound internet (apk, pnpm, next/font/google).
|
||||
DOCKER_BUILDKIT=1 docker build --progress=plain --cache-from epicnext-cms:latest \
|
||||
${build_cache_flags[@]+"${build_cache_flags[@]}"} \
|
||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||
check_current
|
||||
# Read reports from the already-built image; do not start an extra application.
|
||||
|
||||
@@ -39,6 +39,23 @@ pnpm exec playwright install chromium
|
||||
export NEWS_E2E_IMAGE="$image"
|
||||
export NEWS_E2E_RELEASE="$sha"
|
||||
build_attempted=1
|
||||
# Duurzame buildcache buiten de tijdelijke werkmap, zodat de lagen van een
|
||||
# vorige preflight hergebruikt worden in plaats van opnieuw gedownload. Eigen
|
||||
# map, gescheiden van de release-cache: een branch-preflight mag nooit de cache
|
||||
# van de release-build voeden. Een lege CMS_BUILD_CACHE_DIR schakelt het uit.
|
||||
build_cache_dir="${CMS_BUILD_CACHE_DIR-/var/cache/epicnext-cms-preflight-cache}"
|
||||
build_cache_flags=()
|
||||
if [ -n "$build_cache_dir" ]; then
|
||||
if mkdir -p "$build_cache_dir" 2>/dev/null && [ -w "$build_cache_dir" ]; then
|
||||
build_cache_flags=(
|
||||
--cache-from "type=local,src=$build_cache_dir"
|
||||
--cache-to "type=local,dest=$build_cache_dir,mode=max"
|
||||
)
|
||||
else
|
||||
echo "Warning: buildcache $build_cache_dir is niet beschrijfbaar; build zonder hergebruikte cache." >&2
|
||||
fi
|
||||
fi
|
||||
DOCKER_BUILDKIT=1 docker build --progress=plain \
|
||||
${build_cache_flags[@]+"${build_cache_flags[@]}"} \
|
||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
|
||||
@@ -126,7 +126,9 @@ if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
|
||||
docker tag "$app_reference" "epicnext-cms:$CMS_RELEASE"
|
||||
docker tag "$remote_migration_image" "$migration_image"
|
||||
else
|
||||
docker build --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" -t "$migration_image" . >>"$LOG_FILE" 2>&1
|
||||
docker build --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" \
|
||||
--cache-from type=local,src=.docker-cache/cms \
|
||||
-t "$migration_image" . >>"$LOG_FILE" 2>&1
|
||||
docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1
|
||||
fi
|
||||
expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")"
|
||||
|
||||
+15
-4
@@ -78,13 +78,24 @@ else
|
||||
if install_file "$PROXY_DIR/cms_upstream_servers.conf" "$NGINX_DIR/snippets/cms_upstream_servers.conf"; then changed=1; fi
|
||||
fi
|
||||
|
||||
if [[ ! -f "$NGINX_DIR/sites-enabled/cms.conf" ]]; then
|
||||
# sites-enabled/cms.conf must be a symlink, not a copy. A regular file here
|
||||
# silently shadows sites-available/cms.conf, so nginx keeps serving stale
|
||||
# config even though the sync updated sites-available.
|
||||
enabled="$NGINX_DIR/sites-enabled/cms.conf"
|
||||
if [[ -L "$enabled" && "$(readlink "$enabled")" == "../sites-available/cms.conf" ]]; then
|
||||
echo "= $enabled symlink up to date"
|
||||
else
|
||||
if [[ "$MODE" == "check" ]]; then
|
||||
echo "- sites-enabled/cms.conf missing"
|
||||
echo "- $enabled is not the expected symlink"
|
||||
changed=1
|
||||
else
|
||||
ln -sf ../sites-available/cms.conf "$NGINX_DIR/sites-enabled/cms.conf"
|
||||
echo "+ linked sites-enabled/cms.conf"
|
||||
mkdir -p "$NGINX_DIR/sites-enabled"
|
||||
if [[ -e "$enabled" || -L "$enabled" ]]; then
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cp -a "$enabled" "$BACKUP_DIR/cms-enabled.conf"
|
||||
fi
|
||||
ln -sfn ../sites-available/cms.conf "$enabled"
|
||||
echo "+ linked $enabled"
|
||||
changed=1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -18,7 +18,7 @@ const hasCompose =
|
||||
}).status === 0;
|
||||
|
||||
it.skipIf(!hasCompose)(
|
||||
"loads the loopback profile from .env for bare Compose commands without changing mounts or host networking",
|
||||
"loads the loopback override from .env for the cms service without changing mounts or host networking",
|
||||
() => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "cms-proxy-config-"));
|
||||
try {
|
||||
@@ -50,9 +50,12 @@ it.skipIf(!hasCompose)(
|
||||
["CMS_RELEASE", "HOSTNAME", "PORT"].includes(key)
|
||||
)
|
||||
delete environment[key];
|
||||
// `cms` is behind the `cms` profile so a bare `docker compose up -d`
|
||||
// can never squat port 3002 on a CI-managed host. Selecting the
|
||||
// profile here keeps the loopback/port-override contract under test.
|
||||
const result = spawnSync(
|
||||
"docker",
|
||||
["compose", "config", "--format", "json"],
|
||||
["compose", "--profile", "cms", "config", "--format", "json"],
|
||||
{ cwd: directory, env: environment, encoding: "utf8", timeout: 15_000 },
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
|
||||
@@ -118,6 +118,13 @@ systemd_cmd=()
|
||||
# starten. De markering maakt dat afdwingbaar in next.config.ts.
|
||||
export CMS_MEMORY_CAPPED=1
|
||||
|
||||
# Gebruik hoogstens de helft van de cores, zodat de machine niet op 100% CPU
|
||||
# piekt, maar behoud voldoende parallelle kracht op (Ryzen) hosts.
|
||||
cpu_count="$(nproc 2>/dev/null || echo 1)"
|
||||
worker_cores=$((cpu_count / 2))
|
||||
[ "$worker_cores" -lt 1 ] && worker_cores=1
|
||||
cpu_affinity="0-$((worker_cores - 1))"
|
||||
|
||||
# Echt proberen, niet alleen uitzoeken of het bestand bestaat: `systemd-run`
|
||||
# zonder rechten faalt met "Access denied", en dat moet dan een nette
|
||||
# terugval naar ulimit worden in plaats van een kapotte build.
|
||||
@@ -174,7 +181,7 @@ run_systemd() {
|
||||
# expliciet: deze host heeft geen swap.
|
||||
local high=$((effective * 95 / 100))
|
||||
echo "[mem-cap] systemd cgroup MemoryHigh=$((high / 1024 / 1024))MB MemoryMax=$((effective / 1024 / 1024))MB MemorySwapMax=0: $*" >&2
|
||||
exec "${systemd_cmd[@]}" -p "MemoryHigh=$high" -p "MemoryMax=$effective" -p "MemorySwapMax=0" "$@"
|
||||
exec "${systemd_cmd[@]}" -E "PATH=$PATH" -p "MemoryHigh=$high" -p "MemoryMax=$effective" -p "MemorySwapMax=0" -p "AllowedCPUs=$cpu_affinity" "$@"
|
||||
}
|
||||
|
||||
run_ulimit() {
|
||||
@@ -186,7 +193,11 @@ run_ulimit() {
|
||||
echo "[mem-cap] ulimit -v $virtual_kb werd geweigerd" >&2
|
||||
return 1
|
||||
}
|
||||
if command -v taskset >/dev/null 2>&1; then
|
||||
exec taskset -c "$cpu_affinity" "$@"
|
||||
else
|
||||
exec "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
run_refuse() {
|
||||
@@ -197,7 +208,11 @@ run_refuse() {
|
||||
|
||||
run_opted_out() {
|
||||
echo "[mem-cap] WAARSCHUWING: plafond bewust uitgeschakeld, dit commando kan de machine laten OOM-killed worden: $*" >&2
|
||||
if command -v taskset >/dev/null 2>&1; then
|
||||
exec taskset -c "$cpu_affinity" "$@"
|
||||
else
|
||||
exec "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
case "$backend" in
|
||||
|
||||
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
|
||||
import { env } from "@/env";
|
||||
import { invalidateLoginCache } from "@/lib/auth/login-core";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { passwordPolicy } from "@/lib/auth/password-policy";
|
||||
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
|
||||
import { db, PasswordReset, User } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -109,7 +110,11 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
let error: "password" | "invalid" | "failed" | null = null;
|
||||
if (password.length < 12) error = "password";
|
||||
if (
|
||||
password.length < passwordPolicy.minLength ||
|
||||
password.length > passwordPolicy.maxLength
|
||||
)
|
||||
error = "password";
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
|
||||
+10
-4
@@ -5,6 +5,7 @@ import { after } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { sendVerification } from "@/lib/auth/email-verification";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { passwordPolicy } from "@/lib/auth/password-policy";
|
||||
import { DEFAULT_AVATAR_FIGURE } from "@/lib/avatar-figure";
|
||||
import { invalidateKey } from "@/lib/cached-db";
|
||||
import { db, User } from "@/lib/db";
|
||||
@@ -83,6 +84,11 @@ function hasDisposableEmailDomain(email: string): boolean {
|
||||
return domain ? DISPOSABLE_EMAIL_DOMAINS.has(domain) : false;
|
||||
}
|
||||
|
||||
// Rendered into the schema below and mapped back to a locale-independent code
|
||||
// via `ZOD_MESSAGE_CODES`, so the length always reflects the configured policy.
|
||||
const PASSWORD_MIN_MESSAGE = `Password must be at least ${passwordPolicy.minLength} characters`;
|
||||
const PASSWORD_MAX_MESSAGE = "Password is too long";
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
username: z
|
||||
@@ -105,8 +111,8 @@ const registerSchema = z
|
||||
),
|
||||
password: z
|
||||
.string()
|
||||
.min(12, "Password must be at least 12 characters") // Increased min length
|
||||
.max(128, "Password is too long") // Added max length
|
||||
.min(passwordPolicy.minLength, PASSWORD_MIN_MESSAGE)
|
||||
.max(passwordPolicy.maxLength, PASSWORD_MAX_MESSAGE)
|
||||
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||
.regex(/[0-9]/, "Password must contain at least one digit")
|
||||
@@ -160,8 +166,8 @@ const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
|
||||
"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_MIN_MESSAGE]: "passwordMinLength",
|
||||
[PASSWORD_MAX_MESSAGE]: "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",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ContentCard } from "@/components/public/ui";
|
||||
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { publicAssetExists } from "@/lib/public-asset";
|
||||
import { sanitize } from "@/lib/sanitize";
|
||||
|
||||
// Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/<image_url>').
|
||||
@@ -99,7 +100,10 @@ export default async function HelpCategoryPage({
|
||||
const title = withHotel(cat.name, hotelName);
|
||||
const content = withHotel(cat.content, hotelName);
|
||||
const buttonText = withHotel(cat.buttonText, hotelName);
|
||||
const imageSrc = cat.imageUrl ? `${HELP_IMAGE_BASE}/${cat.imageUrl}` : "";
|
||||
const imageSrc =
|
||||
cat.imageUrl && publicAssetExists(`${HELP_IMAGE_BASE}/${cat.imageUrl}`)
|
||||
? `${HELP_IMAGE_BASE}/${cat.imageUrl}`
|
||||
: "";
|
||||
const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== "");
|
||||
|
||||
return (
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
WebsiteRuleCategories,
|
||||
WebsiteRules,
|
||||
} from "@/lib/db";
|
||||
import { publicAssetExists } from "@/lib/public-asset";
|
||||
|
||||
// Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/<image_url>').
|
||||
const HELP_IMAGE_BASE = "/assets/images/help-center";
|
||||
|
||||
type HelpCategory = {
|
||||
id: bigint;
|
||||
@@ -149,7 +153,11 @@ export default async function HelpCenterPage() {
|
||||
<EmptyState icon="🧭">{t("topicsEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{categories.map((c) => (
|
||||
{categories.map((c) => {
|
||||
const imageSrc = c.imageUrl
|
||||
? `${HELP_IMAGE_BASE}/${c.imageUrl}`
|
||||
: "";
|
||||
return (
|
||||
<article key={String(c.id)} className="card hover">
|
||||
<div
|
||||
style={{
|
||||
@@ -158,9 +166,9 @@ export default async function HelpCenterPage() {
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{c.imageUrl ? (
|
||||
{imageSrc && publicAssetExists(imageSrc) ? (
|
||||
<img
|
||||
src={c.imageUrl}
|
||||
src={imageSrc}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
@@ -208,7 +216,8 @@ export default async function HelpCenterPage() {
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { headers } from "next/headers";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -34,28 +35,43 @@ export default async function SiteLayout({
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [session, locale, messages] = await Promise.all([
|
||||
const [session, locale, messages, h] = await Promise.all([
|
||||
auth(),
|
||||
getLocale(),
|
||||
getMessages(),
|
||||
headers(),
|
||||
]);
|
||||
const clientMessages = publicClientMessages(messages);
|
||||
|
||||
// The landing pages render their own AuthTopBar (logo + live online), so the
|
||||
// global site banner / navigation is hidden there to keep them clean and
|
||||
// avoid showing the same chrome twice. Everything else keeps the chrome.
|
||||
const pathname = h.get("x-pathname");
|
||||
const isLandingPage =
|
||||
pathname === "/" || pathname === "/login" || pathname === "/register";
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider locale={locale} messages={clientMessages}>
|
||||
<CloudsField />
|
||||
{/* Site chrome is public: hiding it all for anonymous visitors used to
|
||||
strand them — from /news, /leaderboard or /shop there was no way to
|
||||
reach any other page at all. */}
|
||||
reach any other page at all. Only the standalone landing pages opt
|
||||
out, because they carry their own header. */}
|
||||
{!isLandingPage && (
|
||||
<div data-theme-block="top_header">
|
||||
<TopHeader session={session} />
|
||||
</div>
|
||||
)}
|
||||
{!isLandingPage && (
|
||||
<div data-theme-block="site_header">
|
||||
<SiteHeader />
|
||||
</div>
|
||||
)}
|
||||
{!isLandingPage && (
|
||||
<div data-theme-block="navigation">
|
||||
<Navigation session={session} />
|
||||
</div>
|
||||
)}
|
||||
<main>
|
||||
<div
|
||||
data-theme-block="content_grid"
|
||||
|
||||
+25
-163
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { buildNewsRss } from "@/lib/news-rss";
|
||||
import { appBaseUrl } from "@/lib/seo";
|
||||
import { getNewsList } from "@/lib/services/news-list";
|
||||
|
||||
// Built per request so the feed never needs a database connection during
|
||||
// `next build`, matching the sitemap and robots routes.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const [hotelName, items] = await Promise.all([
|
||||
resolveHotelName(),
|
||||
getNewsList(30),
|
||||
]);
|
||||
|
||||
return new Response(
|
||||
buildNewsRss(items, { hotelName, appUrl: appBaseUrl() }),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/rss+xml; charset=utf-8",
|
||||
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
+307
-418
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import { resetPassword } from "@/actions/password-reset";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { passwordPolicy } from "@/lib/auth/password-policy";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.reset");
|
||||
@@ -41,8 +42,12 @@ export default async function ResetPage({
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
placeholder={t("passwordPlaceholder", {
|
||||
min: passwordPolicy.minLength,
|
||||
})}
|
||||
autoComplete="new-password"
|
||||
minLength={passwordPolicy.minLength}
|
||||
maxLength={passwordPolicy.maxLength}
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
@@ -58,7 +63,7 @@ export default async function ResetPage({
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
{t(errorKey)}
|
||||
{t(errorKey, { min: passwordPolicy.minLength })}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
|
||||
@@ -87,7 +87,7 @@ export default async function AdminAntiDdosPage() {
|
||||
const ip = key.replace("antiddos:block:", "");
|
||||
return {
|
||||
ip,
|
||||
ttlMs: ttlMs > 0 ? ttlMs : 0,
|
||||
ttlMs: Math.max(ttlMs, 0),
|
||||
count: violationCounts.get(ip) ?? 0,
|
||||
source: "gate" as const,
|
||||
};
|
||||
|
||||
@@ -63,7 +63,7 @@ export default async function AdminRadioHistoryPage() {
|
||||
|
||||
const liveCount = history.filter((h) => !h.endedAt).length;
|
||||
const peakListeners = history.reduce(
|
||||
(max, h) => (h.listenersCount > max ? h.listenersCount : max),
|
||||
(max, h) => Math.max(h.listenersCount, max),
|
||||
0,
|
||||
);
|
||||
const uniqueDjs = new Set(history.map((h) => String(h.userId))).size;
|
||||
|
||||
+193
-83
File diff suppressed because it is too large.
Load diff
+19
-3
@@ -38,7 +38,10 @@ const pixelify = Pixelify_Sans({
|
||||
// export const instant = false;
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
||||
const appUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
const hotel = await resolveHotelName();
|
||||
const customFavicon = await siteSettings.get("cms_favicon");
|
||||
const faviconUrl = customFavicon ?? getDefaultFaviconDataUri();
|
||||
@@ -50,15 +53,28 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
title: { default: `${hotel} - Hotel`, template: `%s · ${hotel}` },
|
||||
description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`,
|
||||
metadataBase: new URL(appUrl),
|
||||
alternates: { canonical: appUrl },
|
||||
alternates: {
|
||||
canonical: appUrl,
|
||||
types: { "application/rss+xml": `${appUrl}/news/rss.xml` },
|
||||
},
|
||||
icons,
|
||||
openGraph: {
|
||||
title: `${hotel} - Hotel`,
|
||||
description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`,
|
||||
type: "website",
|
||||
siteName: hotel,
|
||||
images: [
|
||||
{
|
||||
url: `${appUrl}/assets/images/next_header.jpg`,
|
||||
alt: `${hotel} - Hotel`,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: `${hotel} - Hotel`,
|
||||
images: [`${appUrl}/assets/images/next_header.jpg`],
|
||||
},
|
||||
twitter: { card: "summary_large_image", title: `${hotel} - Hotel` },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export function AdminMediaGrid() {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const copyTimers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -40,6 +41,12 @@ export function AdminMediaGrid() {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const t of copyTimers.current) clearTimeout(t);
|
||||
copyTimers.current.clear();
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface AuthAlertProps {
|
||||
children: ReactNode;
|
||||
/** Tint of the box: red for errors, green for confirmations. */
|
||||
variant?: "error" | "success";
|
||||
/** Extra classes, e.g. spacing (`mb-4`) or entrance animation. */
|
||||
className?: string;
|
||||
/** Optional id so inputs can point at the message via `aria-describedby`. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single status/error banner for the auth forms. Uses `role="alert"` for
|
||||
* failures (screen readers announce them immediately) and `role="status"` for
|
||||
* confirmations, so the two are wired correctly instead of every caller
|
||||
* repeating the markup and risking an unannounced message.
|
||||
*/
|
||||
export function AuthAlert({
|
||||
children,
|
||||
variant = "error",
|
||||
className = "",
|
||||
id,
|
||||
}: AuthAlertProps) {
|
||||
return (
|
||||
<p
|
||||
id={id}
|
||||
role={variant === "error" ? "alert" : "status"}
|
||||
className={`auth-alert animate-fade-in-up m-0 ${
|
||||
variant === "success" ? "auth-alert--success" : ""
|
||||
} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -128,6 +128,8 @@ function AvatarGrid({
|
||||
interface AuthPageFrameProps {
|
||||
hotelName: string;
|
||||
logo?: string | null;
|
||||
/** Live online count shown in the top bar; omit to hide it. */
|
||||
online?: number;
|
||||
/** Title of the form card on the right. */
|
||||
title: string;
|
||||
/** One-line intro above the form. */
|
||||
@@ -148,6 +150,7 @@ interface AuthPageFrameProps {
|
||||
export function AuthPageFrame({
|
||||
hotelName,
|
||||
logo,
|
||||
online,
|
||||
title,
|
||||
subtitle,
|
||||
notice,
|
||||
@@ -158,7 +161,7 @@ export function AuthPageFrame({
|
||||
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
|
||||
<div className="relative">
|
||||
<AuthAurora />
|
||||
<AuthTopBar hotelName={hotelName} logo={logo} />
|
||||
<AuthTopBar hotelName={hotelName} logo={logo} online={online} />
|
||||
</div>
|
||||
|
||||
<Reveal>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,41 +1,38 @@
|
||||
import Image from "next/image";
|
||||
import { Clock } from "@/components/clock";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { LiveOnlineCount } from "@/components/live-online-count";
|
||||
import { Reveal } from "@/components/motion-reveal";
|
||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||
import { ICON_LIGHTHOUSE } from "@/lib/site-icons";
|
||||
|
||||
/**
|
||||
* Shared frosted-glass top bar for the public auth pages (login, register,
|
||||
* etc.). Shows the hotel logo + name on the left and language / clock / theme
|
||||
* controls on the right. Defaults mirror the homepage top bar so auth pages
|
||||
* share one consistent look.
|
||||
* Shared frosted-glass top bar for the public pages (index, login, register).
|
||||
* Shows the hotel logo on the left and the live "online" counter (which carries
|
||||
* the hotel name), language / clock / theme controls on the right. One
|
||||
* component so every public page keeps the exact same chrome instead of
|
||||
* duplicating it.
|
||||
*/
|
||||
export function AuthTopBar({
|
||||
hotelName,
|
||||
logo,
|
||||
showHotelName = true,
|
||||
online,
|
||||
}: {
|
||||
hotelName: string;
|
||||
logo?: string | null;
|
||||
showHotelName?: boolean;
|
||||
/** Live online count; omit (or pass null) to hide the indicator. */
|
||||
online?: number | null;
|
||||
}) {
|
||||
return (
|
||||
<Reveal>
|
||||
<div
|
||||
className="relative flex items-center justify-between rounded-full border px-3 py-2.5 sm:px-5"
|
||||
className="panel relative flex items-center justify-between gap-3 rounded-full px-3 py-2.5 sm:px-5"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, color-mix(in srgb, var(--color-surface) 62%, transparent), color-mix(in srgb, var(--color-primary) 7%, transparent))",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||
boxShadow:
|
||||
"0 1px 3px rgba(0,0,0,0.04), 0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent)",
|
||||
"linear-gradient(90deg, var(--panel-surface), color-mix(in srgb, var(--color-primary) 7%, var(--panel-surface)))",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||
{logo ? (
|
||||
<Image
|
||||
src={logo}
|
||||
@@ -67,14 +64,6 @@ export function AuthTopBar({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showHotelName ? (
|
||||
<span
|
||||
className="hidden text-sm font-extrabold uppercase tracking-wide sm:inline"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{hotelName}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 sm:gap-1.5 rounded-full px-1.5 py-1 sm:px-2"
|
||||
@@ -83,6 +72,31 @@ export function AuthTopBar({
|
||||
"color-mix(in srgb, var(--color-text-muted) 5%, transparent)",
|
||||
}}
|
||||
>
|
||||
{typeof online === "number" && (
|
||||
<span
|
||||
className="flex items-center gap-1.5 px-1"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
<span className="relative flex h-2 w-2 shrink-0">
|
||||
<span
|
||||
className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-70"
|
||||
style={{ background: "var(--color-primary)" }}
|
||||
/>
|
||||
<span
|
||||
className="relative inline-flex h-2 w-2 rounded-full"
|
||||
style={{ background: "var(--color-primary)" }}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-black tabular-nums"
|
||||
style={{
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
<LiveOnlineCount initial={online} hotelName={hotelName} />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
<div
|
||||
className="hidden sm:block w-px h-5"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -267,7 +267,7 @@ export function LanguageSwitcher({
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
disabled={pending}
|
||||
aria-label="Change language"
|
||||
aria-label={`Change language — ${current.code.toUpperCase()}`}
|
||||
title="Language"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Renders schema.org JSON-LD. The payload is data, not executable script, but
|
||||
* `<` is escaped so an article title can never terminate the tag early.
|
||||
*/
|
||||
export function JsonLd({
|
||||
data,
|
||||
nonce,
|
||||
}: {
|
||||
data: Record<string, unknown> | Record<string, unknown>[];
|
||||
nonce?: string;
|
||||
}) {
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(data).replace(/</g, "\\u003c"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const compactCallSites = new Map<string, number>([
|
||||
["src/app/(site)/leaderboard/page.tsx", 1],
|
||||
["src/app/(site)/me/page.tsx", 1],
|
||||
["src/app/(site)/messages/page.tsx", 2],
|
||||
["src/app/(site)/page.tsx", 2],
|
||||
["src/app/(site)/page.tsx", 1],
|
||||
["src/app/(site)/radio/leaderboard/page.tsx", 1],
|
||||
["src/app/(site)/radio/shouts/page.tsx", 1],
|
||||
["src/app/(site)/rankings/page.tsx", 1],
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, MouseEvent, ReactNode } from "react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
interface SpotlightCardProps {
|
||||
children: ReactNode;
|
||||
@@ -12,22 +18,48 @@ interface SpotlightCardProps {
|
||||
* Wraps a card with a mouse-tracking radial glow. The pointer position is
|
||||
* written to `--spot-x` / `--spot-y` and consumed by the `.spotlight::before`
|
||||
* overlay in globals.css.
|
||||
*
|
||||
* The pointer move handler is throttled to one update per animation frame:
|
||||
* a pointermove fires far more often than the browser paints, and every
|
||||
* extra write re-styles the card (and its ::before gradient) for nothing.
|
||||
*/
|
||||
export function SpotlightCard({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: SpotlightCardProps) {
|
||||
const frame = useRef(0);
|
||||
const pending = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Drop the queued frame if the card is removed mid-gesture.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (frame.current) cancelAnimationFrame(frame.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePointerMove = (event: MouseEvent<HTMLDivElement>) => {
|
||||
const el = event.currentTarget;
|
||||
const rect = el.getBoundingClientRect();
|
||||
el.style.setProperty("--spot-x", `${event.clientX - rect.left}px`);
|
||||
el.style.setProperty("--spot-y", `${event.clientY - rect.top}px`);
|
||||
pending.current = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
};
|
||||
if (frame.current) return;
|
||||
|
||||
frame.current = requestAnimationFrame(() => {
|
||||
frame.current = 0;
|
||||
const point = pending.current;
|
||||
if (!point) return;
|
||||
el.style.setProperty("--spot-x", `${point.x}px`);
|
||||
el.style.setProperty("--spot-y", `${point.y}px`);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
className={className ? `spotlight ${className}` : "spotlight"}
|
||||
style={
|
||||
{ "--spot-x": "50%", "--spot-y": "50%", ...style } as CSSProperties
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { publicAssetExists } from "@/lib/public-asset";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import {
|
||||
BACKGROUND_DEFAULTS,
|
||||
@@ -240,9 +241,19 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
||||
? Math.min(parseInt(backgroundOverlayOpacity as string, 10), 90)
|
||||
: 0;
|
||||
|
||||
// The wallpaper is painted as the *first* background layer on top of the
|
||||
// theme gradient (`--site-bg-gradient`). It used to be the only layer,
|
||||
// which meant a missing upload wiped the sky entirely and left the site on
|
||||
// flat white. Layering keeps the gradient as a guaranteed fallback.
|
||||
//
|
||||
// A local wallpaper that no longer exists on disk (deleted upload, stale
|
||||
// theme setting) would also fire a 404 on every page view, so only emit the
|
||||
// layer when the file is actually there. Remote URLs are trusted as-is.
|
||||
const backgroundCss =
|
||||
backgroundImage && isSafeBackgroundUrl(backgroundImage)
|
||||
? `body.site-bg{background-image:url("${backgroundImage}");background-position:${safeBgPosition};background-size:${safeBgSize};background-repeat:${safeBgRepeat}}`
|
||||
backgroundImage &&
|
||||
isSafeBackgroundUrl(backgroundImage) &&
|
||||
publicAssetExists(backgroundImage)
|
||||
? `body.site-bg{background-image:url("${backgroundImage}"),var(--site-bg-gradient);background-position:${safeBgPosition},center top;background-size:${safeBgSize},cover;background-repeat:${safeBgRepeat},no-repeat}`
|
||||
: "";
|
||||
|
||||
const backdropStyle: CSSProperties | undefined = safeOverlay
|
||||
|
||||
@@ -71,6 +71,11 @@ const schema = z
|
||||
|
||||
// bcrypt cost factor used for new password hashes.
|
||||
BCRYPT_COST: z.coerce.number().int().min(4).max(31).default(12),
|
||||
// Password-length policy for every new password (register, password reset
|
||||
// and staff-created accounts). The minimum may be set between 5 and 30
|
||||
// characters, the maximum between 30 and 128, so min always fits under max.
|
||||
PASSWORD_MIN_LENGTH: z.coerce.number().int().min(5).max(30).default(12),
|
||||
PASSWORD_MAX_LENGTH: z.coerce.number().int().min(30).max(128).default(128),
|
||||
// Filesystem dir the badge uploader writes <code>.gif into (the emulator's
|
||||
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
|
||||
// when unset.
|
||||
|
||||
@@ -32,12 +32,12 @@ export async function register() {
|
||||
);
|
||||
const timer = setInterval(() => {
|
||||
void drainFurnitureImports();
|
||||
}, 30000);
|
||||
}, 60000);
|
||||
timer.unref();
|
||||
const { sweepExpiredCloudflareBlocks } = await import("@/lib/cloudflare-api");
|
||||
const cloudflareSweep = setInterval(() => {
|
||||
void sweepExpiredCloudflareBlocks();
|
||||
}, 30000);
|
||||
}, 60000);
|
||||
cloudflareSweep.unref();
|
||||
|
||||
// A deploy starts with an empty in-process cache, so prime the hot public
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { env } from "@/env";
|
||||
|
||||
/** Hard floor/ceiling so a bad env value can never lock accounts out. */
|
||||
const ABSOLUTE_MIN_LENGTH = 5;
|
||||
const ABSOLUTE_MAX_LENGTH = 128;
|
||||
|
||||
function toLength(value: unknown, fallback: number): number {
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective password-length policy. `PASSWORD_MIN_LENGTH` / `PASSWORD_MAX_LENGTH`
|
||||
* drive the bounds; the values are clamped to a sane window so a typo in the env
|
||||
* can neither accept empty passwords nor reject every possible one.
|
||||
*/
|
||||
export const passwordPolicy = (() => {
|
||||
const minLength = Math.min(
|
||||
ABSOLUTE_MAX_LENGTH,
|
||||
Math.max(ABSOLUTE_MIN_LENGTH, toLength(env.PASSWORD_MIN_LENGTH, 12)),
|
||||
);
|
||||
const maxLength = Math.max(
|
||||
minLength,
|
||||
Math.min(ABSOLUTE_MAX_LENGTH, toLength(env.PASSWORD_MAX_LENGTH, 128)),
|
||||
);
|
||||
return { minLength, maxLength };
|
||||
})();
|
||||
+14
-3
@@ -49,7 +49,7 @@ function readPositiveInt(name: string, fallback: number): number {
|
||||
// In-process budget. Entries are evicted least-recently-*used* (see getMemory),
|
||||
// so a hot key only leaves when a hotter one takes its place. The default is
|
||||
// generous because the layer exists to keep origin calls off the hot path.
|
||||
const MAX_MEMORY_ENTRIES = readPositiveInt("CACHE_MEMORY_MAX_ENTRIES", 2_000);
|
||||
const MAX_MEMORY_ENTRIES = readPositiveInt("CACHE_MEMORY_MAX_ENTRIES", 1_000);
|
||||
|
||||
// Single-flight: a key being (re)computed is awaited by concurrent callers
|
||||
// instead of each starting its own `fn()` (cache-stampede protection).
|
||||
@@ -62,9 +62,20 @@ const generations = new Map<string, number>();
|
||||
const MAX_GENERATION_ENTRIES = MAX_MEMORY_ENTRIES * 2;
|
||||
|
||||
// Keys whose most recent background refresh failed, so a persistently broken
|
||||
// origin reports once instead of on every read of a hot key.
|
||||
// origin reports once instead of on every read of a hot key. Bounded so a
|
||||
// high-cardinality stream of failing keys can never grow this without limit.
|
||||
const MAX_REPORTED_FAILURES = 500;
|
||||
const reportedFailures = new Set<string>();
|
||||
|
||||
function recordReportedFailure(key: string): void {
|
||||
if (reportedFailures.has(key)) return;
|
||||
if (reportedFailures.size >= MAX_REPORTED_FAILURES) {
|
||||
const oldest = reportedFailures.values().next().value;
|
||||
if (oldest !== undefined) reportedFailures.delete(oldest);
|
||||
}
|
||||
reportedFailures.add(key);
|
||||
}
|
||||
|
||||
/** Drop a key from the in-process cache (used when an upstream value changes). */
|
||||
export function invalidateMemory(key: string): void {
|
||||
generations.set(key, (generations.get(key) ?? 0) + 1);
|
||||
@@ -180,7 +191,7 @@ export async function cached<T>(
|
||||
// read: a broken origin would otherwise flood the log from a
|
||||
// single hot key.
|
||||
if (reportedFailures.has(key)) return;
|
||||
reportedFailures.add(key);
|
||||
recordReportedFailure(key);
|
||||
logger.warn("[cache] background refresh failed", {
|
||||
key,
|
||||
error: String(error),
|
||||
|
||||
@@ -29,6 +29,7 @@ function simulate(
|
||||
scenario: string,
|
||||
previous = "epicnext-cms-app",
|
||||
both = false,
|
||||
extraEnv: Record<string, string> = {},
|
||||
) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cms-deploy-test-"));
|
||||
try {
|
||||
@@ -58,6 +59,9 @@ function simulate(
|
||||
TEST_SHA: sha,
|
||||
SCENARIO: scenario,
|
||||
DEPLOY_BRANCH: "main",
|
||||
// Standaard uit: de tests rimen geen cache-map aan op de host.
|
||||
CMS_BUILD_CACHE_DIR: "",
|
||||
...extraEnv,
|
||||
},
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
@@ -180,6 +184,24 @@ describe("deployment transaction", () => {
|
||||
expect(result.calls).not.toContain("pnpm db:migrate");
|
||||
expect(result.calls).not.toContain("docker run");
|
||||
});
|
||||
// `docker builder prune` (na elke release) gooit de buildcache weg. Zonder
|
||||
// duurzame export downloadt de volgende build alle packages opnieuw.
|
||||
it("bouwt met een duurzame buildcache buiten de werkboom", () => {
|
||||
const cacheDirectory = mkdtempSync(join(tmpdir(), "cms-deploy-cache-"));
|
||||
try {
|
||||
const cache = join(cacheDirectory, "cache");
|
||||
const result = simulate("success", "epicnext-cms-app", false, {
|
||||
CMS_BUILD_CACHE_DIR: cache,
|
||||
});
|
||||
expect(result.status, result.output).toBe(0);
|
||||
expect(result.calls).toContain(`--cache-from type=local,src=${cache}`);
|
||||
expect(result.calls).toContain(
|
||||
`--cache-to type=local,dest=${cache},mode=max`,
|
||||
);
|
||||
} finally {
|
||||
rmSync(cacheDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy and CI container coexistence", () => {
|
||||
@@ -248,6 +270,8 @@ function simulateBlueGreen(scenario: string, livePort = 3002) {
|
||||
TEST_SHA: sha,
|
||||
SCENARIO: scenario,
|
||||
DEPLOY_BRANCH: "main",
|
||||
// Standaard uit: de tests rimen geen cache-map aan op de host.
|
||||
CMS_BUILD_CACHE_DIR: "",
|
||||
},
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
|
||||
@@ -25,7 +25,7 @@ const bash =
|
||||
.find(existsSync) ?? "bash")
|
||||
: "bash";
|
||||
const sha = "d".repeat(40);
|
||||
function simulate(scenario: string) {
|
||||
function simulate(scenario: string, extraEnv: Record<string, string> = {}) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "cms-preflight-test-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
@@ -48,6 +48,9 @@ function simulate(scenario: string) {
|
||||
SCENARIO: scenario,
|
||||
CMS_DEPLOY_DIR: "/must-not-read-production",
|
||||
DATABASE_URL: "must-not-use-production",
|
||||
// Standaard uit: de tests rimen geen cache-map aan op de host.
|
||||
CMS_BUILD_CACHE_DIR: "",
|
||||
...extraEnv,
|
||||
},
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
@@ -103,6 +106,24 @@ describe("isolated branch preflight", () => {
|
||||
expect(result.remaining).toEqual([]);
|
||||
});
|
||||
|
||||
it("hergebruikt een duurzame buildcache buiten de tijdelijke werkmap", () => {
|
||||
const cacheDirectory = mkdtempSync(join(tmpdir(), "cms-preflight-cache-"));
|
||||
try {
|
||||
const cache = join(cacheDirectory, "cache");
|
||||
const result = simulate("success", { CMS_BUILD_CACHE_DIR: cache });
|
||||
expect(result.status, result.output).toBe(0);
|
||||
expect(result.calls).toContain(`--cache-from type=local,src=${cache}`);
|
||||
expect(result.calls).toContain(
|
||||
`--cache-to type=local,dest=${cache},mode=max`,
|
||||
);
|
||||
// De cache mag nooit in de tijdelijke werkmap van de preflight
|
||||
// belanden: die wordt na de run geleegd.
|
||||
expect(result.remaining).toEqual([]);
|
||||
} finally {
|
||||
rmSync(cacheDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("stops after a failed build and cleans only its own attempted image", () => {
|
||||
const result = simulate("build-failure");
|
||||
expect(result.status).not.toBe(0);
|
||||
|
||||
+21
-1
@@ -35,9 +35,29 @@ const HOSTILE_PATH_SEGMENTS = new Set([
|
||||
"server-status",
|
||||
".env",
|
||||
".git",
|
||||
"cmd.exe", // Windows command execution
|
||||
"shell", // Generic web shell indicator
|
||||
"jndi", // Log4j exploit attempts
|
||||
"tomcat", // Tomcat exploitation attempts
|
||||
"web-inf", // Java web app directory exposure
|
||||
"passwd", // Linux password file attempts
|
||||
"etc", // Linux configuration directory traversal
|
||||
]);
|
||||
|
||||
const HOSTILE_PATH_EXTENSIONS = [".php", ".asp", ".aspx", ".jsp", ".cgi"];
|
||||
const HOSTILE_PATH_EXTENSIONS = [
|
||||
".php",
|
||||
".asp",
|
||||
".aspx",
|
||||
".jsp",
|
||||
".cgi",
|
||||
".env", // Direct .env file access
|
||||
".bak", // Backup files
|
||||
".sql", // Database dumps
|
||||
".zip", // Archives that may contain sensitive data
|
||||
".rar", // Archives that may contain sensitive data
|
||||
".7z", // Archives that may contain sensitive data
|
||||
".txt", // Generic text files that might contain sensitive data
|
||||
];
|
||||
|
||||
/**
|
||||
* Cheap scanner/exploit triage. These paths are never routes in this app, so a
|
||||
|
||||
@@ -80,3 +80,23 @@ it("no longer publishes container images in CI", () => {
|
||||
expect(workflow).not.toContain("publish-container");
|
||||
expect(workflow).not.toContain("publish-container.sh");
|
||||
});
|
||||
|
||||
it("keeps een duurzame buildcache buiten de CI-werkboom", () => {
|
||||
const preflight = readFileSync("scripts/ci-preflight.sh", "utf8");
|
||||
for (const script of [deploy, preflight]) {
|
||||
// `docker builder prune` gooit de buildcache weg en de CI-werkboom wordt
|
||||
// na elke job gewist. Zonder duurzame export downloadt de volgende build
|
||||
// alle 493 packages opnieuw; gemeten: reused 0 / downloaded 493.
|
||||
expect(script).toContain('--cache-from "type=local,src=$build_cache_dir"');
|
||||
expect(script).toContain(
|
||||
'--cache-to "type=local,dest=$build_cache_dir,mode=max"',
|
||||
);
|
||||
expect(script).toContain("CMS_BUILD_CACHE_DIR");
|
||||
// Zonder schrijftoegang bouwen we gewoon zonder cache; dat mag de
|
||||
// release niet blokkeren.
|
||||
expect(script).toContain("build zonder hergebruikte cache");
|
||||
}
|
||||
// Eigen map voor de preflight: een branch mag de release-cache nooit voeden.
|
||||
expect(deploy).toContain("/var/cache/epicnext-cms-buildcache");
|
||||
expect(preflight).toContain("/var/cache/epicnext-cms-preflight-cache");
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dockerfile = readFileSync("Dockerfile", "utf8");
|
||||
@@ -47,6 +48,17 @@ it("passes a compiled release to both the application build and final image", ()
|
||||
expect(compose).toContain("NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}");
|
||||
});
|
||||
|
||||
it("keeps the compose CMS opt-in and inside the 4 GB limit", () => {
|
||||
const compose = readFileSync("docker-compose.yml", "utf8");
|
||||
// A bare `docker compose up -d` starts only byparr. On a CI-managed host the
|
||||
// CMS owns port 3002 through ci-deploy.sh; letting compose claim the same
|
||||
// port again produces an endless EADDRINUSE crash loop.
|
||||
expect(compose).toContain('profiles: ["cms"]');
|
||||
expect(compose).not.toContain("cms-green");
|
||||
expect(compose).toContain("mem_limit: 4g");
|
||||
expect(compose).toContain("memswap_limit: 5g");
|
||||
});
|
||||
|
||||
it("builds with fixtures and excludes installation secrets from every stage", () => {
|
||||
const ignored = readFileSync(".dockerignore", "utf8");
|
||||
expect(ignored).toMatch(/^\.env$/m);
|
||||
@@ -76,3 +88,74 @@ it("does not prerender installation metadata into a shared image", () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves pnpm from the packageManager pin instead of a hardcoded version", () => {
|
||||
// Een vastgepind pnpm-pakket in de Dockerfile loopt altijd uit sync met de
|
||||
// packageManager-pin in package.json (12.10.1 vs 12.11.2).
|
||||
expect(dockerfile).not.toMatch(/npm install -g pnpm@/);
|
||||
expect(dockerfile).toContain("corepack enable pnpm");
|
||||
expect(dockerfile).toContain("corepack install");
|
||||
|
||||
// De gedownloade pnpm moet in de image staan, anders kan de runtime
|
||||
// (`docker run --entrypoint pnpm <migrations-image> db:migrate`) pnpm niet
|
||||
// gebruiken. Een cache-mount verdwijnt na de build.
|
||||
const corepackHome = dockerfile.match(/ENV COREPACK_HOME="([^"]+)"/)?.[1];
|
||||
expect(corepackHome).toBeTruthy();
|
||||
expect(dockerfile).not.toContain(`--mount=type=cache,target=${corepackHome}`);
|
||||
});
|
||||
|
||||
it("caches the pnpm store on the path pnpm actually uses", () => {
|
||||
const pnpmHome = dockerfile.match(/ENV PNPM_HOME="([^"]+)"/)?.[1];
|
||||
expect(pnpmHome).toBeTruthy();
|
||||
const cacheMounts = [
|
||||
...dockerfile.matchAll(/--mount=type=cache,target=([^\s\\]+)/g),
|
||||
].map((match) => match[1]);
|
||||
const storeMounts = cacheMounts.filter((target) => target.includes("pnpm"));
|
||||
expect(storeMounts.length).toBeGreaterThan(0);
|
||||
// Met een ander pad (bv. /root/.local/share/pnpm/store) cache't de mount
|
||||
// niets en downloadt elke build alle packages opnieuw.
|
||||
for (const target of storeMounts) {
|
||||
expect(target).toBe(`${pnpmHome}/store`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps volatile and regenerated files out of the build context", () => {
|
||||
const ignored = readFileSync(".dockerignore", "utf8");
|
||||
// `backups/` (dagelijkse rotatie, ~232MB) maakte elke buildcontext groot
|
||||
// en invalideerde bij elke rotatie de `COPY . .`-laag.
|
||||
expect(ignored).toMatch(/^backups\/?$/m);
|
||||
// public/vendor/tinymce wordt tijdens de build opnieuw gegenereerd door
|
||||
// `pnpm assets:editor` en staat niet in git.
|
||||
expect(ignored).toMatch(/^public\/vendor\/?$/m);
|
||||
});
|
||||
|
||||
it("keeps de testsuites buiten de buildcontext", () => {
|
||||
const ignored = readFileSync(".dockerignore", "utf8");
|
||||
// `next build` typecheckt het hele project. Met e2e/ en integration/ in de
|
||||
// context faalt de image-build op een typefout in testcode en sleurt hij
|
||||
// @playwright/test, vitest, msw en testcontainers mee. De CI draait die
|
||||
// suites op de host (pnpm test:ui / test:integration).
|
||||
expect(ignored).toMatch(/^e2e\/?$/m);
|
||||
expect(ignored).toMatch(/^integration\/?$/m);
|
||||
// Geen applicatiecode mag naar die mappen importeren: dan zou de build in
|
||||
// de image breken zodra ze uit de context verdwijnen.
|
||||
const offenders: string[] = [];
|
||||
for (const entry of readdirSync("src", { recursive: true })) {
|
||||
if (!/\.tsx?$/.test(entry)) continue;
|
||||
const source = readFileSync(join("src", entry), "utf8");
|
||||
if (/from\s+["'][^"']*\/(?:e2e|integration)\//.test(source)) {
|
||||
offenders.push(entry);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a local build cache that survives the daemon cache prune", () => {
|
||||
const compose = readFileSync("docker-compose.yml", "utf8");
|
||||
expect(compose).toContain("cache_from:");
|
||||
expect(compose).toContain("cache_to:");
|
||||
expect(compose).toContain("type=local");
|
||||
// De cache-map mag niet in de buildcontext of in git terechtkomen.
|
||||
expect(readFileSync(".dockerignore", "utf8")).toMatch(/^\.docker-cache\/?$/m);
|
||||
expect(readFileSync(".gitignore", "utf8")).toMatch(/^\.docker-cache\/?$/m);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { absoluteAssetUrl, escapeXml } from "@/lib/seo";
|
||||
|
||||
export interface NewsRssItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
shortStory: string;
|
||||
image: string;
|
||||
createdAt: Date | null;
|
||||
}
|
||||
|
||||
function imageMime(url: string): string {
|
||||
const extension = url.split("?")[0]?.split(".").pop()?.toLowerCase();
|
||||
switch (extension) {
|
||||
case "png":
|
||||
return "image/png";
|
||||
case "gif":
|
||||
return "image/gif";
|
||||
case "webp":
|
||||
return "image/webp";
|
||||
case "avif":
|
||||
return "image/avif";
|
||||
default:
|
||||
return "image/jpeg";
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a standards-compliant RSS 2.0 feed for the public news archive. */
|
||||
export function buildNewsRss(
|
||||
items: NewsRssItem[],
|
||||
options: { hotelName: string; appUrl: string; now?: Date },
|
||||
): string {
|
||||
const base = options.appUrl.replace(/\/+$/, "");
|
||||
const now = options.now ?? new Date();
|
||||
|
||||
const entries = items
|
||||
.map((item) => {
|
||||
const url = `${base}/news/${encodeURIComponent(item.slug)}`;
|
||||
const image = absoluteAssetUrl(item.image, base);
|
||||
return [
|
||||
"<item>",
|
||||
`<title>${escapeXml(item.title)}</title>`,
|
||||
`<link>${escapeXml(url)}</link>`,
|
||||
`<guid isPermaLink="true">${escapeXml(url)}</guid>`,
|
||||
item.createdAt
|
||||
? `<pubDate>${item.createdAt.toUTCString()}</pubDate>`
|
||||
: "",
|
||||
`<description>${escapeXml(item.shortStory)}</description>`,
|
||||
image
|
||||
? `<enclosure url="${escapeXml(image)}" type="${imageMime(image)}" length="0" />`
|
||||
: "",
|
||||
"</item>",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
})
|
||||
.join("");
|
||||
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
|
||||
"<channel>",
|
||||
`<title>${escapeXml(`${options.hotelName} — News`)}</title>`,
|
||||
`<link>${escapeXml(`${base}/news`)}</link>`,
|
||||
`<description>${escapeXml(`Latest news from ${options.hotelName}`)}</description>`,
|
||||
"<language>en</language>",
|
||||
`<atom:link href="${escapeXml(`${base}/news/rss.xml`)}" rel="self" type="application/rss+xml" />`,
|
||||
`<lastBuildDate>${now.toUTCString()}</lastBuildDate>`,
|
||||
entries,
|
||||
"</channel>",
|
||||
"</rss>",
|
||||
].join("");
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { publicAssetExists } from "./public-asset";
|
||||
|
||||
describe("publicAssetExists", () => {
|
||||
const originalCwd = process.cwd();
|
||||
let root: string;
|
||||
|
||||
beforeAll(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "public-asset-"));
|
||||
mkdirSync(join(root, "public", "assets"), { recursive: true });
|
||||
writeFileSync(join(root, "public", "assets", "present.png"), "");
|
||||
process.chdir(root);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.chdir(originalCwd);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("accepts a local file that exists", () => {
|
||||
expect(publicAssetExists("/assets/present.png")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores query strings and hashes", () => {
|
||||
expect(publicAssetExists("/assets/present.png?v=2#x")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a local file that is missing", () => {
|
||||
expect(publicAssetExists("/assets/gone.png")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty input", () => {
|
||||
expect(publicAssetExists(null)).toBe(false);
|
||||
expect(publicAssetExists(undefined)).toBe(false);
|
||||
expect(publicAssetExists("")).toBe(false);
|
||||
});
|
||||
|
||||
it("trusts remote and protocol-relative URLs", () => {
|
||||
expect(publicAssetExists("https://cdn.example.com/a.png")).toBe(true);
|
||||
expect(publicAssetExists("http://cdn.example.com/a.png")).toBe(true);
|
||||
expect(publicAssetExists("//cdn.example.com/a.png")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-http schemes", () => {
|
||||
expect(publicAssetExists("javascript:alert(1)")).toBe(false);
|
||||
expect(publicAssetExists("data:image/png;base64,AAAA")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* True when a site-relative URL points at a file that really exists under
|
||||
* `public/`. CMS records can keep pointing at an upload that has since been
|
||||
* deleted, and a stale reference renders a broken image (or a 404 background
|
||||
* layer) on every page view. Server-only: it stats the filesystem.
|
||||
*
|
||||
* Remote URLs (`http:`, `https:`, protocol-relative) are trusted as-is because
|
||||
* we cannot stat them, and data/blob URIs are rejected outright.
|
||||
*/
|
||||
export function publicAssetExists(url: string | null | undefined): boolean {
|
||||
if (!url) return false;
|
||||
if (url.startsWith("//")) return true;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) {
|
||||
return /^https?:/i.test(url);
|
||||
}
|
||||
if (!url.startsWith("/")) return true;
|
||||
try {
|
||||
const file = decodeURIComponent(url.split(/[?#]/)[0] ?? "");
|
||||
return existsSync(join(process.cwd(), "public", file));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+120
File diff suppressed because it is too large.
Load diff
@@ -12,7 +12,7 @@ describe("createUserSchema", () => {
|
||||
const result = createUserSchema.safeParse({
|
||||
username: "TestUser",
|
||||
mail: "[email protected]",
|
||||
password: "secret123",
|
||||
password: "secret123456",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
@@ -26,7 +26,7 @@ describe("createUserSchema", () => {
|
||||
createUserSchema.safeParse({
|
||||
username: "ab",
|
||||
mail: "[email protected]",
|
||||
password: "secret123",
|
||||
password: "secret123456",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -36,7 +36,7 @@ describe("createUserSchema", () => {
|
||||
createUserSchema.safeParse({
|
||||
username: "a".repeat(21),
|
||||
mail: "[email protected]",
|
||||
password: "secret123",
|
||||
password: "secret123456",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -46,7 +46,7 @@ describe("createUserSchema", () => {
|
||||
createUserSchema.safeParse({
|
||||
username: "user@name",
|
||||
mail: "[email protected]",
|
||||
password: "secret123",
|
||||
password: "secret123456",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -56,7 +56,7 @@ describe("createUserSchema", () => {
|
||||
createUserSchema.safeParse({
|
||||
username: "TestUser",
|
||||
mail: "not-an-email",
|
||||
password: "secret123",
|
||||
password: "secret123456",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { passwordPolicy } from "@/lib/auth/password-policy";
|
||||
|
||||
export const createUserSchema = z.object({
|
||||
username: z
|
||||
@@ -10,7 +11,16 @@ export const createUserSchema = z.object({
|
||||
"Username can only contain letters, numbers, _ and -",
|
||||
),
|
||||
mail: z.string().email("Invalid email address"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
password: z
|
||||
.string()
|
||||
.min(
|
||||
passwordPolicy.minLength,
|
||||
`Password must be at least ${passwordPolicy.minLength} characters`,
|
||||
)
|
||||
.max(
|
||||
passwordPolicy.maxLength,
|
||||
`Password must be at most ${passwordPolicy.maxLength} characters`,
|
||||
),
|
||||
rank: z.coerce.number().int().min(1).max(7).default(1),
|
||||
motto: z.string().max(127).default(""),
|
||||
});
|
||||
|
||||
@@ -5353,6 +5353,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Welcome to {hotelName}",
|
||||
"heroEyebrow": "أهلاً بك",
|
||||
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
||||
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
||||
"enterHotel": "Enter {hotelName}",
|
||||
@@ -6132,7 +6133,7 @@
|
||||
"confirmPassword": "أعد كلمة المرور",
|
||||
"usernamePlaceholder": "اسم المستخدم",
|
||||
"emailPlaceholder": "البريد الإلكتروني",
|
||||
"passwordPlaceholder": "كلمة المرور (12 حرفًا على الأقل)",
|
||||
"passwordPlaceholder": "كلمة المرور ({min} حرفًا على الأقل)",
|
||||
"confirmPasswordPlaceholder": "أعد كلمة المرور",
|
||||
"selectOutfit": "اختر ملابسك",
|
||||
"termsAccept": "أنا أبلغ 18 عامًا وأوافق على شروط {hotel}.",
|
||||
@@ -6153,7 +6154,7 @@
|
||||
"termsError": "يجب الموافقة على الشروط للتسجيل.",
|
||||
"usernameError": "اسم المستخدم مطلوب.",
|
||||
"emailError": "البريد الإلكتروني الصالح مطلوب.",
|
||||
"passwordError": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
|
||||
"passwordError": "يجب ألا تقل كلمة المرور عن {min} حرفًا",
|
||||
"confirmPasswordError": "كلمتا المرور غير متطابقتين.",
|
||||
"termsRequired": "يجب الموافقة على الشروط.",
|
||||
"usernameRequired": "اسم المستخدم مطلوب.",
|
||||
@@ -6167,7 +6168,7 @@
|
||||
"strengthFair": "مقبول",
|
||||
"strengthGood": "جيد",
|
||||
"strengthStrong": "قوي",
|
||||
"passwordMinLength": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
|
||||
"passwordMinLength": "يجب ألا تقل كلمة المرور عن {min} حرفًا",
|
||||
"passwordUpper": "يجب أن تحتوي كلمة المرور على حرف كبير واحد على الأقل",
|
||||
"passwordLower": "يجب أن تحتوي كلمة المرور على حرف صغير واحد على الأقل",
|
||||
"passwordDigit": "يجب أن تحتوي كلمة المرور على رقم واحد على الأقل",
|
||||
@@ -6202,10 +6203,10 @@
|
||||
"reset": {
|
||||
"title": "Set a new password",
|
||||
"subtitle": "Choose a strong password you don't use elsewhere.",
|
||||
"passwordPlaceholder": "New password (min 12 chars)",
|
||||
"passwordPlaceholder": "New password (min {min} chars)",
|
||||
"resetPassword": "Reset password",
|
||||
"backToLogin": "Back to login",
|
||||
"passwordMinLength": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
|
||||
"passwordMinLength": "يجب ألا تقل كلمة المرور عن {min} حرفًا",
|
||||
"tooManyAttempts": "محاولات كثيرة جدًا — حاول لاحقًا",
|
||||
"invalidLink": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية. اطلب رابطًا جديدًا.",
|
||||
"failed": "تعذّرت إعادة تعيين كلمة المرور. حاول مرة أخرى."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Добре дошли в {hotelName}",
|
||||
"heroEyebrow": "Добре дошли",
|
||||
"welcomeSubtitle": "Вашият ретро хотел, сега захранван от Next.js",
|
||||
"welcomeBody": "Излизайте, събирайте мебели и се срещайте с приятели.",
|
||||
"enterHotel": "Въведете {hotelName}",
|
||||
@@ -885,7 +886,7 @@
|
||||
"confirmPassword": "Повторете паролата",
|
||||
"usernamePlaceholder": "Потребителско име",
|
||||
"emailPlaceholder": "Имейл",
|
||||
"passwordPlaceholder": "Парола (мин. 12 знака)",
|
||||
"passwordPlaceholder": "Парола (мин. {min} знака)",
|
||||
"confirmPasswordPlaceholder": "Повторете паролата",
|
||||
"selectOutfit": "Изберете вашето облекло",
|
||||
"termsAccept": "Аз съм на 18+ и приемам условията и правилата на {hotel}.",
|
||||
@@ -897,7 +898,7 @@
|
||||
"termsError": "Трябва да приемете условията и правилата, за да се регистрирате.",
|
||||
"usernameError": "Изисква се потребителско име.",
|
||||
"emailError": "Изисква се валиден имейл.",
|
||||
"passwordError": "Паролата трябва да е поне 12 знака",
|
||||
"passwordError": "Паролата трябва да е поне {min} знака",
|
||||
"confirmPasswordError": "Паролите не съвпадат.",
|
||||
"termsRequired": "Трябва да приемете условията и правилата.",
|
||||
"usernameRequired": "Изисква се потребителско име.",
|
||||
@@ -920,7 +921,7 @@
|
||||
"strengthFair": "Средна",
|
||||
"strengthGood": "Добра",
|
||||
"strengthStrong": "Силна",
|
||||
"passwordMinLength": "Паролата трябва да е поне 12 знака",
|
||||
"passwordMinLength": "Паролата трябва да е поне {min} знака",
|
||||
"passwordUpper": "Паролата трябва да съдържа поне една главна буква",
|
||||
"passwordLower": "Паролата трябва да съдържа поне една малка буква",
|
||||
"passwordDigit": "Паролата трябва да съдържа поне една цифра",
|
||||
@@ -955,10 +956,10 @@
|
||||
"reset": {
|
||||
"title": "Задайте нова парола",
|
||||
"subtitle": "Изберете силна парола, която не използвате другаде.",
|
||||
"passwordPlaceholder": "Нова парола (мин. 12 знака)",
|
||||
"passwordPlaceholder": "Нова парола (мин. {min} знака)",
|
||||
"resetPassword": "Нулирайте паролата",
|
||||
"backToLogin": "Назад към входа",
|
||||
"passwordMinLength": "Паролата трябва да е поне 12 знака",
|
||||
"passwordMinLength": "Паролата трябва да е поне {min} знака",
|
||||
"tooManyAttempts": "Твърде много опити — опитайте по-късно",
|
||||
"invalidLink": "Този линк за нулиране е невалиден или е изтекъл. Поискайте нов.",
|
||||
"failed": "Нулирането на паролата не бе успешно. Моля, опитайте отново."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Vítejte v {hotelName}",
|
||||
"heroEyebrow": "Vítejte",
|
||||
"welcomeSubtitle": "Váš retro hotel, nyní poháněný Next.js",
|
||||
"welcomeBody": "Setkejte se, sbírejte nábytek a setkejte se s přáteli.",
|
||||
"enterHotel": "Zadejte {hotelName}",
|
||||
@@ -885,7 +886,7 @@
|
||||
"confirmPassword": "Opakujte heslo",
|
||||
"usernamePlaceholder": "Uživatelské jméno",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Heslo (min. 12 znaků)",
|
||||
"passwordPlaceholder": "Heslo (min. {min} znaků)",
|
||||
"confirmPasswordPlaceholder": "Opakujte heslo",
|
||||
"selectOutfit": "Vyberte si oblečení",
|
||||
"termsAccept": "Je mi 18+ a souhlasím s podmínkami a pravidly {hotel}.",
|
||||
@@ -897,7 +898,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ň 12 znaků",
|
||||
"passwordError": "Heslo musí mít alespoň {min} znaků",
|
||||
"confirmPasswordError": "Hesla se neshodují.",
|
||||
"termsRequired": "Musíte přijmout podmínky a pravidla.",
|
||||
"usernameRequired": "Uživatelské jméno je povinné.",
|
||||
@@ -920,7 +921,7 @@
|
||||
"strengthFair": "Ucházející",
|
||||
"strengthGood": "Dobré",
|
||||
"strengthStrong": "Silné",
|
||||
"passwordMinLength": "Heslo musí mít alespoň 12 znaků",
|
||||
"passwordMinLength": "Heslo musí mít alespoň {min} 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",
|
||||
@@ -955,10 +956,10 @@
|
||||
"reset": {
|
||||
"title": "Nastavte nové heslo",
|
||||
"subtitle": "Vyberte si silné heslo, které jinde nepoužíváte.",
|
||||
"passwordPlaceholder": "Nové heslo (min. 12 znaků)",
|
||||
"passwordPlaceholder": "Nové heslo (min. {min} znaků)",
|
||||
"resetPassword": "Obnovit heslo",
|
||||
"backToLogin": "Zpět k přihlášení",
|
||||
"passwordMinLength": "Heslo musí mít alespoň 12 znaků",
|
||||
"passwordMinLength": "Heslo musí mít alespoň {min} znaků",
|
||||
"tooManyAttempts": "Příliš mnoho pokusů — zkuste to později",
|
||||
"invalidLink": "Tento odkaz pro obnovu je neplatný nebo vypršel. Vyžádejte si nový.",
|
||||
"failed": "Heslo nebylo možné obnovit. Zkuste to prosím znovu."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Velkommen til {hotelName}",
|
||||
"heroEyebrow": "Velkommen",
|
||||
"welcomeSubtitle": "Dit retrohotel, nu drevet af Next.js",
|
||||
"welcomeBody": "Hæng ud, saml møbler og mød venner.",
|
||||
"enterHotel": "Indtast {hotelName}",
|
||||
@@ -885,7 +886,7 @@
|
||||
"confirmPassword": "Gentag adgangskoden",
|
||||
"usernamePlaceholder": "Brugernavn",
|
||||
"emailPlaceholder": "E-mail",
|
||||
"passwordPlaceholder": "Adgangskode (min. 12 tegn)",
|
||||
"passwordPlaceholder": "Adgangskode (min. {min} tegn)",
|
||||
"confirmPasswordPlaceholder": "Gentag adgangskoden",
|
||||
"selectOutfit": "Vælg dit outfit",
|
||||
"termsAccept": "Jeg er 18+ og accepterer vilkårene og reglerne for {hotel}.",
|
||||
@@ -897,7 +898,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 12 tegn",
|
||||
"passwordError": "Adgangskoden skal være på mindst {min} tegn",
|
||||
"confirmPasswordError": "Adgangskoder stemmer ikke overens.",
|
||||
"termsRequired": "Du skal acceptere vilkårene og reglerne.",
|
||||
"usernameRequired": "Brugernavn er påkrævet.",
|
||||
@@ -920,7 +921,7 @@
|
||||
"strengthFair": "Middelmådig",
|
||||
"strengthGood": "God",
|
||||
"strengthStrong": "Stærk",
|
||||
"passwordMinLength": "Adgangskoden skal være på mindst 12 tegn",
|
||||
"passwordMinLength": "Adgangskoden skal være på mindst {min} tegn",
|
||||
"passwordUpper": "Adgangskoden skal indeholde mindst ét stort bogstav",
|
||||
"passwordLower": "Adgangskoden skal indeholde mindst ét lille bogstav",
|
||||
"passwordDigit": "Adgangskoden skal indeholde mindst ét tal",
|
||||
@@ -955,10 +956,10 @@
|
||||
"reset": {
|
||||
"title": "Indstil en ny adgangskode",
|
||||
"subtitle": "Vælg en stærk adgangskode, du ikke bruger andre steder.",
|
||||
"passwordPlaceholder": "Ny adgangskode (min. 12 tegn)",
|
||||
"passwordPlaceholder": "Ny adgangskode (min. {min} tegn)",
|
||||
"resetPassword": "Nulstil adgangskode",
|
||||
"backToLogin": "Tilbage til login",
|
||||
"passwordMinLength": "Adgangskoden skal være på mindst 12 tegn",
|
||||
"passwordMinLength": "Adgangskoden skal være på mindst {min} tegn",
|
||||
"tooManyAttempts": "For mange forsøg — prøv igen senere",
|
||||
"invalidLink": "Dette nulstillingslink er ugyldigt eller udløbet. Anmod om et nyt.",
|
||||
"failed": "Adgangskoden kunne ikke nulstilles. Prøv igen."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Willkommen bei {hotelName}",
|
||||
"heroEyebrow": "Willkommen",
|
||||
"welcomeSubtitle": "Dein Retro-Hotel, jetzt mit Next.js",
|
||||
"welcomeBody": "Abhängen, Möbel sammeln und Freunde treffen.",
|
||||
"enterHotel": "{hotelName} betreten",
|
||||
@@ -855,7 +856,7 @@
|
||||
"confirmPassword": "Passwort wiederholen",
|
||||
"usernamePlaceholder": "Benutzername",
|
||||
"emailPlaceholder": "E-Mail",
|
||||
"passwordPlaceholder": "Passwort (min. 12 Zeichen)",
|
||||
"passwordPlaceholder": "Passwort (min. {min} Zeichen)",
|
||||
"confirmPasswordPlaceholder": "Passwort wiederholen",
|
||||
"selectOutfit": "Wähle dein Outfit",
|
||||
"termsAccept": "Ich bin 18+ und akzeptiere die {hotel} AGB & Regeln.",
|
||||
@@ -867,7 +868,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 12 Zeichen lang sein",
|
||||
"passwordError": "Das Passwort muss mindestens {min} Zeichen lang sein",
|
||||
"confirmPasswordError": "Passwörter stimmen nicht überein.",
|
||||
"termsRequired": "Du musst die AGB & Regeln akzeptieren.",
|
||||
"usernameRequired": "Benutzername ist erforderlich.",
|
||||
@@ -890,7 +891,7 @@
|
||||
"strengthFair": "Mittel",
|
||||
"strengthGood": "Gut",
|
||||
"strengthStrong": "Stark",
|
||||
"passwordMinLength": "Das Passwort muss mindestens 12 Zeichen lang sein",
|
||||
"passwordMinLength": "Das Passwort muss mindestens {min} 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",
|
||||
@@ -925,10 +926,10 @@
|
||||
"reset": {
|
||||
"title": "Neues Passwort festlegen",
|
||||
"subtitle": "Wähle ein sicheres Passwort, das du sonst nirgendwo verwendest.",
|
||||
"passwordPlaceholder": "Neues Passwort (min. 12 Zeichen)",
|
||||
"passwordPlaceholder": "Neues Passwort (min. {min} Zeichen)",
|
||||
"resetPassword": "Passwort zurücksetzen",
|
||||
"backToLogin": "Zurück zur Anmeldung",
|
||||
"passwordMinLength": "Das Passwort muss mindestens 12 Zeichen lang sein",
|
||||
"passwordMinLength": "Das Passwort muss mindestens {min} Zeichen lang sein",
|
||||
"tooManyAttempts": "Zu viele Versuche — bitte später erneut probieren",
|
||||
"invalidLink": "Dieser Zurücksetzungslink ist ungültig oder abgelaufen. Fordere einen neuen an.",
|
||||
"failed": "Das Passwort konnte nicht zurückgesetzt werden. Bitte versuche es erneut."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Καλώς ήρθατε στο {hotelName}",
|
||||
"heroEyebrow": "Καλώς ήρθατε",
|
||||
"welcomeSubtitle": "Το ρετρό ξενοδοχείο σας, τώρα με την υποστήριξη του Next.js",
|
||||
"welcomeBody": "Κάντε παρέα, μαζέψτε έπιπλα και συναντήστε φίλους.",
|
||||
"enterHotel": "Εισαγάγετε {hotelName}",
|
||||
@@ -885,7 +886,7 @@
|
||||
"confirmPassword": "Επαναλάβετε τον κωδικό πρόσβασης",
|
||||
"usernamePlaceholder": "Όνομα χρήστη",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Κωδικός πρόσβασης (ελάχ. 12 χαρακτήρες)",
|
||||
"passwordPlaceholder": "Κωδικός πρόσβασης (ελάχ. {min} χαρακτήρες)",
|
||||
"confirmPasswordPlaceholder": "Επαναλάβετε τον κωδικό πρόσβασης",
|
||||
"selectOutfit": "Επιλέξτε το ντύσιμό σας",
|
||||
"termsAccept": "Είμαι 18+ και αποδέχομαι τους όρους και τους κανόνες του {hotel}.",
|
||||
@@ -897,7 +898,7 @@
|
||||
"termsError": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες για να εγγραφείτε.",
|
||||
"usernameError": "Απαιτείται όνομα χρήστη.",
|
||||
"emailError": "Απαιτείται έγκυρο email.",
|
||||
"passwordError": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
|
||||
"passwordError": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες",
|
||||
"confirmPasswordError": "Οι κωδικοί πρόσβασης δεν ταιριάζουν.",
|
||||
"termsRequired": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες.",
|
||||
"usernameRequired": "Απαιτείται όνομα χρήστη.",
|
||||
@@ -920,7 +921,7 @@
|
||||
"strengthFair": "Μέτριο",
|
||||
"strengthGood": "Καλό",
|
||||
"strengthStrong": "Δυνατό",
|
||||
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
|
||||
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες",
|
||||
"passwordUpper": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα κεφαλαίο γράμμα",
|
||||
"passwordLower": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα πεζό γράμμα",
|
||||
"passwordDigit": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα ψηφίο",
|
||||
@@ -955,10 +956,10 @@
|
||||
"reset": {
|
||||
"title": "Ορίστε νέο κωδικό πρόσβασης",
|
||||
"subtitle": "Επιλέξτε έναν ισχυρό κωδικό πρόσβασης που δεν χρησιμοποιείτε αλλού.",
|
||||
"passwordPlaceholder": "Νέος κωδικός πρόσβασης (ελάχ. 12 χαρακτήρες)",
|
||||
"passwordPlaceholder": "Νέος κωδικός πρόσβασης (ελάχ. {min} χαρακτήρες)",
|
||||
"resetPassword": "Επαναφορά κωδικού πρόσβασης",
|
||||
"backToLogin": "Επιστροφή στην είσοδο",
|
||||
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
|
||||
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες",
|
||||
"tooManyAttempts": "Πάρα πολλές προσπάθειες — δοκιμάστε αργότερα",
|
||||
"invalidLink": "Αυτός ο σύνδεσμος επαναφοράς δεν είναι έγκυρος ή έχει λήξει. Ζητήστε νέο.",
|
||||
"failed": "Δεν ήταν δυνατή η επαναφορά του κωδικού. Δοκιμάστε ξανά."
|
||||
|
||||
@@ -278,6 +278,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Welcome to {hotelName}",
|
||||
"heroEyebrow": "Welcome",
|
||||
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
||||
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
||||
"enterHotel": "Enter {hotelName}",
|
||||
@@ -1058,7 +1059,7 @@
|
||||
"confirmPassword": "Repeat password",
|
||||
"usernamePlaceholder": "Username",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Password (min 12 chars)",
|
||||
"passwordPlaceholder": "Password (min {min} chars)",
|
||||
"confirmPasswordPlaceholder": "Repeat password",
|
||||
"selectOutfit": "Select your outfit",
|
||||
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
|
||||
@@ -1081,7 +1082,7 @@
|
||||
"termsError": "You must accept the terms & rules to register.",
|
||||
"usernameError": "Username is required.",
|
||||
"emailError": "Valid email is required.",
|
||||
"passwordError": "Password must be at least 12 characters",
|
||||
"passwordError": "Password must be at least {min} characters",
|
||||
"confirmPasswordError": "Passwords do not match.",
|
||||
"termsRequired": "You must accept the terms & rules.",
|
||||
"usernameRequired": "Username is required.",
|
||||
@@ -1093,7 +1094,7 @@
|
||||
"strengthFair": "Fair",
|
||||
"strengthGood": "Good",
|
||||
"strengthStrong": "Strong",
|
||||
"passwordMinLength": "Password must be at least 12 characters",
|
||||
"passwordMinLength": "Password must be at least {min} 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",
|
||||
@@ -1128,10 +1129,10 @@
|
||||
"reset": {
|
||||
"title": "Set a new password",
|
||||
"subtitle": "Choose a strong password you don't use elsewhere.",
|
||||
"passwordPlaceholder": "New password (min 12 chars)",
|
||||
"passwordPlaceholder": "New password (min {min} chars)",
|
||||
"resetPassword": "Reset password",
|
||||
"backToLogin": "Back to login",
|
||||
"passwordMinLength": "Password must be at least 12 characters",
|
||||
"passwordMinLength": "Password must be at least {min} characters",
|
||||
"tooManyAttempts": "Too many attempts — try again later",
|
||||
"invalidLink": "This reset link is invalid or has expired. Request a new one.",
|
||||
"failed": "The password could not be reset. Please try again."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Bienvenido a {hotelName}",
|
||||
"heroEyebrow": "Bienvenido",
|
||||
"welcomeSubtitle": "Tu hotel retro, ahora impulsado por Next.js",
|
||||
"welcomeBody": "Pasa el rato, colecciona furni y conoce amigos.",
|
||||
"enterHotel": "Entrar a {hotelName}",
|
||||
@@ -855,7 +856,7 @@
|
||||
"confirmPassword": "Repetir contraseña",
|
||||
"usernamePlaceholder": "Usuario",
|
||||
"emailPlaceholder": "Correo electrónico",
|
||||
"passwordPlaceholder": "Contraseña (mín. 12 caracteres)",
|
||||
"passwordPlaceholder": "Contraseña (mín. {min} 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}.",
|
||||
@@ -867,7 +868,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 12 caracteres",
|
||||
"passwordError": "La contraseña debe tener al menos {min} caracteres",
|
||||
"confirmPasswordError": "Las contraseñas no coinciden.",
|
||||
"termsRequired": "Debes aceptar los términos y reglas.",
|
||||
"usernameRequired": "El usuario es obligatorio.",
|
||||
@@ -890,7 +891,7 @@
|
||||
"strengthFair": "Aceptable",
|
||||
"strengthGood": "Buena",
|
||||
"strengthStrong": "Fuerte",
|
||||
"passwordMinLength": "La contraseña debe tener al menos 12 caracteres",
|
||||
"passwordMinLength": "La contraseña debe tener al menos {min} 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",
|
||||
@@ -925,10 +926,10 @@
|
||||
"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. 12 caracteres)",
|
||||
"passwordPlaceholder": "Nueva contraseña (mín. {min} caracteres)",
|
||||
"resetPassword": "Restablecer contraseña",
|
||||
"backToLogin": "Volver al inicio de sesión",
|
||||
"passwordMinLength": "La contraseña debe tener al menos 12 caracteres",
|
||||
"passwordMinLength": "La contraseña debe tener al menos {min} caracteres",
|
||||
"tooManyAttempts": "Demasiados intentos — inténtalo más tarde",
|
||||
"invalidLink": "Este enlace de restablecimiento no es válido o ha caducado. Solicita uno nuevo.",
|
||||
"failed": "No se pudo restablecer la contraseña. Vuelve a intentarlo."
|
||||
|
||||
@@ -5353,6 +5353,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Welcome to {hotelName}",
|
||||
"heroEyebrow": "Tervetuloa",
|
||||
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
||||
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
||||
"enterHotel": "Enter {hotelName}",
|
||||
@@ -6132,7 +6133,7 @@
|
||||
"confirmPassword": "Toista salasana",
|
||||
"usernamePlaceholder": "Käyttäjätunnus",
|
||||
"emailPlaceholder": "Sähköposti",
|
||||
"passwordPlaceholder": "Salasana (vähintään 12 merkkiä)",
|
||||
"passwordPlaceholder": "Salasana (vähintään {min} merkkiä)",
|
||||
"confirmPasswordPlaceholder": "Toista salasana",
|
||||
"selectOutfit": "Valitse asustesi",
|
||||
"termsAccept": "Olen 18-vuotias ja hyväksyn {hotel} säännöt.",
|
||||
@@ -6153,7 +6154,7 @@
|
||||
"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ä",
|
||||
"passwordError": "Salasanassa on oltava vähintään {min} merkkiä",
|
||||
"confirmPasswordError": "Salasanat eivät täsmää.",
|
||||
"termsRequired": "Sinun on hyväksyttävä säännöt.",
|
||||
"usernameRequired": "Käyttäjätunnus on pakollinen.",
|
||||
@@ -6167,7 +6168,7 @@
|
||||
"strengthFair": "Tyydyttävä",
|
||||
"strengthGood": "Hyvä",
|
||||
"strengthStrong": "Vahva",
|
||||
"passwordMinLength": "Salasanassa on oltava vähintään 12 merkkiä",
|
||||
"passwordMinLength": "Salasanassa on oltava vähintään {min} 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",
|
||||
@@ -6202,10 +6203,10 @@
|
||||
"reset": {
|
||||
"title": "Set a new password",
|
||||
"subtitle": "Choose a strong password you don't use elsewhere.",
|
||||
"passwordPlaceholder": "New password (min 12 chars)",
|
||||
"passwordPlaceholder": "New password (min {min} chars)",
|
||||
"resetPassword": "Reset password",
|
||||
"backToLogin": "Back to login",
|
||||
"passwordMinLength": "Salasanassa on oltava vähintään 12 merkkiä",
|
||||
"passwordMinLength": "Salasanassa on oltava vähintään {min} merkkiä",
|
||||
"tooManyAttempts": "Liikaa yrityksiä — yritä myöhemmin uudelleen",
|
||||
"invalidLink": "Tämä palautuslinkki on virheellinen tai vanhentunut. Pyydä uusi.",
|
||||
"failed": "Salasanaa ei voitu vaihtaa. Yritä uudelleen."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Bienvenue sur {hotelName}",
|
||||
"heroEyebrow": "Bienvenue",
|
||||
"welcomeSubtitle": "Votre hôtel rétro, désormais propulsé par Next.js",
|
||||
"welcomeBody": "Traînez, collectionnez des meubles et rencontrez des amis.",
|
||||
"enterHotel": "Entrer dans {hotelName}",
|
||||
@@ -855,7 +856,7 @@
|
||||
"confirmPassword": "Répéter le mot de passe",
|
||||
"usernamePlaceholder": "Nom d'utilisateur",
|
||||
"emailPlaceholder": "E-mail",
|
||||
"passwordPlaceholder": "Mot de passe (min 12 car.)",
|
||||
"passwordPlaceholder": "Mot de passe (min {min} 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}.",
|
||||
@@ -867,7 +868,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 12 caractères",
|
||||
"passwordError": "Le mot de passe doit contenir au moins {min} 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.",
|
||||
@@ -890,7 +891,7 @@
|
||||
"strengthFair": "Moyen",
|
||||
"strengthGood": "Bon",
|
||||
"strengthStrong": "Fort",
|
||||
"passwordMinLength": "Le mot de passe doit contenir au moins 12 caractères",
|
||||
"passwordMinLength": "Le mot de passe doit contenir au moins {min} 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",
|
||||
@@ -925,10 +926,10 @@
|
||||
"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 12 car.)",
|
||||
"passwordPlaceholder": "Nouveau mot de passe (min {min} car.)",
|
||||
"resetPassword": "Réinitialiser le mot de passe",
|
||||
"backToLogin": "Retour à la connexion",
|
||||
"passwordMinLength": "Le mot de passe doit contenir au moins 12 caractères",
|
||||
"passwordMinLength": "Le mot de passe doit contenir au moins {min} caractères",
|
||||
"tooManyAttempts": "Trop de tentatives — réessayez plus tard",
|
||||
"invalidLink": "Ce lien de réinitialisation est invalide ou expiré. Demandez-en un nouveau.",
|
||||
"failed": "Le mot de passe n'a pas pu être réinitialisé. Veuillez réessayer."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Dobro došli u {hotelName}",
|
||||
"heroEyebrow": "Dobrodošli",
|
||||
"welcomeSubtitle": "Vaš retro hotel, sada pokreće Next.js",
|
||||
"welcomeBody": "Družite se, skupljajte namještaj i upoznajte prijatelje.",
|
||||
"enterHotel": "Unesite {hotelName}",
|
||||
@@ -885,7 +886,7 @@
|
||||
"confirmPassword": "Ponovi lozinku",
|
||||
"usernamePlaceholder": "Korisničko ime",
|
||||
"emailPlaceholder": "E-mail",
|
||||
"passwordPlaceholder": "Lozinka (najmanje 12 znakova)",
|
||||
"passwordPlaceholder": "Lozinka (najmanje {min} znakova)",
|
||||
"confirmPasswordPlaceholder": "Ponovi lozinku",
|
||||
"selectOutfit": "Odaberite svoju odjeću",
|
||||
"termsAccept": "Imam 18+ godina i prihvaćam odredbe i pravila hotela {hotel}.",
|
||||
@@ -897,7 +898,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 12 znakova.",
|
||||
"passwordError": "Lozinka mora imati najmanje {min} znakova.",
|
||||
"confirmPasswordError": "Lozinke se ne podudaraju.",
|
||||
"termsRequired": "Morate prihvatiti uvjete i pravila.",
|
||||
"usernameRequired": "Korisničko ime je potrebno.",
|
||||
@@ -920,7 +921,7 @@
|
||||
"strengthFair": "Osrednja",
|
||||
"strengthGood": "Dobra",
|
||||
"strengthStrong": "Jaka",
|
||||
"passwordMinLength": "Lozinka mora imati najmanje 12 znakova",
|
||||
"passwordMinLength": "Lozinka mora imati najmanje {min} 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",
|
||||
@@ -955,10 +956,10 @@
|
||||
"reset": {
|
||||
"title": "Postavite novu lozinku",
|
||||
"subtitle": "Odaberite jaku lozinku koju ne koristite drugdje.",
|
||||
"passwordPlaceholder": "Nova lozinka (najmanje 12 znakova)",
|
||||
"passwordPlaceholder": "Nova lozinka (najmanje {min} znakova)",
|
||||
"resetPassword": "Resetiraj lozinku",
|
||||
"backToLogin": "Natrag na prijavu",
|
||||
"passwordMinLength": "Lozinka mora imati najmanje 12 znakova",
|
||||
"passwordMinLength": "Lozinka mora imati najmanje {min} znakova",
|
||||
"tooManyAttempts": "Previše pokušaja — pokušajte kasnije",
|
||||
"invalidLink": "Ova poveznica za ponovno postavljanje nije valjana ili je istekla. Zatražite novu.",
|
||||
"failed": "Lozinku nije bilo moguće ponovno postaviti. Pokušajte ponovo."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Üdvözöljük itt: {hotelName}",
|
||||
"heroEyebrow": "Üdvözöljük",
|
||||
"welcomeSubtitle": "Az Ön retro szállodája, amelyet most a Next.js üzemeltet",
|
||||
"welcomeBody": "Tárcsázz, gyűjts bútorokat és találkozz barátokkal.",
|
||||
"enterHotel": "Írja be a következőt: {hotelName}",
|
||||
@@ -885,7 +886,7 @@
|
||||
"confirmPassword": "Ismételje meg a jelszót",
|
||||
"usernamePlaceholder": "Felhasználónév",
|
||||
"emailPlaceholder": "E-mail",
|
||||
"passwordPlaceholder": "Jelszó (minimum 12 karakter)",
|
||||
"passwordPlaceholder": "Jelszó (minimum {min} 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.",
|
||||
@@ -897,7 +898,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 12 karakter hosszúnak kell lennie",
|
||||
"passwordError": "A jelszónak legalább {min} 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ő.",
|
||||
@@ -920,7 +921,7 @@
|
||||
"strengthFair": "Közepes",
|
||||
"strengthGood": "Jó",
|
||||
"strengthStrong": "Erős",
|
||||
"passwordMinLength": "A jelszónak legalább 12 karakter hosszúnak kell lennie",
|
||||
"passwordMinLength": "A jelszónak legalább {min} 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",
|
||||
@@ -955,10 +956,10 @@
|
||||
"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 12 karakter)",
|
||||
"passwordPlaceholder": "Új jelszó (minimum {min} karakter)",
|
||||
"resetPassword": "Jelszó visszaállítása",
|
||||
"backToLogin": "Vissza a bejelentkezéshez",
|
||||
"passwordMinLength": "A jelszónak legalább 12 karakter hosszúnak kell lennie",
|
||||
"passwordMinLength": "A jelszónak legalább {min} karakter hosszúnak kell lennie",
|
||||
"tooManyAttempts": "Túl sok próbálkozás — próbáld később",
|
||||
"invalidLink": "Ez a visszaállítási link érvénytelen vagy lejárt. Kérjen újat.",
|
||||
"failed": "A jelszó visszaállítása nem sikerült. Kérjük, próbálja újra."
|
||||
|
||||
@@ -274,6 +274,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Benvenuto su {hotelName}",
|
||||
"heroEyebrow": "Benvenuto",
|
||||
"welcomeSubtitle": "Il tuo hotel retrò, ora con tecnologia Next.js",
|
||||
"welcomeBody": "Rilassati, colleziona furni e fai nuove amicizie.",
|
||||
"enterHotel": "Entra in {hotelName}",
|
||||
@@ -1027,7 +1028,7 @@
|
||||
"confirmPassword": "Ripeti la password",
|
||||
"usernamePlaceholder": "Nome utente",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Password (min 12 caratteri)",
|
||||
"passwordPlaceholder": "Password (min {min} caratteri)",
|
||||
"confirmPasswordPlaceholder": "Ripeti la password",
|
||||
"selectOutfit": "Scegli il tuo outfit",
|
||||
"termsAccept": "Ho 18+ anni e accetto i termini e regole di {hotel}.",
|
||||
@@ -1039,7 +1040,7 @@
|
||||
"termsError": "Devi accettare i termini e regole per registrarti.",
|
||||
"usernameError": "Il nome utente è obbligatorio.",
|
||||
"emailError": "Email valida obbligatoria.",
|
||||
"passwordError": "La password deve contenere almeno 12 caratteri",
|
||||
"passwordError": "La password deve contenere almeno {min} caratteri",
|
||||
"confirmPasswordError": "Le password non coincidono.",
|
||||
"termsRequired": "Devi accettare i termini e le regole.",
|
||||
"usernameRequired": "Il nome utente è obbligatorio.",
|
||||
@@ -1062,7 +1063,7 @@
|
||||
"strengthFair": "Discreta",
|
||||
"strengthGood": "Buona",
|
||||
"strengthStrong": "Forte",
|
||||
"passwordMinLength": "La password deve contenere almeno 12 caratteri",
|
||||
"passwordMinLength": "La password deve contenere almeno {min} caratteri",
|
||||
"passwordUpper": "La password deve contenere almeno una lettera maiuscola",
|
||||
"passwordLower": "La password deve contenere almeno una lettera minuscola",
|
||||
"passwordDigit": "La password deve contenere almeno una cifra",
|
||||
@@ -1097,10 +1098,10 @@
|
||||
"reset": {
|
||||
"title": "Imposta una nuova password",
|
||||
"subtitle": "Scegli una password robusta che non usi altrove.",
|
||||
"passwordPlaceholder": "Nuova password (min 12 caratteri)",
|
||||
"passwordPlaceholder": "Nuova password (min {min} caratteri)",
|
||||
"resetPassword": "Reimposta password",
|
||||
"backToLogin": "Torna all'accesso",
|
||||
"passwordMinLength": "La password deve contenere almeno 12 caratteri",
|
||||
"passwordMinLength": "La password deve contenere almeno {min} caratteri",
|
||||
"tooManyAttempts": "Troppi tentativi — riprova più tardi",
|
||||
"invalidLink": "Questo link di reimpostazione non è valido o è scaduto. Richiedine uno nuovo.",
|
||||
"failed": "Non è stato possibile reimpostare la password. Riprova."
|
||||
|
||||
@@ -5353,6 +5353,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Welcome to {hotelName}",
|
||||
"heroEyebrow": "ようこそ",
|
||||
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
||||
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
||||
"enterHotel": "Enter {hotelName}",
|
||||
@@ -6132,7 +6133,7 @@
|
||||
"confirmPassword": "パスワードの確認",
|
||||
"usernamePlaceholder": "ユーザー名",
|
||||
"emailPlaceholder": "メールアドレス",
|
||||
"passwordPlaceholder": "パスワード(12文字以上)",
|
||||
"passwordPlaceholder": "パスワード({min}文字以上)",
|
||||
"confirmPasswordPlaceholder": "パスワードの確認",
|
||||
"selectOutfit": "アバターを選んでスタイルを設定",
|
||||
"termsAccept": "18歳以上であり、{hotel} の規約に同意します。",
|
||||
@@ -6153,7 +6154,7 @@
|
||||
"termsError": "登録するには規約に同意する必要があります。",
|
||||
"usernameError": "ユーザー名は必須です。",
|
||||
"emailError": "有効なメールアドレスが必要です。",
|
||||
"passwordError": "パスワードは12文字以上にしてください",
|
||||
"passwordError": "パスワードは{min}文字以上にしてください",
|
||||
"confirmPasswordError": "パスワードが一致しません。",
|
||||
"termsRequired": "規約に同意する必要があります。",
|
||||
"usernameRequired": "ユーザー名は必須です。",
|
||||
@@ -6167,7 +6168,7 @@
|
||||
"strengthFair": "普通",
|
||||
"strengthGood": "良",
|
||||
"strengthStrong": "強",
|
||||
"passwordMinLength": "パスワードは12文字以上にしてください",
|
||||
"passwordMinLength": "パスワードは{min}文字以上にしてください",
|
||||
"passwordUpper": "パスワードには大文字を1文字以上含めてください",
|
||||
"passwordLower": "パスワードには小文字を1文字以上含めてください",
|
||||
"passwordDigit": "パスワードには数字を1文字以上含めてください",
|
||||
@@ -6202,10 +6203,10 @@
|
||||
"reset": {
|
||||
"title": "Set a new password",
|
||||
"subtitle": "Choose a strong password you don't use elsewhere.",
|
||||
"passwordPlaceholder": "New password (min 12 chars)",
|
||||
"passwordPlaceholder": "New password (min {min} chars)",
|
||||
"resetPassword": "Reset password",
|
||||
"backToLogin": "Back to login",
|
||||
"passwordMinLength": "パスワードは12文字以上にしてください",
|
||||
"passwordMinLength": "パスワードは{min}文字以上にしてください",
|
||||
"tooManyAttempts": "試行回数が多すぎます — 後で再度お試しください",
|
||||
"invalidLink": "このリセットリンクは無効か期限切れです。新しいリンクをリクエストしてください。",
|
||||
"failed": "パスワードをリセットできませんでした。もう一度お試しください。"
|
||||
|
||||
@@ -274,6 +274,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Welkom bij {hotelName}",
|
||||
"heroEyebrow": "Welkom",
|
||||
"welcomeSubtitle": "Je retro hotel, nu mogelijk gemaakt door Next.js",
|
||||
"welcomeBody": "Rondhangen, meubels verzamelen en vrienden ontmoeten.",
|
||||
"enterHotel": "{hotelName} betreden",
|
||||
@@ -1058,7 +1059,7 @@
|
||||
"confirmPassword": "Wachtwoord herhalen",
|
||||
"usernamePlaceholder": "Gebruikersnaam",
|
||||
"emailPlaceholder": "E-mail",
|
||||
"passwordPlaceholder": "Wachtwoord (min 12 tekens)",
|
||||
"passwordPlaceholder": "Wachtwoord (min {min} tekens)",
|
||||
"confirmPasswordPlaceholder": "Wachtwoord herhalen",
|
||||
"selectOutfit": "Kies je outfit",
|
||||
"termsAccept": "Ik ben 18+ en accepteer de {hotel} voorwaarden & regels.",
|
||||
@@ -1074,7 +1075,7 @@
|
||||
"termsError": "Je moet de voorwaarden & regels accepteren om te registreren.",
|
||||
"usernameError": "Gebruikersnaam is verplicht.",
|
||||
"emailError": "Een geldig e-mailadres is verplicht.",
|
||||
"passwordError": "Wachtwoord moet minimaal 12 tekens lang zijn",
|
||||
"passwordError": "Wachtwoord moet minimaal {min} tekens lang zijn",
|
||||
"confirmPasswordError": "Wachtwoorden komen niet overeen.",
|
||||
"termsRequired": "Je moet de voorwaarden accepteren.",
|
||||
"ageVerified": "Ik ben 18+ en ga akkoord met de leeftijdsvereiste.",
|
||||
@@ -1093,7 +1094,7 @@
|
||||
"strengthFair": "Redelijk",
|
||||
"strengthGood": "Goed",
|
||||
"strengthStrong": "Sterk",
|
||||
"passwordMinLength": "Wachtwoord moet minimaal 12 tekens lang zijn",
|
||||
"passwordMinLength": "Wachtwoord moet minimaal {min} tekens lang zijn",
|
||||
"passwordUpper": "Wachtwoord moet minstens één hoofdletter bevatten",
|
||||
"passwordLower": "Wachtwoord moet minstens één kleine letter bevatten",
|
||||
"passwordDigit": "Wachtwoord moet minstens één cijfer bevatten",
|
||||
@@ -1128,10 +1129,10 @@
|
||||
"reset": {
|
||||
"title": "Nieuw wachtwoord instellen",
|
||||
"subtitle": "Kies een sterk wachtwoord dat je nergens anders gebruikt.",
|
||||
"passwordPlaceholder": "Nieuw wachtwoord (min 12 tekens)",
|
||||
"passwordPlaceholder": "Nieuw wachtwoord (min {min} tekens)",
|
||||
"resetPassword": "Wachtwoord resetten",
|
||||
"backToLogin": "Terug naar inloggen",
|
||||
"passwordMinLength": "Wachtwoord moet minimaal 12 tekens lang zijn",
|
||||
"passwordMinLength": "Wachtwoord moet minimaal {min} tekens lang zijn",
|
||||
"tooManyAttempts": "Te veel pogingen — probeer het later opnieuw",
|
||||
"invalidLink": "Deze reset-link is ongeldig of verlopen. Vraag een nieuwe aan.",
|
||||
"failed": "Het wachtwoord kon niet worden gereset. Probeer het opnieuw."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Velkommen til {hotelName}",
|
||||
"heroEyebrow": "Velkommen",
|
||||
"welcomeSubtitle": "Ditt retrohotell, nå drevet av Next.js",
|
||||
"welcomeBody": "Heng med, samle møbler og møt venner.",
|
||||
"enterHotel": "Skriv inn {hotelName}",
|
||||
@@ -885,7 +886,7 @@
|
||||
"confirmPassword": "Gjenta passord",
|
||||
"usernamePlaceholder": "Brukernavn",
|
||||
"emailPlaceholder": "E-post",
|
||||
"passwordPlaceholder": "Passord (minst 12 tegn)",
|
||||
"passwordPlaceholder": "Passord (minst {min} tegn)",
|
||||
"confirmPasswordPlaceholder": "Gjenta passord",
|
||||
"selectOutfit": "Velg antrekket ditt",
|
||||
"termsAccept": "Jeg er 18+ og godtar vilkårene og reglene for {hotel}.",
|
||||
@@ -897,7 +898,7 @@
|
||||
"termsError": "Du må godta vilkårene og reglene for å registrere deg.",
|
||||
"usernameError": "Brukernavn er påkrevd.",
|
||||
"emailError": "Gyldig e-post er nødvendig.",
|
||||
"passwordError": "Passordet må være på minst 12 tegn.",
|
||||
"passwordError": "Passordet må være på minst {min} tegn.",
|
||||
"confirmPasswordError": "Passord stemmer ikke.",
|
||||
"termsRequired": "Du må godta vilkårene og reglene.",
|
||||
"usernameRequired": "Brukernavn er påkrevd.",
|
||||
@@ -920,7 +921,7 @@
|
||||
"strengthFair": "Middels",
|
||||
"strengthGood": "God",
|
||||
"strengthStrong": "Sterk",
|
||||
"passwordMinLength": "Passordet må være minst 12 tegn",
|
||||
"passwordMinLength": "Passordet må være minst {min} tegn",
|
||||
"passwordUpper": "Passordet må inneholde minst én stor bokstav",
|
||||
"passwordLower": "Passordet må inneholde minst én liten bokstav",
|
||||
"passwordDigit": "Passordet må inneholde minst ett siffer",
|
||||
@@ -955,10 +956,10 @@
|
||||
"reset": {
|
||||
"title": "Angi et nytt passord",
|
||||
"subtitle": "Velg et sterkt passord du ikke bruker andre steder.",
|
||||
"passwordPlaceholder": "Nytt passord (minst 12 tegn)",
|
||||
"passwordPlaceholder": "Nytt passord (minst {min} tegn)",
|
||||
"resetPassword": "Tilbakestill passord",
|
||||
"backToLogin": "Tilbake til innlogging",
|
||||
"passwordMinLength": "Passordet må være minst 12 tegn",
|
||||
"passwordMinLength": "Passordet må være minst {min} tegn",
|
||||
"tooManyAttempts": "For mange forsøk — prøv igjen senere",
|
||||
"invalidLink": "Denne tilbakestillingslenken er ugyldig eller utløpt. Be om en ny.",
|
||||
"failed": "Passordet kunne ikke tilbakestilles. Prøv igjen."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Witamy w {hotelName}",
|
||||
"heroEyebrow": "Witamy",
|
||||
"welcomeSubtitle": "Twój hotel w stylu retro, teraz obsługiwany przez Next.js",
|
||||
"welcomeBody": "Spędzaj czas, zbieraj meble i spotykaj się z przyjaciółmi.",
|
||||
"enterHotel": "Wpisz {hotelName}",
|
||||
@@ -885,7 +886,7 @@
|
||||
"confirmPassword": "Powtórz hasło",
|
||||
"usernamePlaceholder": "Nazwa użytkownika",
|
||||
"emailPlaceholder": "E-mail",
|
||||
"passwordPlaceholder": "Hasło (min. 12 znaków)",
|
||||
"passwordPlaceholder": "Hasło (min. {min} znaków)",
|
||||
"confirmPasswordPlaceholder": "Powtórz hasło",
|
||||
"selectOutfit": "Wybierz swój strój",
|
||||
"termsAccept": "Mam ukończone 18 lat i akceptuję warunki i zasady hotelu {hotel}.",
|
||||
@@ -897,7 +898,7 @@
|
||||
"termsError": "Aby się zarejestrować, musisz zaakceptować regulamin.",
|
||||
"usernameError": "Nazwa użytkownika jest wymagana.",
|
||||
"emailError": "Wymagany jest prawidłowy adres e-mail.",
|
||||
"passwordError": "Hasło musi mieć co najmniej 12 znaków",
|
||||
"passwordError": "Hasło musi mieć co najmniej {min} znaków",
|
||||
"confirmPasswordError": "Hasła nie pasują.",
|
||||
"termsRequired": "Musisz zaakceptować warunki i zasady.",
|
||||
"usernameRequired": "Nazwa użytkownika jest wymagana.",
|
||||
@@ -920,7 +921,7 @@
|
||||
"strengthFair": "Średnie",
|
||||
"strengthGood": "Dobre",
|
||||
"strengthStrong": "Silne",
|
||||
"passwordMinLength": "Hasło musi mieć co najmniej 12 znaków",
|
||||
"passwordMinLength": "Hasło musi mieć co najmniej {min} znaków",
|
||||
"passwordUpper": "Hasło musi zawierać co najmniej jedną wielką literę",
|
||||
"passwordLower": "Hasło musi zawierać co najmniej jedną małą literę",
|
||||
"passwordDigit": "Hasło musi zawierać co najmniej jedną cyfrę",
|
||||
@@ -955,10 +956,10 @@
|
||||
"reset": {
|
||||
"title": "Ustaw nowe hasło",
|
||||
"subtitle": "Wybierz silne hasło, którego nie używasz gdzie indziej.",
|
||||
"passwordPlaceholder": "Nowe hasło (min. 12 znaków)",
|
||||
"passwordPlaceholder": "Nowe hasło (min. {min} znaków)",
|
||||
"resetPassword": "Zresetuj hasło",
|
||||
"backToLogin": "Powrót do logowania",
|
||||
"passwordMinLength": "Hasło musi mieć co najmniej 12 znaków",
|
||||
"passwordMinLength": "Hasło musi mieć co najmniej {min} znaków",
|
||||
"tooManyAttempts": "Zbyt wiele prób — spróbuj później",
|
||||
"invalidLink": "Ten link resetujący jest nieprawidłowy lub wygasł. Poproś o nowy.",
|
||||
"failed": "Nie udało się zresetować hasła. Spróbuj ponownie."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Bem-vindo ao {hotelName}",
|
||||
"heroEyebrow": "Bem-vindo",
|
||||
"welcomeSubtitle": "Seu hotel retrô, agora com tecnologia Next.js",
|
||||
"welcomeBody": "Saia, colete mobis e encontre amigos.",
|
||||
"enterHotel": "Digite {hotelName}",
|
||||
@@ -885,7 +886,7 @@
|
||||
"confirmPassword": "Repetir senha",
|
||||
"usernamePlaceholder": "Nome de usuário",
|
||||
"emailPlaceholder": "E-mail",
|
||||
"passwordPlaceholder": "Senha (mín. 12 caracteres)",
|
||||
"passwordPlaceholder": "Senha (mín. {min} caracteres)",
|
||||
"confirmPasswordPlaceholder": "Repetir senha",
|
||||
"selectOutfit": "Selecione sua roupa",
|
||||
"termsAccept": "Tenho mais de 18 anos e aceito os termos e regras do {hotel}.",
|
||||
@@ -897,7 +898,7 @@
|
||||
"termsError": "Você deve aceitar os termos e regras para se registrar.",
|
||||
"usernameError": "O nome de usuário é obrigatório.",
|
||||
"emailError": "E-mail válido é obrigatório.",
|
||||
"passwordError": "A senha deve ter pelo menos 12 caracteres",
|
||||
"passwordError": "A senha deve ter pelo menos {min} caracteres",
|
||||
"confirmPasswordError": "As senhas não coincidem.",
|
||||
"termsRequired": "Você deve aceitar os termos e regras.",
|
||||
"usernameRequired": "O nome de usuário é obrigatório.",
|
||||
@@ -920,7 +921,7 @@
|
||||
"strengthFair": "Razoável",
|
||||
"strengthGood": "Boa",
|
||||
"strengthStrong": "Forte",
|
||||
"passwordMinLength": "A senha deve ter pelo menos 12 caracteres",
|
||||
"passwordMinLength": "A senha deve ter pelo menos {min} caracteres",
|
||||
"passwordUpper": "A senha deve conter pelo menos uma letra maiúscula",
|
||||
"passwordLower": "A senha deve conter pelo menos uma letra minúscula",
|
||||
"passwordDigit": "A senha deve conter pelo menos um dígito",
|
||||
@@ -955,10 +956,10 @@
|
||||
"reset": {
|
||||
"title": "Defina uma nova senha",
|
||||
"subtitle": "Escolha uma senha forte que você não usa em outro lugar.",
|
||||
"passwordPlaceholder": "Nova senha (mínimo 12 caracteres)",
|
||||
"passwordPlaceholder": "Nova senha (mínimo {min} caracteres)",
|
||||
"resetPassword": "Redefinir senha",
|
||||
"backToLogin": "Voltar ao login",
|
||||
"passwordMinLength": "A senha deve ter pelo menos 12 caracteres",
|
||||
"passwordMinLength": "A senha deve ter pelo menos {min} caracteres",
|
||||
"tooManyAttempts": "Muitas tentativas — tente mais tarde",
|
||||
"invalidLink": "Este link de redefinição é inválido ou expirou. Peça um novo.",
|
||||
"failed": "Não foi possível redefinir a palavra-passe. Tente novamente."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Bun venit la {hotelName}",
|
||||
"heroEyebrow": "Bine ai venit",
|
||||
"welcomeSubtitle": "Hotelul tău retro, acum dezvoltat de Next.js",
|
||||
"welcomeBody": "Ieșiți, strângeți mobilier și întâlniți-vă cu prietenii.",
|
||||
"enterHotel": "Introdu {hotelName}",
|
||||
@@ -885,7 +886,7 @@
|
||||
"confirmPassword": "Repetați parola",
|
||||
"usernamePlaceholder": "Nume de utilizator",
|
||||
"emailPlaceholder": "E-mail",
|
||||
"passwordPlaceholder": "Parola (min 12 caractere)",
|
||||
"passwordPlaceholder": "Parola (min {min} caractere)",
|
||||
"confirmPasswordPlaceholder": "Repetați parola",
|
||||
"selectOutfit": "Alege-ți ținuta",
|
||||
"termsAccept": "Am peste 18 ani și accept termenii și regulile {hotel}.",
|
||||
@@ -897,7 +898,7 @@
|
||||
"termsError": "Trebuie să acceptați termenii și regulile pentru a vă înregistra.",
|
||||
"usernameError": "Numele de utilizator este obligatoriu.",
|
||||
"emailError": "Este necesar un e-mail valid.",
|
||||
"passwordError": "Parola trebuie să aibă cel puțin 12 caractere",
|
||||
"passwordError": "Parola trebuie să aibă cel puțin {min} caractere",
|
||||
"confirmPasswordError": "Parolele nu se potrivesc.",
|
||||
"termsRequired": "Trebuie să acceptați termenii și regulile.",
|
||||
"usernameRequired": "Numele de utilizator este obligatoriu.",
|
||||
@@ -920,7 +921,7 @@
|
||||
"strengthFair": "Medie",
|
||||
"strengthGood": "Bună",
|
||||
"strengthStrong": "Puternică",
|
||||
"passwordMinLength": "Parola trebuie să aibă cel puțin 12 caractere",
|
||||
"passwordMinLength": "Parola trebuie să aibă cel puțin {min} caractere",
|
||||
"passwordUpper": "Parola trebuie să conțină cel puțin o literă mare",
|
||||
"passwordLower": "Parola trebuie să conțină cel puțin o literă mică",
|
||||
"passwordDigit": "Parola trebuie să conțină cel puțin o cifră",
|
||||
@@ -955,10 +956,10 @@
|
||||
"reset": {
|
||||
"title": "Setați o nouă parolă",
|
||||
"subtitle": "Alegeți o parolă puternică pe care nu o utilizați în altă parte.",
|
||||
"passwordPlaceholder": "Parolă nouă (min 12 caractere)",
|
||||
"passwordPlaceholder": "Parolă nouă (min {min} caractere)",
|
||||
"resetPassword": "Resetează parola",
|
||||
"backToLogin": "Înapoi la autentificare",
|
||||
"passwordMinLength": "Parola trebuie să aibă cel puțin 12 caractere",
|
||||
"passwordMinLength": "Parola trebuie să aibă cel puțin {min} caractere",
|
||||
"tooManyAttempts": "Prea multe încercări — încearcă mai târziu",
|
||||
"invalidLink": "Acest link de resetare nu este valabil sau a expirat. Solicită unul nou.",
|
||||
"failed": "Parola nu a putut fi resetată. Încearcă din nou."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Добро пожаловать в {hotelName}",
|
||||
"heroEyebrow": "Добро пожаловать",
|
||||
"welcomeSubtitle": "Ваш ретро-отель на базе Next.js",
|
||||
"welcomeBody": "Тусуйтесь, собирайте мебель и встречайтесь с друзьями.",
|
||||
"enterHotel": "Введите {hotelName}",
|
||||
@@ -883,7 +884,7 @@
|
||||
"confirmPassword": "Повторите пароль",
|
||||
"usernamePlaceholder": "Имя пользователя",
|
||||
"emailPlaceholder": "Электронная почта",
|
||||
"passwordPlaceholder": "Пароль (мин. 12 символов)",
|
||||
"passwordPlaceholder": "Пароль (мин. {min} символов)",
|
||||
"confirmPasswordPlaceholder": "Повторите пароль",
|
||||
"selectOutfit": "Выберите свой наряд",
|
||||
"termsAccept": "Мне 18+ и я принимаю условия и правила {hotel}.",
|
||||
@@ -895,7 +896,7 @@
|
||||
"termsError": "Чтобы зарегистрироваться, вы должны принять условия и правила.",
|
||||
"usernameError": "Требуется имя пользователя.",
|
||||
"emailError": "Требуется действительный адрес электронной почты.",
|
||||
"passwordError": "Пароль должен содержать не менее 12 символов",
|
||||
"passwordError": "Пароль должен содержать не менее {min} символов",
|
||||
"confirmPasswordError": "Пароли не совпадают.",
|
||||
"termsRequired": "Вы должны принять условия и правила.",
|
||||
"usernameRequired": "Требуется имя пользователя.",
|
||||
@@ -918,7 +919,7 @@
|
||||
"strengthFair": "Средний",
|
||||
"strengthGood": "Хороший",
|
||||
"strengthStrong": "Надёжный",
|
||||
"passwordMinLength": "Пароль должен содержать не менее 12 символов",
|
||||
"passwordMinLength": "Пароль должен содержать не менее {min} символов",
|
||||
"passwordUpper": "Пароль должен содержать хотя бы одну заглавную букву",
|
||||
"passwordLower": "Пароль должен содержать хотя бы одну строчную букву",
|
||||
"passwordDigit": "Пароль должен содержать хотя бы одну цифру",
|
||||
@@ -953,10 +954,10 @@
|
||||
"reset": {
|
||||
"title": "Установить новый пароль",
|
||||
"subtitle": "Выберите надежный пароль, который вы больше не используете.",
|
||||
"passwordPlaceholder": "Новый пароль (мин. 12 символов)",
|
||||
"passwordPlaceholder": "Новый пароль (мин. {min} символов)",
|
||||
"resetPassword": "Сбросить пароль",
|
||||
"backToLogin": "Назад к входу",
|
||||
"passwordMinLength": "Пароль должен содержать не менее 12 символов",
|
||||
"passwordMinLength": "Пароль должен содержать не менее {min} символов",
|
||||
"tooManyAttempts": "Слишком много попыток — повторите позже",
|
||||
"invalidLink": "Эта ссылка для сброса недействительна или истекла. Запросите новую.",
|
||||
"failed": "Не удалось сбросить пароль. Попробуйте снова."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Vitajte v {hotelName}",
|
||||
"heroEyebrow": "Vitajte",
|
||||
"welcomeSubtitle": "Váš retro hotel, ktorý teraz používa Next.js",
|
||||
"welcomeBody": "Stretnite sa, zbierajte nábytok a stretávajte sa s priateľmi.",
|
||||
"enterHotel": "Zadajte {hotelName}",
|
||||
@@ -885,7 +886,7 @@
|
||||
"confirmPassword": "Zopakujte heslo",
|
||||
"usernamePlaceholder": "Používateľské meno",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Heslo (min. 12 znakov)",
|
||||
"passwordPlaceholder": "Heslo (min. {min} znakov)",
|
||||
"confirmPasswordPlaceholder": "Zopakujte heslo",
|
||||
"selectOutfit": "Vyberte si oblečenie",
|
||||
"termsAccept": "Mám 18+ a súhlasím so zmluvnými podmienkami a pravidlami {hotel}.",
|
||||
@@ -897,7 +898,7 @@
|
||||
"termsError": "Ak sa chcete zaregistrovať, musíte prijať podmienky a pravidlá.",
|
||||
"usernameError": "Vyžaduje sa používateľské meno.",
|
||||
"emailError": "Vyžaduje sa platný e-mail.",
|
||||
"passwordError": "Heslo musí mať aspoň 12 znakov",
|
||||
"passwordError": "Heslo musí mať aspoň {min} znakov",
|
||||
"confirmPasswordError": "Heslá sa nezhodujú.",
|
||||
"termsRequired": "Musíte prijať podmienky a pravidlá.",
|
||||
"usernameRequired": "Vyžaduje sa používateľské meno.",
|
||||
@@ -920,7 +921,7 @@
|
||||
"strengthFair": "Priemerné",
|
||||
"strengthGood": "Dobré",
|
||||
"strengthStrong": "Silné",
|
||||
"passwordMinLength": "Heslo musí mať aspoň 12 znakov",
|
||||
"passwordMinLength": "Heslo musí mať aspoň {min} znakov",
|
||||
"passwordUpper": "Heslo musí obsahovať aspoň jedno veľké písmeno",
|
||||
"passwordLower": "Heslo musí obsahovať aspoň jedno malé písmeno",
|
||||
"passwordDigit": "Heslo musí obsahovať aspoň jednu číslicu",
|
||||
@@ -955,10 +956,10 @@
|
||||
"reset": {
|
||||
"title": "Nastavte si nové heslo",
|
||||
"subtitle": "Zvoľte si silné heslo, ktoré inde nepoužívate.",
|
||||
"passwordPlaceholder": "Nové heslo (min. 12 znakov)",
|
||||
"passwordPlaceholder": "Nové heslo (min. {min} znakov)",
|
||||
"resetPassword": "Obnoviť heslo",
|
||||
"backToLogin": "Späť na prihlásenie",
|
||||
"passwordMinLength": "Heslo musí mať aspoň 12 znakov",
|
||||
"passwordMinLength": "Heslo musí mať aspoň {min} znakov",
|
||||
"tooManyAttempts": "Príliš veľa pokusov — skúste to neskôr",
|
||||
"invalidLink": "Tento odkaz na obnovenie je neplatný alebo vypršal. Vyžiadajte si nový.",
|
||||
"failed": "Heslo nebolo možné obnoviť. Skúste to znova."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Добро дошли у {hotelName}",
|
||||
"heroEyebrow": "Добродошли",
|
||||
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
||||
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
||||
"enterHotel": "Унесите {hotelName}",
|
||||
@@ -885,7 +886,7 @@
|
||||
"confirmPassword": "Поновите лозинку",
|
||||
"usernamePlaceholder": "Корисничко име",
|
||||
"emailPlaceholder": "Емаил",
|
||||
"passwordPlaceholder": "Лозинка (мин 12 знакова)",
|
||||
"passwordPlaceholder": "Лозинка (мин {min} знакова)",
|
||||
"confirmPasswordPlaceholder": "Поновите лозинку",
|
||||
"selectOutfit": "Изаберите своју одећу",
|
||||
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
|
||||
@@ -897,7 +898,7 @@
|
||||
"termsError": "Морате прихватити услове и правила да бисте се регистровали.",
|
||||
"usernameError": "Корисничко име је обавезно.",
|
||||
"emailError": "Потребан је важећи имејл.",
|
||||
"passwordError": "Лозинка мора имати најмање 12 знакова",
|
||||
"passwordError": "Лозинка мора имати најмање {min} знакова",
|
||||
"confirmPasswordError": "Лозинке се не поклапају.",
|
||||
"termsRequired": "Морате прихватити услове и правила.",
|
||||
"usernameRequired": "Корисничко име је обавезно.",
|
||||
@@ -920,7 +921,7 @@
|
||||
"strengthFair": "Средња",
|
||||
"strengthGood": "Добра",
|
||||
"strengthStrong": "Јака",
|
||||
"passwordMinLength": "Лозинка мора имати најмање 12 знакова",
|
||||
"passwordMinLength": "Лозинка мора имати најмање {min} знакова",
|
||||
"passwordUpper": "Лозинка мора садржати најмање једно велико слово",
|
||||
"passwordLower": "Лозинка мора садржати најмање једно мало слово",
|
||||
"passwordDigit": "Лозинка мора садржати најмање једну цифру",
|
||||
@@ -955,10 +956,10 @@
|
||||
"reset": {
|
||||
"title": "Поставите нову лозинку",
|
||||
"subtitle": "Изаберите јаку лозинку коју не користите другде.",
|
||||
"passwordPlaceholder": "Нова лозинка (мин 12 знакова)",
|
||||
"passwordPlaceholder": "Нова лозинка (мин {min} знакова)",
|
||||
"resetPassword": "Ресетујте лозинку",
|
||||
"backToLogin": "Назад на пријаву",
|
||||
"passwordMinLength": "Лозинка мора имати најмање 12 знакова",
|
||||
"passwordMinLength": "Лозинка мора имати најмање {min} знакова",
|
||||
"tooManyAttempts": "Превише покушаја — покушајте касније",
|
||||
"invalidLink": "Ова веза за ресетовање није исправна или је истекла. Затражите нову.",
|
||||
"failed": "Лозинка није могла бити ресетована. Покушајте поново."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Välkommen till {hotelName}",
|
||||
"heroEyebrow": "Välkommen",
|
||||
"welcomeSubtitle": "Ditt retrohotell, nu drivs av Next.js",
|
||||
"welcomeBody": "Umgås, samla möbler och träffa vänner.",
|
||||
"enterHotel": "Ange {hotelName}",
|
||||
@@ -885,7 +886,7 @@
|
||||
"confirmPassword": "Upprepa lösenordet",
|
||||
"usernamePlaceholder": "Användarnamn",
|
||||
"emailPlaceholder": "E-post",
|
||||
"passwordPlaceholder": "Lösenord (minst 12 tecken)",
|
||||
"passwordPlaceholder": "Lösenord (minst {min} tecken)",
|
||||
"confirmPasswordPlaceholder": "Upprepa lösenordet",
|
||||
"selectOutfit": "Välj din outfit",
|
||||
"termsAccept": "Jag är 18+ och accepterar villkoren och reglerna för {hotel}.",
|
||||
@@ -897,7 +898,7 @@
|
||||
"termsError": "Du måste acceptera villkoren och reglerna för att registrera dig.",
|
||||
"usernameError": "Användarnamn krävs.",
|
||||
"emailError": "Giltig e-postadress krävs.",
|
||||
"passwordError": "Lösenordet måste bestå av minst 12 tecken.",
|
||||
"passwordError": "Lösenordet måste bestå av minst {min} tecken.",
|
||||
"confirmPasswordError": "Lösenord stämmer inte överens.",
|
||||
"termsRequired": "Du måste acceptera villkoren och reglerna.",
|
||||
"usernameRequired": "Användarnamn krävs.",
|
||||
@@ -920,7 +921,7 @@
|
||||
"strengthFair": "Medel",
|
||||
"strengthGood": "God",
|
||||
"strengthStrong": "Stark",
|
||||
"passwordMinLength": "Lösenordet måste vara minst 12 tecken",
|
||||
"passwordMinLength": "Lösenordet måste vara minst {min} tecken",
|
||||
"passwordUpper": "Lösenordet måste innehålla minst en stor bokstav",
|
||||
"passwordLower": "Lösenordet måste innehålla minst en liten bokstav",
|
||||
"passwordDigit": "Lösenordet måste innehålla minst en siffra",
|
||||
@@ -955,10 +956,10 @@
|
||||
"reset": {
|
||||
"title": "Ange ett nytt lösenord",
|
||||
"subtitle": "Välj ett starkt lösenord som du inte använder någon annanstans.",
|
||||
"passwordPlaceholder": "Nytt lösenord (minst 12 tecken)",
|
||||
"passwordPlaceholder": "Nytt lösenord (minst {min} tecken)",
|
||||
"resetPassword": "Återställ lösenord",
|
||||
"backToLogin": "Tillbaka till inloggning",
|
||||
"passwordMinLength": "Lösenordet måste vara minst 12 tecken",
|
||||
"passwordMinLength": "Lösenordet måste vara minst {min} tecken",
|
||||
"tooManyAttempts": "För många försök — försök igen senare",
|
||||
"invalidLink": "Den här återställningslänken är ogiltig eller har gått ut. Begär en ny.",
|
||||
"failed": "Lösenordet kunde inte återställas. Försök igen."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "{hotelName}'e hoş geldiniz",
|
||||
"heroEyebrow": "Hoş geldiniz",
|
||||
"welcomeSubtitle": "Retro oteliniz artık Next.js tarafından destekleniyor",
|
||||
"welcomeBody": "Takılın, mobilya toplayın ve arkadaşlarla tanışın.",
|
||||
"enterHotel": "{hotelName} girin",
|
||||
@@ -885,7 +886,7 @@
|
||||
"confirmPassword": "Şifreyi tekrarla",
|
||||
"usernamePlaceholder": "Kullanıcı adı",
|
||||
"emailPlaceholder": "E-posta",
|
||||
"passwordPlaceholder": "Şifre (en az 12 karakter)",
|
||||
"passwordPlaceholder": "Şifre (en az {min} karakter)",
|
||||
"confirmPasswordPlaceholder": "Şifreyi tekrarla",
|
||||
"selectOutfit": "Kıyafetinizi seçin",
|
||||
"termsAccept": "18 yaşındayım ve {hotel} şartlarını ve kurallarını kabul ediyorum.",
|
||||
@@ -897,7 +898,7 @@
|
||||
"termsError": "Kayıt olmak için şartları ve kuralları kabul etmelisiniz.",
|
||||
"usernameError": "Kullanıcı adı gerekli.",
|
||||
"emailError": "Geçerli e-posta gereklidir.",
|
||||
"passwordError": "Şifre en az 12 karakter olmalıdır.",
|
||||
"passwordError": "Şifre en az {min} karakter olmalıdır.",
|
||||
"confirmPasswordError": "Şifreler eşleşmiyor.",
|
||||
"termsRequired": "Şartları ve kuralları kabul etmelisiniz.",
|
||||
"usernameRequired": "Kullanıcı adı gerekli.",
|
||||
@@ -920,7 +921,7 @@
|
||||
"strengthFair": "Orta",
|
||||
"strengthGood": "İyi",
|
||||
"strengthStrong": "Güçlü",
|
||||
"passwordMinLength": "Şifre en az 12 karakter olmalıdır",
|
||||
"passwordMinLength": "Şifre en az {min} karakter olmalıdır",
|
||||
"passwordUpper": "Şifre en az bir büyük harf içermelidir",
|
||||
"passwordLower": "Şifre en az bir küçük harf içermelidir",
|
||||
"passwordDigit": "Şifre en az bir rakam içermelidir",
|
||||
@@ -955,10 +956,10 @@
|
||||
"reset": {
|
||||
"title": "Yeni bir şifre belirleyin",
|
||||
"subtitle": "Başka yerde kullanmadığınız güçlü bir şifre seçin.",
|
||||
"passwordPlaceholder": "Yeni şifre (en az 12 karakter)",
|
||||
"passwordPlaceholder": "Yeni şifre (en az {min} karakter)",
|
||||
"resetPassword": "Şifreyi sıfırla",
|
||||
"backToLogin": "Girişe geri dön",
|
||||
"passwordMinLength": "Şifre en az 12 karakter olmalıdır",
|
||||
"passwordMinLength": "Şifre en az {min} karakter olmalıdır",
|
||||
"tooManyAttempts": "Çok fazla deneme — daha sonra tekrar deneyin",
|
||||
"invalidLink": "Bu şifre sıfırlama bağlantısı geçersiz veya süresi dolmuş. Yeni bir tane isteyin.",
|
||||
"failed": "Şifre sıfırlanamadı. Lütfen tekrar deneyin."
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Ласкаво просимо до {hotelName}",
|
||||
"heroEyebrow": "Ласкаво просимо",
|
||||
"welcomeSubtitle": "Ваш ретро-готель, тепер на базі Next.js",
|
||||
"welcomeBody": "Гуляйте, збирайте меблі та зустрічайтеся з друзями.",
|
||||
"enterHotel": "Введіть {hotelName}",
|
||||
@@ -885,7 +886,7 @@
|
||||
"confirmPassword": "Повторіть пароль",
|
||||
"usernamePlaceholder": "Ім'я користувача",
|
||||
"emailPlaceholder": "Електронна пошта",
|
||||
"passwordPlaceholder": "Пароль (мінімум 12 символів)",
|
||||
"passwordPlaceholder": "Пароль (мінімум {min} символів)",
|
||||
"confirmPasswordPlaceholder": "Повторіть пароль",
|
||||
"selectOutfit": "Виберіть свій наряд",
|
||||
"termsAccept": "Мені 18+ і я приймаю умови та правила {hotel}.",
|
||||
@@ -897,7 +898,7 @@
|
||||
"termsError": "Ви повинні прийняти умови та правила, щоб зареєструватися.",
|
||||
"usernameError": "Потрібно ввести ім'я користувача.",
|
||||
"emailError": "Потрібна дійсна електронна адреса.",
|
||||
"passwordError": "Пароль має містити щонайменше 12 символів",
|
||||
"passwordError": "Пароль має містити щонайменше {min} символів",
|
||||
"confirmPasswordError": "Паролі не збігаються.",
|
||||
"termsRequired": "Ви повинні прийняти умови та правила.",
|
||||
"usernameRequired": "Потрібно ввести ім'я користувача.",
|
||||
@@ -920,7 +921,7 @@
|
||||
"strengthFair": "Середній",
|
||||
"strengthGood": "Добрий",
|
||||
"strengthStrong": "Надійний",
|
||||
"passwordMinLength": "Пароль має містити щонайменше 12 символів",
|
||||
"passwordMinLength": "Пароль має містити щонайменше {min} символів",
|
||||
"passwordUpper": "Пароль має містити хоча б одну велику літеру",
|
||||
"passwordLower": "Пароль має містити хоча б одну малу літеру",
|
||||
"passwordDigit": "Пароль має містити хоча б одну цифру",
|
||||
@@ -955,10 +956,10 @@
|
||||
"reset": {
|
||||
"title": "Встановіть новий пароль",
|
||||
"subtitle": "Виберіть надійний пароль, який більше не використовується.",
|
||||
"passwordPlaceholder": "Новий пароль (мінімум 12 символів)",
|
||||
"passwordPlaceholder": "Новий пароль (мінімум {min} символів)",
|
||||
"resetPassword": "Скинути пароль",
|
||||
"backToLogin": "Назад до входу",
|
||||
"passwordMinLength": "Пароль має містити щонайменше 12 символів",
|
||||
"passwordMinLength": "Пароль має містити щонайменше {min} символів",
|
||||
"tooManyAttempts": "Забагато спроб — спробуйте пізніше",
|
||||
"invalidLink": "Це посилання для скидання недійсне або прострочене. Запросіть нове.",
|
||||
"failed": "Не вдалося скинути пароль. Спробуйте ще раз."
|
||||
|
||||
+15
-4
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { env } from "@/env";
|
||||
import { buildContentSecurityPolicy, createCspNonce } from "@/lib/csp";
|
||||
import { isSuspiciousPath } from "@/lib/ddos";
|
||||
import { ddosReject, enforceDdosRateLimit } from "@/lib/ddos-guard";
|
||||
import {
|
||||
isCacheableAssetPath,
|
||||
@@ -19,16 +20,26 @@ const SECURITY_HEADERS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const proxy = async (req: import("next/server").NextRequest) => {
|
||||
const decision = await enforceDdosRateLimit(req);
|
||||
if (decision.outcome === "suspect") {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
|
||||
// Drop scanner probes immediately before performing any rate-limit or DB operations.
|
||||
if (isSuspiciousPath(pathname)) {
|
||||
return ddosReject(403);
|
||||
}
|
||||
|
||||
// Server actions carry an opaque action id in this header. A malformed or
|
||||
// trivially short id (e.g. "x" sent by bots) can never resolve to a real
|
||||
// action; rejecting it here avoids the expensive lookup/render path.
|
||||
const nextAction = req.headers.get("next-action");
|
||||
if (nextAction && !/^[A-Za-z0-9_-]{8,}$/.test(nextAction)) {
|
||||
return new NextResponse(null, { status: 400 });
|
||||
}
|
||||
|
||||
const decision = await enforceDdosRateLimit(req);
|
||||
if (decision.outcome === "block") {
|
||||
return ddosReject(429, decision.retryAfterSeconds);
|
||||
}
|
||||
|
||||
const pathname = req.nextUrl.pathname;
|
||||
|
||||
// Only /admin needs a real session token for the redirect guard; skipping
|
||||
// JWT decoding on every other request keeps the proxy cheap under load.
|
||||
const adminPath = pathname === "/admin" || pathname.startsWith("/admin/");
|
||||
|
||||
@@ -27,6 +27,8 @@ export default defineConfig({
|
||||
DATABASE_URL: "mysql://root:root@localhost:3306/test",
|
||||
},
|
||||
testTimeout: 10000,
|
||||
maxWorkers: 1,
|
||||
fileParallelism: false,
|
||||
exclude: ["integration/**", "e2e/**", "node_modules/**", ".next/**"],
|
||||
coverage: {
|
||||
// Off unless asked for (`pnpm test:coverage` passes
|
||||
|
||||
Reference in new issue
Block a user