Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa34efd3f0 | ||
|
|
b816026bd3 | ||
|
|
836defd0c1 | ||
|
|
9560ddc212 | ||
|
|
c0a5079ede | ||
|
|
a8172ce88e | ||
|
|
9416317c5b | ||
|
|
e17576856f | ||
|
|
201296e837 | ||
|
|
2ae8127476 | ||
|
|
95bc7da83e | ||
|
|
9fd675a6a0 | ||
|
|
5080ca586d | ||
|
|
76493a9df8 | ||
|
|
9cff32a04b | ||
|
|
79b0b8894d | ||
|
|
6b9ed8d15e |
No files matched your search
@@ -8,6 +8,7 @@
|
|||||||
# directly (drizzle-kit, scripts). Keep both in step and keep them well below
|
# 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
|
# MariaDB's `max_connections` (151 by default) or the emulator and any second
|
||||||
# CMS instance get "Too many connections". README §performance: pool size 25.
|
# 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_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=25&connect_timeout=5"
|
||||||
DATABASE_POOL_SIZE=25
|
DATABASE_POOL_SIZE=25
|
||||||
DATABASE_IDLE_TIMEOUT_MS=60000
|
DATABASE_IDLE_TIMEOUT_MS=60000
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
.husky/* text eol=lf
|
.husky/* text eol=lf
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
*.nitro binary
|
||||||
|
*.hab binary
|
||||||
|
*.swf binary
|
||||||
@@ -21,6 +21,11 @@ jobs:
|
|||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
|
||||||
- name: Toolchain check
|
- name: Toolchain check
|
||||||
run: node scripts/check-node-toolchain.mjs
|
run: node scripts/check-node-toolchain.mjs
|
||||||
|
|
||||||
@@ -30,6 +35,9 @@ jobs:
|
|||||||
- name: Deploy port-selection tests
|
- name: Deploy port-selection tests
|
||||||
run: bash scripts/ci-deploy-ports.test.sh
|
run: bash scripts/ci-deploy-ports.test.sh
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -55,6 +63,14 @@ jobs:
|
|||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -83,6 +99,14 @@ jobs:
|
|||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -99,6 +123,14 @@ jobs:
|
|||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -110,7 +142,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload UI results
|
- name: Upload UI results
|
||||||
if: always()
|
if: always()
|
||||||
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ui-results
|
name: ui-results
|
||||||
path: |
|
path: |
|
||||||
@@ -136,6 +168,14 @@ jobs:
|
|||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -165,6 +205,14 @@ jobs:
|
|||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -179,7 +227,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload quality results
|
- name: Upload quality results
|
||||||
if: always()
|
if: always()
|
||||||
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: quality-results
|
name: quality-results
|
||||||
path: |
|
path: |
|
||||||
@@ -199,6 +247,11 @@ jobs:
|
|||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
|
||||||
- name: Toolchain check
|
- name: Toolchain check
|
||||||
run: node scripts/check-node-toolchain.mjs
|
run: node scripts/check-node-toolchain.mjs
|
||||||
|
|
||||||
@@ -208,7 +261,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload preflight news browser results
|
- name: Upload preflight news browser results
|
||||||
if: always()
|
if: always()
|
||||||
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: preflight-news-browser-results
|
name: preflight-news-browser-results
|
||||||
path: |
|
path: |
|
||||||
@@ -228,6 +281,11 @@ jobs:
|
|||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
|
||||||
- name: Build, deploy and smoke test
|
- name: Build, deploy and smoke test
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -236,7 +294,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload isolated news browser results
|
- name: Upload isolated news browser results
|
||||||
if: always()
|
if: always()
|
||||||
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: news-browser-results
|
name: news-browser-results
|
||||||
path: |
|
path: |
|
||||||
@@ -247,7 +305,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload JavaScript size report
|
- name: Upload JavaScript size report
|
||||||
if: always()
|
if: always()
|
||||||
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: javascript-size-report
|
name: javascript-size-report
|
||||||
path: build-reports/
|
path: build-reports/
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
# en wordt de gedownloade pnpm-binary ook in de runtime-image bewaard.
|
# en wordt de gedownloade pnpm-binary ook in de runtime-image bewaard.
|
||||||
RUN --mount=type=cache,target=/var/cache/apk \
|
RUN --mount=type=cache,target=/var/cache/apk \
|
||||||
--mount=type=cache,target=/root/.npm \
|
--mount=type=cache,target=/root/.npm \
|
||||||
apk add --no-cache git bash \
|
apk add --no-cache git bash util-linux \
|
||||||
&& npm install -g [email protected] \
|
&& npm install -g [email protected] \
|
||||||
&& corepack enable pnpm
|
&& corepack enable pnpm
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
|
|||||||
## System Requirements
|
## System Requirements
|
||||||
|
|
||||||
| Component | Version | Notes |
|
| Component | Version | Notes |
|
||||||
| --------------- | ------------ | --------------------------------------- |
|
| ---------------------------- | ------------------ | --------------------------------------- |
|
||||||
| Node.js | 26.12.1 | Pinned in `.nvmrc` (`>=26.12.1 <27`) |
|
| Node.js | 26.12.1 | Pinned in `.nvmrc` (`>=26.12.1 <27`) |
|
||||||
| pnpm | 12.11.2 | Pinned via `packageManager` |
|
| pnpm | 12.11.2 | Pinned via `packageManager` |
|
||||||
| npm | >= 11.x | Supported alternative |
|
| npm | >= 11.x | Supported alternative |
|
||||||
| yarn | >= 4.x | Supported alternative |
|
| yarn | >= 4.x | Supported alternative |
|
||||||
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
| MySQL / MariaDB / PostgreSQL | 8.0+ / 10.6+ / 15+ | Supported databases |
|
||||||
| Docker | 24+ | Optional — for containerized deployment |
|
| Docker | 24+ | Optional — for containerized deployment |
|
||||||
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
|
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
|
||||||
|
|
||||||
@@ -55,32 +55,59 @@ yarn install
|
|||||||
|
|
||||||
### 2. Database Setup
|
### 2. Database Setup
|
||||||
|
|
||||||
The CMS shares a database with the Polaris/Arcturus emulator. Use an existing database or create a new one:
|
The CMS supports both **MySQL / MariaDB** and **PostgreSQL**. The database type is automatically detected from your `DATABASE_URL` protocol (`mysql://` or `postgres://`).
|
||||||
|
|
||||||
|
#### Option A: MySQL / MariaDB
|
||||||
|
|
||||||
|
Create your database:
|
||||||
```sql
|
```sql
|
||||||
CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
```
|
```
|
||||||
|
|
||||||
The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them. The Drizzle schema in `src/db/schema.ts` is generated from the existing database structure and does not modify it.
|
#### Option B: PostgreSQL
|
||||||
|
|
||||||
|
Create your database:
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE epicnext_cms;
|
||||||
|
```
|
||||||
|
|
||||||
|
The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them. The Drizzle schema in `src/db/schema.ts` maps to the existing database structure.
|
||||||
|
|
||||||
> **Note:** The CMS does **not** own the emulator schema — it maps to those tables via Drizzle. Never run `drizzle-kit push` / `migrate` against the shared DB. CMS-owned tables (`website_*`, `radio_*`, etc.) are created via idempotent SQL in `drizzle/migrations/`.
|
> **Note:** The CMS does **not** own the emulator schema — it maps to those tables via Drizzle. Never run `drizzle-kit push` / `migrate` against the shared DB. CMS-owned tables (`website_*`, `radio_*`, etc.) are created via idempotent SQL in `drizzle/migrations/`.
|
||||||
|
|
||||||
|
#### Multi-Engine Architecture & Dialect Routing
|
||||||
|
|
||||||
|
EpicNext-CMS features a robust, multi-dialect database abstraction layer powered by **Drizzle ORM**. Depending on the connection string protocol supplied in `DATABASE_URL`:
|
||||||
|
- **MySQL / MariaDB (`mysql://`)**: Utilizes `mysql2/promise` connection pooling with optimized keep-alive, named placeholders, and strict statement hardening.
|
||||||
|
- **PostgreSQL (`postgres://` / `postgresql://`)**: Utilizes `node-postgres` (`pg.Pool`) with high-performance async client handling.
|
||||||
|
|
||||||
|
The query builder and helper functions (`queryRows`, `execResult`, `affectedRows`) automatically normalize driver-specific responses (`ResultSetHeader` vs PostgreSQL result objects), ensuring seamless compatibility across database engines without application-layer changes.
|
||||||
|
|
||||||
### 3. Configure Environment
|
### 3. Configure Environment
|
||||||
|
|
||||||
|
Copy the example environment file and edit it with your specific settings:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `.env` with at minimum:
|
Edit `.env` to include at minimum:
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
DATABASE_URL=mysql://user:[email protected]:3306/epicnext_cms
|
# Choose your database type and configure DATABASE_URL:
|
||||||
|
# For MySQL / MariaDB:
|
||||||
|
# DATABASE_URL=mysql://user:[email protected]:3306/epicnext_cms
|
||||||
|
|
||||||
|
# For PostgreSQL:
|
||||||
|
# DATABASE_URL=postgres://user:[email protected]:5432/epicnext_cms
|
||||||
|
|
||||||
|
# Ensure these are set:
|
||||||
AUTH_SECRET=<random 32+ character string>
|
AUTH_SECRET=<random 32+ character string>
|
||||||
HOTEL_NAME=YourHotel
|
HOTEL_NAME=YourHotel
|
||||||
APP_URL=http://localhost:3002
|
APP_URL=http://localhost:3002
|
||||||
```
|
```
|
||||||
|
|
||||||
See `.env.example` for all optional variables (RCON, email, Valkey, OAuth, PayPal, etc.).
|
Refer to `.env.example` for a comprehensive list of all optional configuration variables, including RCON, email services, Valkey, OAuth providers, and PayPal integration.
|
||||||
|
|
||||||
### 4. Run CMS Migrations
|
### 4. Run CMS Migrations
|
||||||
|
|
||||||
@@ -1368,6 +1395,22 @@ It is run against the public site as a `continue-on-error` CI job, so it still
|
|||||||
surfaces a real content problem without turning the pipeline red. Point either
|
surfaces a real content problem without turning the pipeline red. Point either
|
||||||
suite at any running CMS with `PLAYWRIGHT_BASE_URL`.
|
suite at any running CMS with `PLAYWRIGHT_BASE_URL`.
|
||||||
|
|
||||||
|
### Testing & Quality Assurance
|
||||||
|
|
||||||
|
EpicNext-CMS maintains strict engineering standards enforced through automated testing pipelines:
|
||||||
|
1. **Unit & Integration Testing (Vitest)**:
|
||||||
|
- Run tests: `pnpm test`
|
||||||
|
- Run integration tests: `pnpm test:integration`
|
||||||
|
- Covers database helpers, caching strategies, background workers, and housekeeping matrix logic.
|
||||||
|
|
||||||
|
2. **End-to-End Testing (Playwright)**:
|
||||||
|
- Blocking deploy gate (`pnpm test:e2e`): validates critical user journeys (authentication, registration, captcha DOM contracts, security headers).
|
||||||
|
- Quality advisory suite (`pnpm test:e2e:quality`): non-blocking inspection of live content assets, page latency, and responsive layouts.
|
||||||
|
|
||||||
|
3. **Linting & Type Safety (Biome & TypeScript)**:
|
||||||
|
- Lint check: `pnpm lint` (or `pnpm biome:check`)
|
||||||
|
- Type check: `pnpm typecheck` (strictly validates TypeScript across App Router, Server Actions, and Drizzle models).
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
|
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
|
||||||
@@ -1378,7 +1421,21 @@ suite at any running CMS with `PLAYWRIGHT_BASE_URL`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License
|
## Production Deployment & Zero-Downtime Releases
|
||||||
|
|
||||||
|
EpicNext-CMS is engineered for enterprise-grade production environments with zero-downtime deployment capabilities:
|
||||||
|
1. **Blue/Green Slot Deployments**:
|
||||||
|
- Updates run through containerized slot orchestration (`scripts/docker-update.sh`), ensuring seamless transitions between release versions without dropping requests.
|
||||||
|
- Automatic health check verification (`/api/health`) validates database connectivity, Redis/Valkey status, and RCON liveness before routing production traffic.
|
||||||
|
|
||||||
|
2. **Automated Rollback Protection**:
|
||||||
|
- If a post-cutover health check or startup sequence fails, the system automatically triggers a rollback to the previous stable container image and logs the incident.
|
||||||
|
|
||||||
|
3. **High-Performance Caching & Rate Limiting**:
|
||||||
|
- Integrates with Redis / Valkey for distributed session storage, global rate limiting, and ephemeral response caching.
|
||||||
|
- Multi-tier anti-DDoS proxy filtering defends against volumetric application-layer abuse with automatic Cloudflare edge-block mirroring.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
CC BY-NC-SA 4.0. See `LICENSE` for details.
|
CC BY-NC-SA 4.0. See `LICENSE` for details.
|
||||||
test line
|
test line
|
||||||
|
|||||||
@@ -253,6 +253,36 @@ server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Oude, nog gecachte Nitro-configs vroegen SoundData/layout/sounds op paden
|
||||||
|
# die niet bestaan. Serveer de bronbestanden daar ook, zodat een verouderde
|
||||||
|
# browsercache geen 404's meer veroorzaakt.
|
||||||
|
location = /nitro-client/nitro-assets/gamedata/SoundData.json {
|
||||||
|
alias /var/www/Gamedata/config/SoundData.json;
|
||||||
|
add_header Cache-Control "public, max-age=2592000" always;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
location = /nitro-client/configuration/soundboard-layout.jsonc {
|
||||||
|
alias /var/www/Gamedata/config/soundboard-layout.jsonc;
|
||||||
|
add_header Cache-Control "public, max-age=2592000" always;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
location = /nitro-client/configuration/soundboard-sounds.jsonc {
|
||||||
|
alias /var/www/Gamedata/config/soundboard-sounds.jsonc;
|
||||||
|
add_header Cache-Control "public, max-age=2592000" always;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clientconfiguratie (renderer-config, ui-config, SoundData-URL's) moet niet
|
||||||
|
# 30 dagen in de browser blijven hangen: een gewijzigde gamedata-URL zou
|
||||||
|
# anders pas na cache-expiry effect hebben.
|
||||||
|
location ^~ /nitro-client/configuration/ {
|
||||||
|
alias /var/www/Octane/dist/configuration/;
|
||||||
|
try_files $uri =404;
|
||||||
|
limit_req zone=cms_static_per_ip burst=1000 nodelay;
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||||
|
add_header Cache-Tag "cms-client";
|
||||||
|
}
|
||||||
|
|
||||||
location ^~ /nitro-client/ {
|
location ^~ /nitro-client/ {
|
||||||
alias /var/www/Octane/dist/;
|
alias /var/www/Octane/dist/;
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
|
|||||||
+6
-2
@@ -6,12 +6,16 @@ import { defineConfig } from "drizzle-kit";
|
|||||||
// This DB is shared with the Arcturus emulator — NEVER run `drizzle-kit migrate`
|
// 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
|
// 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/.
|
// `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({
|
export default defineConfig({
|
||||||
dialect: "mysql",
|
dialect: isPostgres ? "postgresql" : "mysql",
|
||||||
schema: "./src/db/schema.ts",
|
schema: "./src/db/schema.ts",
|
||||||
out: "./drizzle/drafts",
|
out: "./drizzle/drafts",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL ?? "",
|
url: dbUrl,
|
||||||
},
|
},
|
||||||
strict: true,
|
strict: true,
|
||||||
verbose: true,
|
verbose: true,
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
"next-auth": "5.0.0-beta.32",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"next-intl": "4.14.9",
|
"next-intl": "4.14.9",
|
||||||
"otplib": "13.5.0",
|
"otplib": "13.5.0",
|
||||||
|
"pg": "^8.23.1",
|
||||||
"pino": "10.4.0",
|
"pino": "10.4.0",
|
||||||
"react": "19.3.1",
|
"react": "19.3.1",
|
||||||
"react-dom": "19.3.1",
|
"react-dom": "19.3.1",
|
||||||
@@ -100,6 +101,7 @@
|
|||||||
"@tailwindcss/postcss": "4.3.3",
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
"@tailwindcss/typography": "0.5.20",
|
"@tailwindcss/typography": "0.5.20",
|
||||||
"@types/node": "26.6.5",
|
"@types/node": "26.6.5",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
"@vitest/coverage-v8": "5.0.5",
|
"@vitest/coverage-v8": "5.0.5",
|
||||||
|
|||||||
Generated
+4664
-2453
File diff suppressed because it is too large.
Load diff
@@ -84,6 +84,22 @@ function applyOverrides(data) {
|
|||||||
data["soundboard.url"] =
|
data["soundboard.url"] =
|
||||||
`${gamedata}/config/soundboard-sounds.jsonc?t=%timestamp%`;
|
`${gamedata}/config/soundboard-sounds.jsonc?t=%timestamp%`;
|
||||||
}
|
}
|
||||||
|
if (gamedata) {
|
||||||
|
const soundboardDefaults = {
|
||||||
|
"soundboard.manifest.url": `${gamedata}/config/SoundData.json?t=%timestamp%`,
|
||||||
|
"soundboard.sounds.url": `${gamedata}/config/soundboard-sounds.jsonc?t=%timestamp%`,
|
||||||
|
"soundboard.layout.url": `${gamedata}/config/soundboard-layout.jsonc?t=%timestamp%`,
|
||||||
|
};
|
||||||
|
for (const [key, value] of Object.entries(soundboardDefaults)) {
|
||||||
|
if (
|
||||||
|
!(key in data) ||
|
||||||
|
isPlaceholder(data[key]) ||
|
||||||
|
data[key] === "configuration/soundboard-layout.jsonc"
|
||||||
|
) {
|
||||||
|
data[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Never force ads on/off over a hotel's own choice; only default to "off"
|
// Never force ads on/off over a hotel's own choice; only default to "off"
|
||||||
// when the key does not exist yet (prevents external ad scripts from 404ing).
|
// when the key does not exist yet (prevents external ad scripts from 404ing).
|
||||||
if (!("show.google.ads" in data)) data["show.google.ads"] = false;
|
if (!("show.google.ads" in data)) data["show.google.ads"] = false;
|
||||||
|
|||||||
@@ -193,7 +193,11 @@ run_ulimit() {
|
|||||||
echo "[mem-cap] ulimit -v $virtual_kb werd geweigerd" >&2
|
echo "[mem-cap] ulimit -v $virtual_kb werd geweigerd" >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
if command -v taskset >/dev/null 2>&1; then
|
||||||
exec taskset -c "$cpu_affinity" "$@"
|
exec taskset -c "$cpu_affinity" "$@"
|
||||||
|
else
|
||||||
|
exec "$@"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
run_refuse() {
|
run_refuse() {
|
||||||
@@ -204,7 +208,11 @@ run_refuse() {
|
|||||||
|
|
||||||
run_opted_out() {
|
run_opted_out() {
|
||||||
echo "[mem-cap] WAARSCHUWING: plafond bewust uitgeschakeld, dit commando kan de machine laten OOM-killed worden: $*" >&2
|
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" "$@"
|
exec taskset -c "$cpu_affinity" "$@"
|
||||||
|
else
|
||||||
|
exec "$@"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
case "$backend" in
|
case "$backend" in
|
||||||
|
|||||||
@@ -35,4 +35,12 @@ describe("csp", () => {
|
|||||||
expect(csp).not.toContain("upgrade-insecure-requests");
|
expect(csp).not.toContain("upgrade-insecure-requests");
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits upgrade-insecure-requests for plain-HTTP visitors in production", () => {
|
||||||
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
const csp = buildContentSecurityPolicy("n", { secure: false });
|
||||||
|
expect(csp).toContain("frame-ancestors 'none'");
|
||||||
|
expect(csp).not.toContain("upgrade-insecure-requests");
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+10
-4
@@ -2,8 +2,13 @@
|
|||||||
* Build a Content-Security-Policy value.
|
* Build a Content-Security-Policy value.
|
||||||
* Scripts/styles: per-request nonce for Next.js and ThemeVars inline tags.
|
* Scripts/styles: per-request nonce for Next.js and ThemeVars inline tags.
|
||||||
* style-src-attr keeps 'unsafe-inline' so React `style={{…}}` attributes work.
|
* style-src-attr keeps 'unsafe-inline' so React `style={{…}}` attributes work.
|
||||||
|
* `secure` reports whether the visitor reached us over HTTPS; it gates
|
||||||
|
* `upgrade-insecure-requests`.
|
||||||
*/
|
*/
|
||||||
export function buildContentSecurityPolicy(nonce: string): string {
|
export function buildContentSecurityPolicy(
|
||||||
|
nonce: string,
|
||||||
|
{ secure = true }: { secure?: boolean } = {},
|
||||||
|
): string {
|
||||||
const isDev = process.env.NODE_ENV === "development";
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
const scriptSrc = [
|
const scriptSrc = [
|
||||||
"'self'",
|
"'self'",
|
||||||
@@ -30,15 +35,16 @@ export function buildContentSecurityPolicy(nonce: string): string {
|
|||||||
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
|
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
|
||||||
// Clickjacking defence that also covers the `frame-ancestors`-aware
|
// Clickjacking defence that also covers the `frame-ancestors`-aware
|
||||||
// browsers X-Frame-Options cannot reach. `upgrade-insecure-requests` is
|
// browsers X-Frame-Options cannot reach. `upgrade-insecure-requests` is
|
||||||
// production-only: over plain-HTTP localhost it would rewrite same-origin
|
// only sent to HTTPS visitors outside development: over plain HTTP
|
||||||
// asset URLs to https and break the dev server.
|
// (localhost, a LAN/WSL IP without TLS) it rewrites same-origin asset
|
||||||
|
// URLs to https, where nothing listens, and fonts/scripts fail to load.
|
||||||
"frame-ancestors 'none'",
|
"frame-ancestors 'none'",
|
||||||
"connect-src 'self' https: wss:",
|
"connect-src 'self' https: wss:",
|
||||||
"font-src 'self' data: https://fonts.gstatic.com",
|
"font-src 'self' data: https://fonts.gstatic.com",
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
"base-uri 'self'",
|
"base-uri 'self'",
|
||||||
"form-action 'self'",
|
"form-action 'self'",
|
||||||
...(isDev ? [] : ["upgrade-insecure-requests"]),
|
...(isDev || !secure ? [] : ["upgrade-insecure-requests"]),
|
||||||
].join("; ");
|
].join("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
authUsesSecureCookies,
|
||||||
isCacheableAssetPath,
|
isCacheableAssetPath,
|
||||||
|
isHttpsRequest,
|
||||||
shouldRedirectAdminRequest,
|
shouldRedirectAdminRequest,
|
||||||
} from "./proxy-access";
|
} from "./proxy-access";
|
||||||
|
|
||||||
@@ -37,3 +39,32 @@ describe("isCacheableAssetPath", () => {
|
|||||||
expect(isCacheableAssetPath("/admin/media")).toBe(false);
|
expect(isCacheableAssetPath("/admin/media")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isHttpsRequest", () => {
|
||||||
|
it("trusts the first forwarded proto over the internal hop", () => {
|
||||||
|
expect(isHttpsRequest("https", "http:")).toBe(true);
|
||||||
|
expect(isHttpsRequest("https, http", "http:")).toBe(true);
|
||||||
|
expect(isHttpsRequest("http", "http:")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the URL Next saw", () => {
|
||||||
|
expect(isHttpsRequest(null, "https:")).toBe(true);
|
||||||
|
expect(isHttpsRequest(null, "http:")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authUsesSecureCookies", () => {
|
||||||
|
it("follows AUTH_URL when it is set, like Auth.js", () => {
|
||||||
|
expect(authUsesSecureCookies("http://172.23.71.35", "https", "http:")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
authUsesSecureCookies("https://hotel.example", "http", "http:"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the request scheme without AUTH_URL", () => {
|
||||||
|
expect(authUsesSecureCookies(undefined, "https", "http:")).toBe(true);
|
||||||
|
expect(authUsesSecureCookies(undefined, null, "http:")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,37 @@ export function shouldRedirectAdminRequest(
|
|||||||
return token === null;
|
return token === null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the visitor reached us over HTTPS. Behind Traefik/nginx Next only
|
||||||
|
* sees plain HTTP, so the first `X-Forwarded-Proto` value wins.
|
||||||
|
*/
|
||||||
|
export function isHttpsRequest(
|
||||||
|
forwardedProto: string | null,
|
||||||
|
requestProtocol: string,
|
||||||
|
): boolean {
|
||||||
|
const proto = forwardedProto?.split(",")[0]?.trim() || requestProtocol;
|
||||||
|
return proto.replace(/:$/, "").toLowerCase() === "https";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors how Auth.js picks the session cookie name (`__Secure-` prefix or
|
||||||
|
* not): AUTH_URL decides when set, otherwise the request scheme does.
|
||||||
|
*/
|
||||||
|
export function authUsesSecureCookies(
|
||||||
|
authUrl: string | undefined,
|
||||||
|
forwardedProto: string | null,
|
||||||
|
requestProtocol: string,
|
||||||
|
): boolean {
|
||||||
|
if (authUrl) {
|
||||||
|
try {
|
||||||
|
return new URL(authUrl).protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
// Auth.js would reject a malformed AUTH_URL too; fall through.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isHttpsRequest(forwardedProto, requestProtocol);
|
||||||
|
}
|
||||||
|
|
||||||
const CACHEABLE_ASSET_PREFIXES = ["/api/imaging/", "/api/media/"];
|
const CACHEABLE_ASSET_PREFIXES = ["/api/imaging/", "/api/media/"];
|
||||||
|
|
||||||
export function isCacheableAssetPath(pathname: string): boolean {
|
export function isCacheableAssetPath(pathname: string): boolean {
|
||||||
|
|||||||
+14
-2
@@ -5,8 +5,10 @@ import { buildContentSecurityPolicy, createCspNonce } from "@/lib/csp";
|
|||||||
import { isSuspiciousPath } from "@/lib/ddos";
|
import { isSuspiciousPath } from "@/lib/ddos";
|
||||||
import { ddosReject, enforceDdosRateLimit } from "@/lib/ddos-guard";
|
import { ddosReject, enforceDdosRateLimit } from "@/lib/ddos-guard";
|
||||||
import {
|
import {
|
||||||
|
authUsesSecureCookies,
|
||||||
isCacheableAssetPath,
|
isCacheableAssetPath,
|
||||||
isEdgeCacheablePublicPath,
|
isEdgeCacheablePublicPath,
|
||||||
|
isHttpsRequest,
|
||||||
shouldRedirectAdminRequest,
|
shouldRedirectAdminRequest,
|
||||||
} from "@/lib/proxy-access";
|
} from "@/lib/proxy-access";
|
||||||
|
|
||||||
@@ -43,11 +45,19 @@ export const proxy = async (req: import("next/server").NextRequest) => {
|
|||||||
// Only /admin needs a real session token for the redirect guard; skipping
|
// Only /admin needs a real session token for the redirect guard; skipping
|
||||||
// JWT decoding on every other request keeps the proxy cheap under load.
|
// JWT decoding on every other request keeps the proxy cheap under load.
|
||||||
const adminPath = pathname === "/admin" || pathname.startsWith("/admin/");
|
const adminPath = pathname === "/admin" || pathname.startsWith("/admin/");
|
||||||
|
const forwardedProto = req.headers.get("x-forwarded-proto");
|
||||||
const token = adminPath
|
const token = adminPath
|
||||||
? await getToken({
|
? await getToken({
|
||||||
req,
|
req,
|
||||||
secret: env.AUTH_SECRET,
|
secret: env.AUTH_SECRET,
|
||||||
secureCookie: true,
|
// Must match the cookie name Auth.js chose at sign-in, otherwise a
|
||||||
|
// signed-in admin on a plain-HTTP AUTH_URL is bounced between
|
||||||
|
// /admin and /login forever.
|
||||||
|
secureCookie: authUsesSecureCookies(
|
||||||
|
process.env.AUTH_URL ?? process.env.NEXTAUTH_URL,
|
||||||
|
forwardedProto,
|
||||||
|
req.nextUrl.protocol,
|
||||||
|
),
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -60,7 +70,9 @@ export const proxy = async (req: import("next/server").NextRequest) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nonce = createCspNonce();
|
const nonce = createCspNonce();
|
||||||
const csp = buildContentSecurityPolicy(nonce);
|
const csp = buildContentSecurityPolicy(nonce, {
|
||||||
|
secure: isHttpsRequest(forwardedProto, req.nextUrl.protocol),
|
||||||
|
});
|
||||||
|
|
||||||
const headers = new Headers(req.headers);
|
const headers = new Headers(req.headers);
|
||||||
headers.set("x-pathname", pathname);
|
headers.set("x-pathname", pathname);
|
||||||
|
|||||||
Reference in new issue
Block a user