9 Commits
Author SHA1 Message Date
remco d6de450a9d chore(deps): update All dependencies
CI / check (pull_request) Successful in 22s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-08-02 12:00:39 +00:00
Simo 86cd43def9 fix: mirror manual furni uploads to live assets
CI / check (push) Successful in 24s
CI / release (push) Skipped
CI / deploy (push) Successful in 46s
2026-08-02 13:24:15 +02:00
Simo 01570874a8 fix: map furni imports to production gamedata 2026-08-02 13:16:51 +02:00
Simo a81af2d71a docs: plan production furni asset fix 2026-08-02 13:13:58 +02:00
Simo 44b4b3b45c docs: design production furni asset mapping 2026-08-02 13:12:10 +02:00
Simo b3245a18ea fix: bootstrap admin CSRF tokens
CI / check (push) Successful in 23s
CI / release (push) Skipped
CI / deploy (push) Successful in 41s
2026-08-02 11:46:43 +02:00
Simo a57c73055f fix: retry login across deploy cutovers
CI / check (push) Successful in 23s
CI / release (push) Skipped
CI / deploy (push) Successful in 41s
2026-08-02 11:31:35 +02:00
Simo bfc951cfd9 fix: minimize deploy cutover downtime
CI / check (push) Successful in 22s
CI / release (push) Skipped
CI / deploy (push) Successful in 40s
2026-08-02 11:25:03 +02:00
Simo 828fda63e7 fix: keep app online during deploy preparation
CI / check (push) Successful in 23s
CI / release (push) Skipped
CI / deploy (push) Successful in 44s
2026-08-02 11:19:53 +02:00
25 changed files with 1068 additions and 151 deletions

No files matched your search

+35 -34
View File
@@ -70,16 +70,24 @@ jobs:
error_handler() {
cd /var/www/atom-nexst 2>/dev/null || cd / || true
echo "!!! DEPLOYMENT FAILED on line $1 !!!" >&2
# Roll back the build artifact if cutover already moved .next into place.
if [ "${CUTOVER_STARTED}" = "1" ] && [ -d "${LIVE}/.next.prev" ]; then
echo "Rolling back .next to previous artifact..." >&2
rm -rf "${LIVE}/.next" || true
mv "${LIVE}/.next.prev" "${LIVE}/.next" || true
# Leave the healthy current process untouched when staging fails.
# Restart only when cutover has already stopped or replaced it.
if [ "${CUTOVER_STARTED}" = "1" ]; then
if [ -d "${LIVE}/.next.prev" ]; then
echo "Rolling back .next to previous artifact..." >&2
rm -rf "${LIVE}/.next" || true
mv "${LIVE}/.next.prev" "${LIVE}/.next" || true
fi
if [ -d "${LIVE}/node_modules.prev" ]; then
echo "Rolling back node_modules to previous artifact..." >&2
rm -rf "${LIVE}/node_modules" || true
mv "${LIVE}/node_modules.prev" "${LIVE}/node_modules" || true
fi
pm2 restart next --update-env 2>/dev/null || pm2 start pnpm --name "next" -- start 2>/dev/null || true
fi
if [ -n "${STAGE}" ] && [ -d "${STAGE}" ]; then
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
fi
pm2 restart next --update-env 2>/dev/null || pm2 start pnpm --name "next" -- start 2>/dev/null || true
exit 1
}
trap 'error_handler $LINENO' ERR
@@ -150,14 +158,7 @@ jobs:
exit 1
fi
echo "Cutover: stop service (free DB connections), migrate, swap .next..."
CUTOVER_STARTED=1
pm2 stop next --kill-timeout 10000 || true
# Wait for PM2 to fully exit and MariaDB to reclaim connections.
sleep 10
# Migrate only after live is stopped — avoids ER_CON_COUNT_ERROR while
# the old process still holds DATABASE_POOL_SIZE connections.
echo "Migrating staged release while the current app stays online..."
cd "${STAGE}"
MIGRATE_OK=0
for i in $(seq 1 10); do
@@ -179,7 +180,8 @@ jobs:
# Keep env, uploads, and deps we are about to replace from stage.
git clean -fd \
-e .env -e .env.local -e .env.production -e .env*.local \
-e storage -e public/cache -e node_modules -e .next -e .next.prev
-e storage -e public/cache \
-e node_modules -e node_modules.prev -e .next -e .next.prev
if ! git diff --exit-code HEAD -- src >/dev/null; then
echo "ERROR: live src/ still differs from HEAD after reset:" >&2
@@ -188,18 +190,6 @@ jobs:
fi
echo "Verified live src/ matches HEAD"
# Save current .next as backup before swapping (kept until health check passes).
if [ -d .next ]; then
mv .next .next.prev
fi
mv "${STAGE}/.next" .next
# Use the exact node_modules the stage build resolved against.
rm -rf node_modules
mv "${STAGE}/node_modules" node_modules
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
# Next.js prefers an already-set process PORT over .env. PM2 may still
# have PORT=3000 from an older start, while .env (and nginx) expect 3002.
# Export PORT before start so the process matches health checks.
@@ -220,14 +210,25 @@ jobs:
fi
}
echo "Starting PM2 with a clean PORT=${PORT} listener..."
echo "Cutover: atomically swap artifacts and restart on PORT=${PORT}..."
CUTOVER_STARTED=1
pm2 stop next --kill-timeout 10000 || true
cd "${LIVE}"
# Cutover already stopped the app, but failed deploys can leave orphans
# on 3002 (or an old PM2 env still bound to 3000).
pm2 stop next --kill-timeout 10000 2>/dev/null || true
# Rename both current artifacts so cutover and rollback stay fast.
if [ -d .next ]; then
mv .next .next.prev
fi
mv "${STAGE}/.next" .next
if [ -d node_modules ]; then
mv node_modules node_modules.prev
fi
mv "${STAGE}/node_modules" node_modules
free_tcp_port "${PORT}"
free_tcp_port 3000
sleep 1
echo "Starting PM2 with a clean PORT=${PORT} listener..."
pm2 delete next 2>/dev/null || true
PORT="${PORT}" pm2 start pnpm --name next -- start
pm2 save 2>/dev/null || true
@@ -262,8 +263,8 @@ jobs:
exit 1
fi
echo "Cleaning stage worktree and previous .next backup..."
rm -rf "${LIVE}/.next.prev"
echo "Cleaning stage worktree and previous artifact backups..."
rm -rf "${LIVE}/.next.prev" "${LIVE}/node_modules.prev"
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
STAGE=""
@@ -0,0 +1,126 @@
# Production Furni Assets Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make every furni import write the icon, Nitro bundle, and FurnitureData entry into the directories served by the production client under `/var/www/Gamedata`.
**Architecture:** Extend the central furni asset resolver with a separate Gamedata layout while preserving CMS-local and Nitro-Files targets. Both import paths consume the same resolved targets; FurnitureData resolves the same Gamedata root independently so metadata and binary assets stay aligned.
**Tech Stack:** TypeScript 7, Node.js filesystem APIs, Vitest 4, Next.js 16.
## Global Constraints
- Preserve all existing `nitro_files_root`, `furni_*_dir`, and `furni_data_mirror_path` behavior.
- Auto-detect `/var/www/Gamedata` only when the directory exists; allow `gamedata_root` to override it.
- Do not copy source SWFs into the Gamedata tree.
- Report live mirror failures in the import warnings.
- Use test-first development and run the production build before completion.
---
### Task 1: Resolve the production Gamedata layout
**Files:**
- Create: `src/lib/services/furni-asset-dirs.test.ts`
- Modify: `src/lib/services/furni-asset-dirs.ts`
- Modify: `src/lib/services/furni-data.ts`
- Modify: `src/app/admin/settings/cms-settings-config.ts`
**Interfaces:**
- Produces: `getGamedataRoot(): Promise<string>`.
- Produces: Gamedata mirror entries from `getFurniAssetWriteTargets()` with `<root>/icons` and `<root>/bundled/furniture`.
- Consumes: `siteSettings.get("gamedata_root", "")` and `existsSync(DEFAULT_GAMEDATA_ROOT)`.
- [ ] **Step 1: Write failing resolver tests**
Mock `siteSettings.get` and `existsSync`, then assert that a configured Gamedata root produces:
```ts
expect(targets.mirrorDirs).toContainEqual({
swfDir: targets.swfDir,
iconDir: path.join(gamedataRoot, "icons"),
nitroDir: path.join(gamedataRoot, "bundled/furniture"),
});
```
Also assert that an absent unconfigured root adds no Gamedata mirror and that a duplicate primary destination is de-duplicated.
- [ ] **Step 2: Run the resolver test and verify RED**
Run: `pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts`
Expected: FAIL because `gamedata_root` is not read and the Gamedata mirror is absent.
- [ ] **Step 3: Implement the Gamedata resolver**
Add `DEFAULT_GAMEDATA_ROOT`, `getGamedataRoot()`, and a pure Gamedata mapping that uses the primary `swfDir` while mapping icons and Nitro bundles to the live locations. Merge this candidate with the existing Nitro-Files candidate and remove identical directory triples.
- [ ] **Step 4: Extend FurnitureData and the settings form**
Make `getFurnitureDataWritePaths()` include `<gamedata_root>/config/FurnitureData.json`, after any explicit `furni_data_mirror_path`. Add a documented `gamedata_root` text setting with `/var/www/Gamedata` as the placeholder.
- [ ] **Step 5: Run resolver tests and verify GREEN**
Run: `pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts`
Expected: PASS.
- [ ] **Step 6: Commit Task 1**
```text
fix: map furni imports to production gamedata
```
### Task 2: Mirror manual Nitro uploads
**Files:**
- Create: `src/lib/services/upload-import.test.ts`
- Modify: `src/lib/services/upload-import.ts`
**Interfaces:**
- Consumes: `getFurniAssetWriteTargets(): Promise<FurniAssetWriteTargets>`.
- Produces: manual upload copies at every unique `mirrorDirs[].iconDir` and `mirrorDirs[].nitroDir`.
- [ ] **Step 1: Write a failing manual-upload test**
Mock the database and metadata dependencies, provide temporary primary and Gamedata directories, call `uploadSingleFurni`, and assert:
```ts
await expect(fs.readFile(path.join(liveNitroDir, "chair.nitro"))).resolves.toEqual(nitroBuffer);
await expect(fs.readFile(path.join(liveIconDir, "chair_icon.png"))).resolves.toEqual(iconBuffer);
```
- [ ] **Step 2: Run the upload test and verify RED**
Run: `pnpm exec vitest run --coverage=false src/lib/services/upload-import.test.ts`
Expected: FAIL because manual uploads currently write only to the primary CMS directories.
- [ ] **Step 3: Implement manual mirroring**
Resolve all write targets, create their directories through the existing `ensureDirectories()`, and copy the successfully written primary Nitro and optional icon files to each unique mirror. Catch each copy error separately and append a warning containing the destination path.
- [ ] **Step 4: Run the upload test and verify GREEN**
Run: `pnpm exec vitest run --coverage=false src/lib/services/upload-import.test.ts`
Expected: PASS.
- [ ] **Step 5: Run focused and full verification**
Run:
```text
pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts src/lib/services/upload-import.test.ts src/lib/services/furni-import.test.ts
pnpm typecheck
pnpm test
pnpm build
```
Expected: every command exits with status 0.
- [ ] **Step 6: Commit Task 2**
```text
fix: mirror manual furni uploads to live assets
```
@@ -0,0 +1,55 @@
# Production furni asset destinations
## Problem
The production Nitro client loads furniture icons from `/gamedata/icons`,
furniture bundles from `/gamedata/bundled/furniture`, and furniture metadata
from `/gamedata/config/FurnitureData.json`. The importer currently derives its
mirror paths using the development `Nitro-Files` layout. On the production
server this creates paths such as `swf/dcr/hof_furni/icons` and
`nitro-assets/bundled/furniture` below the configured root, which are not the
directories served by the live client. Manual `.nitro` uploads do not mirror
assets at all.
Files written only below the CMS `public` directory are also not durable: the
deployment workflow cleans untracked files from the CMS checkout.
## Design
Keep the existing CMS-local and `Nitro-Files` destinations for compatibility,
and add the deployed Gamedata tree as a distinct asset layout. In production,
`/var/www/Gamedata` is detected automatically when it exists. A configurable
`gamedata_root` setting can override that location for other installations.
The Gamedata layout maps assets as follows:
- icons: `<gamedata_root>/icons`
- Nitro furniture: `<gamedata_root>/bundled/furniture`
- FurnitureData: `<gamedata_root>/config/FurnitureData.json`
- source SWFs: not copied into Gamedata because the Nitro client does not load
them; existing CMS-local and `Nitro-Files` SWF handling remains unchanged
Both the normal Habbo importer and manual `.nitro` uploader use the same write
target resolver. Duplicate destinations are removed before writing.
## Error handling
The CMS-local write remains the primary operation. Every configured or
auto-detected live destination is treated as an expected mirror. Directory or
copy failures are returned as import warnings containing the failed target,
instead of being silently ignored. A successful import therefore cannot hide
that the live client asset copy failed.
## Compatibility
Existing `nitro_files_root`, `furni_swf_dir`, `furni_icon_dir`,
`furni_nitro_dir`, and `furni_data_mirror_path` behavior is preserved. The new
Gamedata destination is additive, so Windows development using the
`Nitro-Files` layout continues to work.
## Verification
Unit tests will cover Gamedata path derivation, automatic production-root
detection through an injected root, destination de-duplication, and manual
upload mirroring. Existing importer tests, type checks, and the production
build must remain green.
+5 -5
View File
@@ -39,7 +39,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.5.7",
"@hookform/resolvers": "^5.7.1",
"@tanstack/react-virtual": "^3.14.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -48,7 +48,7 @@
"drizzle-orm": "^0.45.2",
"hash-wasm": "^4.12.0",
"ioredis": "^5.11.1",
"isomorphic-dompurify": "^3.20.0",
"isomorphic-dompurify": "^3.21.0",
"jpeg-js": "^0.4.4",
"json5": "^2.2.3",
"jszip": "^3.10.1",
@@ -65,7 +65,7 @@
"pino": "^10.3.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.83.0",
"react-hook-form": "^7.84.0",
"resend": "^6.18.1",
"server-only": "^0.0.1",
"sharp": "^0.35.3",
@@ -88,12 +88,12 @@
"dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"husky": "^9.1.7",
"knip": "^6.30.0",
"knip": "^6.31.0",
"lint-staged": "^17.3.0",
"pino-pretty": "^13.1.3",
"postcss": "^8.5.25",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.1",
"tsx": "^4.23.2",
"typescript": "^7.0.2",
"vite": "8.2.0",
"vitest": "4.1.10"
+39 -39
View File
@@ -33,8 +33,8 @@ importers:
specifier: ^3.2.2
version: 3.2.2([email protected])
'@hookform/resolvers':
specifier: ^5.5.7
version: 5.5.7(@standard-schema/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]3.0([email protected]))([email protected]([email protected]))([email protected])
specifier: ^5.7.1
version: 5.7.1(@standard-schema/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]4.0([email protected]))([email protected]([email protected]))([email protected])
'@tanstack/react-virtual':
specifier: ^3.14.9
version: 3.14.9([email protected]([email protected]))([email protected])
@@ -60,8 +60,8 @@ importers:
specifier: ^5.11.1
version: 5.11.1
isomorphic-dompurify:
specifier: ^3.20.0
version: 3.20.0(@noble/[email protected])
specifier: ^3.21.0
version: 3.21.0(@noble/[email protected])
jpeg-js:
specifier: ^0.4.4
version: 0.4.4
@@ -111,8 +111,8 @@ importers:
specifier: ^19.2.8
version: 19.2.8([email protected])
react-hook-form:
specifier: ^7.83.0
version: 7.83.0([email protected])
specifier: ^7.84.0
version: 7.84.0([email protected])
resend:
specifier: ^6.18.1
version: 6.18.1
@@ -175,8 +175,8 @@ importers:
specifier: ^9.1.7
version: 9.1.7
knip:
specifier: ^6.30.0
version: 6.30.0
specifier: ^6.31.0
version: 6.31.0
lint-staged:
specifier: ^17.3.0
version: 17.3.0
@@ -190,17 +190,17 @@ importers:
specifier: ^4.3.3
version: 4.3.3
tsx:
specifier: ^4.23.1
version: 4.23.1
specifier: ^4.23.2
version: 4.23.2
typescript:
specifier: ^7.0.2
version: 7.0.2
vite:
specifier: 8.2.0
version: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].1)([email protected])
version: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected])
vitest:
specifier: 4.1.10
version: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].1)([email protected]))
version: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected]))
packages:
@@ -934,18 +934,18 @@ packages:
'@formatjs/[email protected]':
resolution: {integrity: sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==}
'@hookform/resolvers@5.5.7':
resolution: {integrity: sha512-CyPCYV8/KlXfEXLWj8HHHhVsR/IZ6Ckm3b/a4fWtO/lRnRK1huqncb8LlAWrmpPRsse5glF5aVuDRMHEr3UGag==}
'@hookform/resolvers@5.7.1':
resolution: {integrity: sha512-8wS/P4UDr5sQDe4nFaV51TVyfDPrWgNIXweqG0Bs9Z5LSuzKLb+RQNPvkN2oHM5SRrJyWrVH/F+LOUcFjUyvwQ==}
peerDependencies:
'@sinclair/typebox': '>=0.25.24'
'@standard-schema/spec': ^1.0.0
'@typeschema/main': '>=0.13.7'
'@vinejs/vine': ^2.0.0 || ^3.0.0
'@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0
ajv: ^8.12.0
ajv-errors: ^3.0.0
ajv-formats: ^2.1.1
arktype: ^2.0.0
ata-validator: ^0.7.0
ata-validator: ^1.2.0
class-transformer: '>=0.4.0'
class-validator: '>=0.12.0'
computed-types: ^1.0.0
@@ -3072,8 +3072,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
[email protected]0.0:
resolution: {integrity: sha512-WH3UcdmOrqLJF6qHaGnGINNMRwkQXVtvxlhEdygP3FdbuPjfFEsvjaqA5E16uYlNvAT5uGqK4MPvJBvtTtriEA==}
[email protected]1.0:
resolution: {integrity: sha512-Bg5mqtMnMdBBwz7MjvH3oAuLOA/j0xtawVkcAZsOpVhOsfaLYwnAvhEvz94f8585Q6LCnnNr2XNApbXlB0J3GQ==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
[email protected]:
@@ -3125,8 +3125,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
[email protected]0.0:
resolution: {integrity: sha512-C5MsWT17QqMLtrvXIH4N9czRpt/T1GzwfkR/10qW3pdCHFUCoGF+Eeyq8FCKIkv56Gm5zSDv8fq3fs4whktehA==}
[email protected]1.0:
resolution: {integrity: sha512-NbeIEmUS2VUMjAkbiSNOKPJeV9wpCsr0660sUyKyMQbk4Iom0++nTLInVp4MJ+LfR4kORnw67bDi5tvO7YLnzA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -3604,8 +3604,8 @@ packages:
peerDependencies:
react: ^19.2.8
[email protected]3.0:
resolution: {integrity: sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A==}
[email protected]4.0:
resolution: {integrity: sha512-+hWvQP6GLco56mDwrbU4XnHix8t1z90ltZsDIrREl+jnQFQxYLX8oAzqe/Xn8nHpmoXTY5M6oEXrAhbP1qevNQ==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
@@ -3924,8 +3924,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
[email protected].1:
resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==}
[email protected].2:
resolution: {integrity: sha512-Cj3EO8dIn8hXjlSFh/dnJOKAFNE52YMw4rZX0HqlnIdcBoombYNd1Z0ALUpbaLilH6fdDpYqs9GZtsyfkfBjPg==}
engines: {node: '>=18.0.0'}
hasBin: true
@@ -4625,10 +4625,10 @@ snapshots:
dependencies:
'@formatjs/fast-memoize': 3.1.7
'@hookform/resolvers@5.5.7(@standard-schema/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]3.0([email protected]))([email protected]([email protected]))([email protected])':
'@hookform/resolvers@5.7.1(@standard-schema/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]4.0([email protected]))([email protected]([email protected]))([email protected])':
dependencies:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.83.0([email protected])
react-hook-form: 7.84.0([email protected])
optionalDependencies:
'@standard-schema/spec': 1.1.0
ajv: 8.20.0
@@ -5806,7 +5806,7 @@ snapshots:
obug: 2.1.4
std-env: 4.2.0
tinyrainbow: 3.1.1
vitest: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].1)([email protected]))
vitest: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected]))
'@vitest/[email protected]':
dependencies:
@@ -5817,13 +5817,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.1
'@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].1)([email protected]))':
'@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected]))':
dependencies:
'@vitest/spy': 4.1.10
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].1)([email protected])
vite: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected])
'@vitest/[email protected]':
dependencies:
@@ -6091,7 +6091,7 @@ snapshots:
'@drizzle-team/brocli': 0.10.2
'@esbuild-kit/esm-loader': 2.6.5
esbuild: 0.25.12
tsx: 4.23.1
tsx: 4.23.2
[email protected](@electric-sql/[email protected])(@opentelemetry/[email protected])(@prisma/[email protected]([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))([email protected](@types/[email protected]))([email protected])([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])([email protected])):
optionalDependencies:
@@ -6395,7 +6395,7 @@ snapshots:
[email protected]:
optional: true
[email protected]0.0(@noble/[email protected]):
[email protected]1.0(@noble/[email protected]):
dependencies:
dompurify: 3.4.12
jsdom: 30.0.1(@noble/[email protected])
@@ -6464,7 +6464,7 @@ snapshots:
readable-stream: 2.3.8
setimmediate: 1.0.5
[email protected]0.0:
[email protected]1.0:
dependencies:
fdir: 6.5.0([email protected])
formatly: 0.3.0
@@ -6970,7 +6970,7 @@ snapshots:
react: 19.2.8
scheduler: 0.27.0
[email protected]3.0([email protected]):
[email protected]4.0([email protected]):
dependencies:
react: 19.2.8
@@ -7274,7 +7274,7 @@ snapshots:
[email protected]: {}
[email protected].1:
[email protected].2:
dependencies:
esbuild: 0.28.1
optionalDependencies:
@@ -7345,7 +7345,7 @@ snapshots:
typescript: 7.0.2
optional: true
[email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].1)([email protected]):
[email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected]):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.5
@@ -7358,13 +7358,13 @@ snapshots:
fsevents: 2.3.3
jiti: 2.7.0
terser: 5.49.0
tsx: 4.23.1
tsx: 4.23.2
yaml: 2.9.0
[email protected](@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].1)([email protected])):
[email protected](@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected])):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].1)([email protected]))
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected]))
'@vitest/pretty-format': 4.1.10
'@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10
@@ -7381,7 +7381,7 @@ snapshots:
tinyexec: 1.2.4
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
vite: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].1)([email protected])
vite: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected])
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
+3 -8
View File
@@ -17,11 +17,11 @@ import {
parseAdminNavConfig,
} from "@/lib/admin-nav-config";
import { db, User } from "@/lib/db";
import { setCsrfCookie } from "@/lib/foundation/security";
import { readCsrfCookieToken } from "@/lib/foundation/security";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
// Request-time auth: requireStaff, CSRF cookie, and optional 2FA gate cannot be
// Request-time auth, existing CSRF cookie, and optional 2FA gate cannot be
// statically rendered. Child admin pages inherit this — leaf force-dynamic is redundant.
export const dynamic = "force-dynamic";
@@ -31,12 +31,7 @@ export default async function AdminLayout({
children: ReactNode;
}) {
const staff = await requireStaff();
let csrfToken = "";
try {
csrfToken = await setCsrfCookie();
} catch {
csrfToken = "";
}
const csrfToken = await readCsrfCookieToken();
if (await siteSettings.getBool("force_staff_2fa", false)) {
const u = await db
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
@@ -148,6 +148,14 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
type: "text",
placeholder: "E:\\path\\to\\Nitro-Files",
},
{
key: "gamedata_root",
label: "Production Gamedata root (server path)",
description:
"Filesystem root served at /gamedata. Auto-detected as /var/www/Gamedata when present.",
type: "text",
placeholder: "/var/www/Gamedata",
},
],
},
{
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
setCsrfCookie: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin:
(_options: unknown, handler: (...args: never[]) => unknown) =>
(...args: never[]) =>
handler(...args),
}));
vi.mock("@/lib/foundation/security", () => ({
setCsrfCookie: mocks.setCsrfCookie,
}));
import { GET } from "./route";
describe("admin CSRF bootstrap route", () => {
beforeEach(() => {
mocks.setCsrfCookie.mockReset();
});
it("returns the token written by the route handler without caching", async () => {
const token = "a".repeat(64);
mocks.setCsrfCookie.mockResolvedValue(token);
const response = await GET(
new NextRequest("http://localhost/api/admin/csrf"),
);
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({ ok: true, token });
});
it("fails closed when the cookie cannot be written", async () => {
mocks.setCsrfCookie.mockResolvedValue("");
const response = await GET(
new NextRequest("http://localhost/api/admin/csrf"),
);
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
error: "Unable to initialize CSRF token",
});
});
});
+12
View File
@@ -0,0 +1,12 @@
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { setCsrfCookie } from "@/lib/foundation/security";
export const GET = withAdmin({ requireCsrf: false }, async () => {
const token = await setCsrfCookie();
if (!token) return apiError("Unable to initialize CSRF token", 500);
const response = apiOk({ token });
response.headers.set("Cache-Control", "no-store");
return response;
});
+2 -7
View File
@@ -17,7 +17,7 @@ import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { requireMod } from "@/lib/admin/guard";
import { db, User } from "@/lib/db";
import { setCsrfCookie } from "@/lib/foundation/security";
import { readCsrfCookieToken } from "@/lib/foundation/security";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
@@ -25,12 +25,7 @@ export const dynamic = "force-dynamic";
export default async function ModLayout({ children }: { children: ReactNode }) {
const staff = await requireMod();
let csrfToken = "";
try {
csrfToken = await setCsrfCookie();
} catch {
csrfToken = "";
}
const csrfToken = await readCsrfCookieToken();
if (await siteSettings.getBool("force_staff_2fa", false)) {
const [u] = await db
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
+11 -6
View File
@@ -8,6 +8,7 @@ import {
CaptchaWidget,
readCaptchaToken,
} from "@/components/auth/captcha-widget";
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
export function HomeLoginForm({
captcha = { provider: "none" },
@@ -49,12 +50,14 @@ export function HomeLoginForm({
return;
}
}
const res = await signIn("credentials", {
username,
password,
code,
redirect: false,
});
const res = await signInWithTransientRetry(() =>
signIn("credentials", {
username,
password,
code,
redirect: false,
}),
);
if (!res || res.error) {
setError(
needs2fa ? "Invalid 2FA code" : "Invalid username or password",
@@ -62,6 +65,8 @@ export function HomeLoginForm({
return;
}
window.location.href = "/";
} catch {
setError(needs2fa ? "Invalid 2FA code" : "Invalid username or password");
} finally {
setPending(false);
}
+11 -6
View File
@@ -10,6 +10,7 @@ import {
CaptchaWidget,
readCaptchaToken,
} from "@/components/auth/captcha-widget";
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
export function LoginForm({
captcha = { provider: "none" },
@@ -52,12 +53,14 @@ export function LoginForm({
return;
}
}
const res = await signIn("credentials", {
username,
password,
code,
redirect: false,
});
const res = await signInWithTransientRetry(() =>
signIn("credentials", {
username,
password,
code,
redirect: false,
}),
);
if (!res || res.error) {
setError(
needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"),
@@ -65,6 +68,8 @@ export function LoginForm({
return;
}
window.location.href = "/me";
} catch {
setError(needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"));
} finally {
setPending(false);
}
+86 -24
View File
@@ -1,31 +1,93 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { adminFetch } from "./admin-fetch";
describe("admin CSRF wiring", () => {
it("defaults CSRF on for mutating withAdmin handlers", () => {
const source = readFileSync(
resolve(process.cwd(), "src/lib/api-handler.ts"),
"utf8",
);
expect(source).toContain("options.requireCsrf !== false");
const TOKEN = "a".repeat(64);
const REFRESHED_TOKEN = "b".repeat(64);
function installDocument(initialToken: string | null) {
let token = initialToken;
const meta = {
getAttribute: (name: string) => (name === "content" ? token : null),
setAttribute: (name: string, value: string) => {
if (name === "content") token = value;
},
};
vi.stubGlobal("document", {
querySelector: () => (token ? meta : null),
createElement: () => meta,
head: { appendChild: vi.fn() },
});
}
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
describe("adminFetch CSRF handling", () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
it("issues a csrf meta tag from the admin layout", () => {
const source = readFileSync(
resolve(process.cwd(), "src/app/admin/layout.tsx"),
"utf8",
);
expect(source).toContain("setCsrfCookie");
expect(source).toContain('meta name="csrf-token"');
afterEach(() => {
vi.unstubAllGlobals();
});
it("provides adminFetch helper that sets x-csrf-token", () => {
const source = readFileSync(
resolve(process.cwd(), "src/lib/admin-fetch.ts"),
"utf8",
);
expect(source).toContain("x-csrf-token");
expect(source).toContain("getCsrfToken");
it("bootstraps a missing CSRF token before a mutating request", async () => {
installDocument(null);
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse({ ok: true, token: TOKEN }))
.mockResolvedValueOnce(jsonResponse({ ok: true }));
vi.stubGlobal("fetch", fetchMock);
const response = await adminFetch("/api/admin/import/furni", {
method: "POST",
});
expect(response.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/admin/csrf");
const requestHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers);
expect(requestHeaders.get("x-csrf-token")).toBe(TOKEN);
});
it("refreshes and retries once when the server rejects a stale token", async () => {
installDocument(TOKEN);
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
jsonResponse(
{ ok: false, error: "Invalid or missing CSRF token" },
403,
),
)
.mockResolvedValueOnce(jsonResponse({ ok: true, token: REFRESHED_TOKEN }))
.mockResolvedValueOnce(jsonResponse({ ok: true, imported: 1 }));
vi.stubGlobal("fetch", fetchMock);
const response = await adminFetch("/api/admin/import/furni", {
method: "POST",
});
expect(await response.json()).toEqual({ ok: true, imported: 1 });
expect(fetchMock).toHaveBeenCalledTimes(3);
const retryHeaders = new Headers(fetchMock.mock.calls[2]?.[1]?.headers);
expect(retryHeaders.get("x-csrf-token")).toBe(REFRESHED_TOKEN);
});
it("does not bootstrap CSRF for read-only requests", async () => {
installDocument(null);
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse([]));
vi.stubGlobal("fetch", fetchMock);
const response = await adminFetch("/api/admin/import/furni");
expect(response.status).toBe(200);
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/admin/import/furni");
});
});
+65 -6
View File
@@ -1,4 +1,9 @@
const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const CSRF_ENDPOINT = "/api/admin/csrf";
const CSRF_ERROR = "Invalid or missing CSRF token";
const CSRF_TOKEN_LENGTH = 64;
let csrfBootstrapPromise: Promise<string> | null = null;
/** Read the CSRF token injected by the admin layout `<meta name="csrf-token">`. */
export function getCsrfToken(): string | null {
@@ -10,22 +15,76 @@ export function getCsrfToken(): string | null {
);
}
function writeCsrfToken(token: string) {
let meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) {
meta = document.createElement("meta");
meta.setAttribute("name", "csrf-token");
document.head.appendChild(meta);
}
meta.setAttribute("content", token);
}
async function bootstrapCsrfToken(): Promise<string> {
if (csrfBootstrapPromise) return csrfBootstrapPromise;
csrfBootstrapPromise = fetch(CSRF_ENDPOINT, {
credentials: "same-origin",
cache: "no-store",
})
.then(async (response) => {
if (!response.ok) throw new Error("Unable to initialize CSRF token");
const data = (await response.json()) as { token?: unknown };
if (
typeof data.token !== "string" ||
data.token.length !== CSRF_TOKEN_LENGTH
) {
throw new Error("Invalid CSRF bootstrap response");
}
writeCsrfToken(data.token);
return data.token;
})
.finally(() => {
csrfBootstrapPromise = null;
});
return csrfBootstrapPromise;
}
async function isCsrfRejection(response: Response): Promise<boolean> {
if (response.status !== 403) return false;
try {
const data = (await response.clone().json()) as { error?: unknown };
return data.error === CSRF_ERROR;
} catch {
return false;
}
}
/**
* Same-origin fetch for admin APIs. Attaches `x-csrf-token` on mutating methods.
*/
export function adminFetch(
export async function adminFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
const method = (init?.method ?? "GET").toUpperCase();
const headers = new Headers(init?.headers);
if (MUTATING.has(method)) {
const token = getCsrfToken();
if (token) headers.set("x-csrf-token", token);
const mutating = MUTATING.has(method);
if (mutating) {
const token = getCsrfToken() ?? (await bootstrapCsrfToken());
headers.set("x-csrf-token", token);
}
return fetch(input, {
const requestInit = {
...init,
headers,
credentials: init?.credentials ?? "same-origin",
});
} satisfies RequestInit;
const response = await fetch(input, requestInit);
if (!mutating || !(await isCsrfRejection(response))) return response;
const refreshedToken = await bootstrapCsrfToken();
headers.set("x-csrf-token", refreshedToken);
return fetch(input, { ...requestInit, headers });
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { signInWithTransientRetry } from "./sign-in-retry";
describe("signInWithTransientRetry", () => {
it("retries thrown transport errors with a short backoff", async () => {
const transportError = new SyntaxError("unexpected character in JSON");
const signIn = vi
.fn<() => Promise<{ ok: boolean }>>()
.mockRejectedValueOnce(transportError)
.mockRejectedValueOnce(transportError)
.mockResolvedValue({ ok: true });
const wait = vi.fn(async () => undefined);
await expect(signInWithTransientRetry(signIn, wait)).resolves.toEqual({
ok: true,
});
expect(signIn).toHaveBeenCalledTimes(3);
expect(wait).toHaveBeenNthCalledWith(1, 400);
expect(wait).toHaveBeenNthCalledWith(2, 800);
});
it("returns ordinary auth responses without retrying", async () => {
const response = { ok: false, error: "CredentialsSignin" };
const signIn = vi.fn(async () => response);
const wait = vi.fn(async () => undefined);
await expect(signInWithTransientRetry(signIn, wait)).resolves.toBe(
response,
);
expect(signIn).toHaveBeenCalledOnce();
expect(wait).not.toHaveBeenCalled();
});
it("rethrows the last transport error after four attempts", async () => {
const transportError = new Error("temporary upstream failure");
const signIn = vi
.fn<() => Promise<never>>()
.mockRejectedValue(transportError);
const wait = vi.fn(async () => undefined);
await expect(signInWithTransientRetry(signIn, wait)).rejects.toBe(
transportError,
);
expect(signIn).toHaveBeenCalledTimes(4);
expect(wait).toHaveBeenCalledTimes(3);
});
});
+25
View File
@@ -0,0 +1,25 @@
type Wait = (delayMs: number) => Promise<unknown>;
const waitForDelay: Wait = (delayMs) =>
new Promise((resolve) => setTimeout(resolve, delayMs));
export async function signInWithTransientRetry<T>(
signIn: () => Promise<T>,
wait: Wait = waitForDelay,
): Promise<T> {
const maxAttempts = 4;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await signIn();
} catch (error) {
if (attempt === maxAttempts - 1) {
throw error;
}
await wait(400 * 2 ** attempt);
}
}
throw new Error("Unreachable auth retry state");
}
+32 -4
View File
@@ -6,6 +6,7 @@ const liveVar = "${" + "LIVE}";
const stageVar = "${" + "STAGE}";
const userVar = "${" + "DEPLOY_USER}";
const groupVar = "${" + "DEPLOY_GROUP}";
const cutoverStartedVar = "${" + "CUTOVER_STARTED}";
function toContainLiteral(workflow: string, literal: string) {
return workflow.indexOf(literal) >= 0;
@@ -66,7 +67,7 @@ describe("production deploy workflow", () => {
expect(deployJob).toContain("pnpm typecheck");
});
it("swaps a built .next artifact during a short service cutover", () => {
it("migrates while the current app is online, then swaps the built artifact", () => {
expect(deployJob).toContain("mv .next .next.prev");
expect(toContainLiteral(deployJob, `mv "${stageVar}/.next" .next`)).toBe(
true,
@@ -74,20 +75,47 @@ describe("production deploy workflow", () => {
expect(
toContainLiteral(deployJob, `mv "${stageVar}/node_modules" node_modules`),
).toBe(true);
expect(deployJob).toContain("mv node_modules node_modules.prev");
expect(deployJob).toContain("Rolling back .next to previous artifact");
expect(deployJob).toContain(
"Rolling back node_modules to previous artifact",
);
const buildAt = deployJob.indexOf("pnpm build");
const stopAt = deployJob.indexOf("pm2 stop next");
const migrateAt = deployJob.indexOf("pnpm db:migrate");
const resetAt = deployJob.indexOf("git reset --hard origin/main");
const startLabelAt = deployJob.indexOf("Starting PM2");
const startAt = deployJob.indexOf(
"pm2 start pnpm --name next -- start",
startLabelAt,
);
expect(buildAt).toBeGreaterThan(-1);
expect(stopAt).toBeGreaterThan(buildAt);
expect(migrateAt).toBeGreaterThan(stopAt);
expect(startLabelAt).toBeGreaterThan(migrateAt);
expect(migrateAt).toBeGreaterThan(buildAt);
expect(resetAt).toBeGreaterThan(migrateAt);
expect(stopAt).toBeGreaterThan(resetAt);
expect(startLabelAt).toBeGreaterThan(stopAt);
expect(startAt).toBeGreaterThan(startLabelAt);
expect(deployJob.match(/pm2 stop next/g)).toHaveLength(1);
expect(deployJob.slice(stopAt, startAt)).not.toContain("sleep ");
});
it("does not restart the healthy app when deployment fails before cutover", () => {
const handlerStart = deployJob.indexOf("error_handler() {");
const handlerEnd = deployJob.indexOf("trap 'error_handler", handlerStart);
const handler = deployJob.slice(handlerStart, handlerEnd);
const cutoverGuardAt = handler.indexOf(
`if [ "${cutoverStartedVar}" = "1" ]; then`,
);
const restartAt = handler.indexOf("pm2 restart next", cutoverGuardAt);
const stageCleanupAt = handler.indexOf(`if [ -n "${stageVar}"`, restartAt);
expect(handlerStart).toBeGreaterThan(-1);
expect(cutoverGuardAt).toBeGreaterThan(-1);
expect(restartAt).toBeGreaterThan(cutoverGuardAt);
expect(stageCleanupAt).toBeGreaterThan(restartAt);
expect(handler.slice(restartAt, stageCleanupAt)).toContain(
"\n fi",
);
});
it("does not override onlyBuiltDependencies (uses pnpm-workspace.yaml)", () => {
+41
View File
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
cookies: vi.fn(),
headers: vi.fn(),
}));
vi.mock("next/headers", () => ({
cookies: mocks.cookies,
headers: mocks.headers,
}));
import { readCsrfCookieToken } from "./security";
describe("readCsrfCookieToken", () => {
beforeEach(() => {
mocks.cookies.mockReset();
});
it("reads an existing valid CSRF cookie without attempting a write", async () => {
const token = "a".repeat(64);
const set = vi.fn();
mocks.cookies.mockResolvedValue({
get: (name: string) =>
name === "__Host-csrf-token" ? { value: token } : undefined,
set,
});
await expect(readCsrfCookieToken()).resolves.toBe(token);
expect(set).not.toHaveBeenCalled();
});
it("returns an empty token when no valid cookie exists", async () => {
mocks.cookies.mockResolvedValue({
get: () => undefined,
set: vi.fn(),
});
await expect(readCsrfCookieToken()).resolves.toBe("");
});
});
+10
View File
@@ -139,6 +139,16 @@ function trySetCsrfCookie(
}
}
/** Read-only access for Server Components, which cannot write response cookies. */
export async function readCsrfCookieToken(): Promise<string> {
try {
return readExistingCsrfCookie(await cookies()) ?? "";
} catch {
logger.warn("Failed to read CSRF cookie");
return "";
}
}
/** Sets the CSRF cookie when possible; returns token or empty string (never throws). */
export async function setCsrfCookie(): Promise<string> {
try {
+87
View File
@@ -0,0 +1,87 @@
// @vitest-environment node
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { existsSyncMock, settingGetMock } = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
settingGetMock: vi.fn(),
}));
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return { ...actual, existsSync: existsSyncMock };
});
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { get: settingGetMock },
}));
import { getFurniAssetWriteTargets } from "./furni-asset-dirs";
const primary = {
swfDir: path.join("test-assets", "primary", "swf"),
iconDir: path.join("test-assets", "primary", "icons"),
nitroDir: path.join("test-assets", "primary", "furniture"),
};
function configureSettings(values: Record<string, string>): void {
settingGetMock.mockImplementation(
async (key: string, fallback: string | null = null) =>
values[key] ?? fallback,
);
}
describe("getFurniAssetWriteTargets", () => {
beforeEach(() => {
vi.clearAllMocks();
existsSyncMock.mockReturnValue(false);
});
it("maps a configured production Gamedata root to the directories served by Nitro", async () => {
const gamedataRoot = path.join("test-assets", "Gamedata");
configureSettings({
furni_swf_dir: primary.swfDir,
furni_icon_dir: primary.iconDir,
furni_nitro_dir: primary.nitroDir,
nitro_files_root: "",
gamedata_root: gamedataRoot,
});
const targets = await getFurniAssetWriteTargets();
expect(targets.mirrorDirs).toContainEqual({
swfDir: primary.swfDir,
iconDir: path.join(gamedataRoot, "icons"),
nitroDir: path.join(gamedataRoot, "bundled", "furniture"),
});
});
it("does not add a production mirror when no root is configured or present", async () => {
configureSettings({
furni_swf_dir: primary.swfDir,
furni_icon_dir: primary.iconDir,
furni_nitro_dir: primary.nitroDir,
nitro_files_root: "",
gamedata_root: "",
});
const targets = await getFurniAssetWriteTargets();
expect(targets.mirrorDirs).toEqual([]);
});
it("does not duplicate a Gamedata layout already used as the primary target", async () => {
const gamedataRoot = path.join("test-assets", "Gamedata");
configureSettings({
furni_swf_dir: primary.swfDir,
furni_icon_dir: path.join(gamedataRoot, "icons"),
furni_nitro_dir: path.join(gamedataRoot, "bundled", "furniture"),
nitro_files_root: "",
gamedata_root: gamedataRoot,
});
const targets = await getFurniAssetWriteTargets();
expect(targets.mirrorDirs).toEqual([]);
});
});
+44 -7
View File
@@ -7,6 +7,8 @@ export const DEFAULT_NITRO_FILES_ROOT = path.join(
"DEV",
"Nitro-Files",
);
export const DEFAULT_GAMEDATA_ROOT =
process.platform === "win32" ? "" : "/var/www/Gamedata";
export const DEFAULT_FURNI_ICON_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
"public/swf/dcr/hof_furni/icons",
@@ -63,6 +65,16 @@ export async function getNitroFilesRoot(): Promise<string> {
: "";
}
export async function getGamedataRoot(): Promise<string> {
const configured = (await siteSettings.get("gamedata_root", "")) ?? "";
if (configured.trim()) return normalizeDir(configured, configured);
return DEFAULT_GAMEDATA_ROOT &&
existsSync(/*turbopackIgnore: true*/ DEFAULT_GAMEDATA_ROOT)
? normalizeDir(DEFAULT_GAMEDATA_ROOT, DEFAULT_GAMEDATA_ROOT)
: "";
}
function dirsFromNitroFilesRoot(root: string): FurniAssetDirs {
return {
swfDir: path.join(/*turbopackIgnore: true*/ root, "swf/dcr/hof_furni"),
@@ -77,6 +89,17 @@ function dirsFromNitroFilesRoot(root: string): FurniAssetDirs {
};
}
function dirsFromGamedataRoot(
root: string,
primarySwfDir: string,
): FurniAssetDirs {
return {
swfDir: primarySwfDir,
iconDir: path.join(/*turbopackIgnore: true*/ root, "icons"),
nitroDir: path.join(/*turbopackIgnore: true*/ root, "bundled/furniture"),
};
}
export async function getFurniAssetDirs(): Promise<FurniAssetDirs> {
const [swfDir, iconDir, nitroDir] = await Promise.all([
siteSettings
@@ -99,10 +122,16 @@ export async function getFurniAssetDirs(): Promise<FurniAssetDirs> {
export async function getFurniAssetWriteTargets(): Promise<FurniAssetWriteTargets> {
const primary = await getFurniAssetDirs();
const nitroFilesRoot = await getNitroFilesRoot();
const mirrorCandidates = nitroFilesRoot
? [dirsFromNitroFilesRoot(nitroFilesRoot)]
: [];
const [nitroFilesRoot, gamedataRoot] = await Promise.all([
getNitroFilesRoot(),
getGamedataRoot(),
]);
const mirrorCandidates = [
...(nitroFilesRoot ? [dirsFromNitroFilesRoot(nitroFilesRoot)] : []),
...(gamedataRoot
? [dirsFromGamedataRoot(gamedataRoot, primary.swfDir)]
: []),
];
const primaryKey = {
swfDir:
@@ -119,18 +148,26 @@ export async function getFurniAssetWriteTargets(): Promise<FurniAssetWriteTarget
: primary.nitroDir,
};
const seenCandidates = new Set<string>();
const mirrorDirs = mirrorCandidates.filter((dirs) => {
const swfDir = normalizeDir(dirs.swfDir, dirs.swfDir);
const iconDir = normalizeDir(dirs.iconDir, dirs.iconDir);
const nitroDir = normalizeDir(dirs.nitroDir, dirs.nitroDir);
return (
const isDifferent =
(process.platform === "win32" ? swfDir.toLowerCase() : swfDir) !==
primaryKey.swfDir ||
(process.platform === "win32" ? iconDir.toLowerCase() : iconDir) !==
primaryKey.iconDir ||
(process.platform === "win32" ? nitroDir.toLowerCase() : nitroDir) !==
primaryKey.nitroDir
);
primaryKey.nitroDir;
if (!isDifferent) return false;
const candidateKey = [swfDir, iconDir, nitroDir]
.map((dir) => (process.platform === "win32" ? dir.toLowerCase() : dir))
.join("\0");
if (seenCandidates.has(candidateKey)) return false;
seenCandidates.add(candidateKey);
return true;
});
return {
+63
View File
@@ -0,0 +1,63 @@
// @vitest-environment node
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { getGamedataRootMock, getNitroFilesRootMock, settingGetMock } =
vi.hoisted(() => ({
getGamedataRootMock: vi.fn(),
getNitroFilesRootMock: vi.fn(),
settingGetMock: vi.fn(),
}));
vi.mock("@/lib/services/furni-asset-dirs", () => ({
getGamedataRoot: getGamedataRootMock,
getNitroFilesRoot: getNitroFilesRootMock,
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { get: settingGetMock },
}));
import { writeFurniData } from "./furni-data";
describe("writeFurniData production mirror", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "furni-data-paths-"));
getNitroFilesRootMock.mockResolvedValue("");
getGamedataRootMock.mockResolvedValue(path.join(tempDir, "Gamedata"));
settingGetMock.mockImplementation(
async (key: string, fallback: string | null = null) => {
if (key === "furni_data_url") {
return path.join(tempDir, "cms", "FurnitureData.json");
}
if (key === "furni_data_mirror_path") return "";
return fallback;
},
);
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
vi.clearAllMocks();
});
it("writes metadata into the config directory served by production", async () => {
const data = { roomitemtypes: { furnitype: [{ id: 123 }] } };
await writeFurniData(data);
const livePath = path.join(
tempDir,
"Gamedata",
"config",
"FurnitureData.json",
);
await expect(fs.readFile(livePath, "utf-8")).resolves.toBe(
JSON.stringify(data),
);
});
});
+17 -3
View File
@@ -1,6 +1,9 @@
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { getNitroFilesRoot } from "@/lib/services/furni-asset-dirs";
import {
getGamedataRoot,
getNitroFilesRoot,
} from "@/lib/services/furni-asset-dirs";
import type { HabboItFurniEntry } from "@/lib/services/habbo-furnidata-cache";
import { siteSettings } from "@/lib/services/site-settings";
import type { FurniMetadata } from "@/lib/services/swf-to-nitro";
@@ -31,7 +34,10 @@ async function getFurnitureDataWritePaths(): Promise<string[]> {
const primary = await getFurnitureDataPath();
const configuredMirror =
(await siteSettings.get("furni_data_mirror_path", "")) ?? "";
const nitroFilesRoot = await getNitroFilesRoot();
const [nitroFilesRoot, gamedataRoot] = await Promise.all([
getNitroFilesRoot(),
getGamedataRoot(),
]);
const mirror = configuredMirror.trim()
? configuredMirror
: nitroFilesRoot
@@ -41,7 +47,15 @@ async function getFurnitureDataWritePaths(): Promise<string[]> {
)
: "";
const paths = [primary, mirror].filter(Boolean).map((p) => path.normalize(p));
const gamedataPath = gamedataRoot
? path.join(
/*turbopackIgnore: true*/ gamedataRoot,
"config/FurnitureData.json",
)
: "";
const paths = [primary, mirror, gamedataPath]
.filter(Boolean)
.map((p) => path.normalize(p));
const seen = new Set<string>();
return paths.filter((p) => {
const key = process.platform === "win32" ? p.toLowerCase() : p;
+139
View File
@@ -0,0 +1,139 @@
// @vitest-environment node
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
appendFurniEntryMock,
assetTargets,
dbExecuteMock,
getOrCreateCategoryPageMock,
} = vi.hoisted(() => ({
appendFurniEntryMock: vi.fn(),
assetTargets: {
swfDir: "",
iconDir: "",
nitroDir: "",
mirrorDirs: [] as Array<{
swfDir: string;
iconDir: string;
nitroDir: string;
}>,
},
dbExecuteMock: vi.fn(),
getOrCreateCategoryPageMock: vi.fn(),
}));
vi.mock("drizzle-orm", () => ({
eq: vi.fn(() => ({})),
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({
strings,
values,
}),
}));
vi.mock("@/lib/db", () => ({
ItemsBase: { id: "id", itemName: "item_name" },
db: {
execute: dbExecuteMock,
select: () => ({
from: () => ({
where: () => ({
limit: async () => [],
}),
}),
}),
},
}));
vi.mock("@/lib/services/furni-asset-dirs", () => ({
getFurniAssetDirs: vi.fn(async () => assetTargets),
getFurniAssetWriteTargets: vi.fn(async () => assetTargets),
}));
vi.mock("@/lib/services/furni-data", () => ({
appendFurniEntry: appendFurniEntryMock,
buildFurniEntry: vi.fn(() => ({ id: 501 })),
}));
vi.mock("@/lib/services/furni-import", () => ({
allocateCatalogItemId: vi.fn(
async (insertFn: (nextId: number) => Promise<unknown>) => insertFn(701),
),
autoPriceFurni: vi.fn(() => ({ credits: 3, points: 0, pointsType: 0 })),
ensureDirectories: vi.fn(async () => {
const dirs = [
assetTargets.swfDir,
assetTargets.iconDir,
assetTargets.nitroDir,
...assetTargets.mirrorDirs.flatMap((target) => [
target.swfDir,
target.iconDir,
target.nitroDir,
]),
];
await Promise.all(dirs.map((dir) => fs.mkdir(dir, { recursive: true })));
}),
getOrCreateCategoryPage: getOrCreateCategoryPageMock,
}));
vi.mock("@/lib/services/swf/nitro-builder", () => ({
parseNitroBundle: vi.fn(() => ({ json: { xdim: 1, ydim: 1 } })),
}));
import { uploadSingleFurni } from "./upload-import";
describe("uploadSingleFurni live asset mirrors", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "upload-furni-"));
assetTargets.swfDir = path.join(tempDir, "cms", "swf");
assetTargets.iconDir = path.join(tempDir, "cms", "icons");
assetTargets.nitroDir = path.join(tempDir, "cms", "furniture");
assetTargets.mirrorDirs = [
{
swfDir: assetTargets.swfDir,
iconDir: path.join(tempDir, "Gamedata", "icons"),
nitroDir: path.join(tempDir, "Gamedata", "bundled", "furniture"),
},
];
dbExecuteMock.mockReset();
dbExecuteMock
.mockResolvedValueOnce([[{ next: 501 }], undefined])
.mockResolvedValue(undefined);
getOrCreateCategoryPageMock.mockResolvedValue("imp_other");
appendFurniEntryMock.mockResolvedValue(undefined);
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
vi.clearAllMocks();
});
it("copies the Nitro bundle and icon into the directories served by production", async () => {
const nitroBuffer = Buffer.from("nitro-bundle");
const iconBuffer = Buffer.from("png-icon");
const result = await uploadSingleFurni({
classname: "chair",
name: "Chair",
itemType: "s",
nitroBuffer,
iconBuffer,
});
expect(result.ok).toBe(true);
await expect(
fs.readFile(
path.join(assetTargets.mirrorDirs[0].nitroDir, "chair.nitro"),
),
).resolves.toEqual(nitroBuffer);
await expect(
fs.readFile(
path.join(assetTargets.mirrorDirs[0].iconDir, "chair_icon.png"),
),
).resolves.toEqual(iconBuffer);
});
});
+54 -2
View File
@@ -2,7 +2,7 @@ import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { eq, sql } from "drizzle-orm";
import { db, ItemsBase } from "@/lib/db";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs";
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
import {
allocateCatalogItemId,
@@ -43,6 +43,36 @@ const MIGRATIONS_DIR = path.resolve(
let itemsBaseIdAllocChain: Promise<unknown> = Promise.resolve();
function pathKey(value: string): string {
const normalized = path.normalize(value);
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
}
async function mirrorUploadedAsset(
sourcePath: string,
fileName: string,
dirs: string[],
warnings: string[],
copiedPaths: string[],
): Promise<void> {
const sourceKey = pathKey(sourcePath);
const seen = new Set<string>();
for (const dir of dirs) {
const targetPath = path.join(/*turbopackIgnore: true*/ dir, fileName);
const targetKey = pathKey(targetPath);
if (targetKey === sourceKey || seen.has(targetKey)) continue;
seen.add(targetKey);
try {
await fs.copyFile(sourcePath, targetPath);
copiedPaths.push(targetPath);
} catch (err) {
warnings.push(
`asset mirror failed (${targetPath}): ${(err as Error).message}`,
);
}
}
}
async function allocateItemsBaseId<T>(
insertFn: (nextId: number) => Promise<T>,
): Promise<T> {
@@ -200,7 +230,8 @@ export async function uploadSingleFurni(params: {
}
await ensureDirectories();
const { iconDir, nitroDir } = await getFurniAssetDirs();
const assetTargets = await getFurniAssetWriteTargets();
const { iconDir, nitroDir } = assetTargets;
const nitroFileName = `${classname}.nitro`;
const nitroPath = path.join(
@@ -225,6 +256,24 @@ export async function uploadSingleFurni(params: {
await fs.writeFile(iconPath, iconBuffer);
}
const mirroredPaths: string[] = [];
await mirrorUploadedAsset(
nitroPath,
nitroFileName,
assetTargets.mirrorDirs.map((target) => target.nitroDir),
warnings,
mirroredPaths,
);
if (iconPath && iconFileName) {
await mirrorUploadedAsset(
iconPath,
iconFileName,
assetTargets.mirrorDirs.map((target) => target.iconDir),
warnings,
mirroredPaths,
);
}
const xdim = userXdim ?? Number(nitroMeta?.xdim ?? 1);
const ydim = userYdim ?? Number(nitroMeta?.ydim ?? 1);
@@ -248,6 +297,9 @@ export async function uploadSingleFurni(params: {
} catch (err) {
await fs.unlink(nitroPath).catch(() => {});
if (iconPath) await fs.unlink(iconPath).catch(() => {});
await Promise.all(
mirroredPaths.map((file) => fs.unlink(file).catch(() => {})),
);
return {
ok: false,
classname,