Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6de450a9d | ||
|
|
86cd43def9 | ||
|
|
01570874a8 | ||
|
|
a81af2d71a | ||
|
|
44b4b3b45c | ||
|
|
b3245a18ea | ||
|
|
a57c73055f | ||
|
|
bfc951cfd9 | ||
|
|
828fda63e7 |
No files matched your search
+35
-34
@@ -70,16 +70,24 @@ jobs:
|
|||||||
error_handler() {
|
error_handler() {
|
||||||
cd /var/www/atom-nexst 2>/dev/null || cd / || true
|
cd /var/www/atom-nexst 2>/dev/null || cd / || true
|
||||||
echo "!!! DEPLOYMENT FAILED on line $1 !!!" >&2
|
echo "!!! DEPLOYMENT FAILED on line $1 !!!" >&2
|
||||||
# Roll back the build artifact if cutover already moved .next into place.
|
# Leave the healthy current process untouched when staging fails.
|
||||||
if [ "${CUTOVER_STARTED}" = "1" ] && [ -d "${LIVE}/.next.prev" ]; then
|
# Restart only when cutover has already stopped or replaced it.
|
||||||
echo "Rolling back .next to previous artifact..." >&2
|
if [ "${CUTOVER_STARTED}" = "1" ]; then
|
||||||
rm -rf "${LIVE}/.next" || true
|
if [ -d "${LIVE}/.next.prev" ]; then
|
||||||
mv "${LIVE}/.next.prev" "${LIVE}/.next" || true
|
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
|
fi
|
||||||
if [ -n "${STAGE}" ] && [ -d "${STAGE}" ]; then
|
if [ -n "${STAGE}" ] && [ -d "${STAGE}" ]; then
|
||||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||||
fi
|
fi
|
||||||
pm2 restart next --update-env 2>/dev/null || pm2 start pnpm --name "next" -- start 2>/dev/null || true
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
trap 'error_handler $LINENO' ERR
|
trap 'error_handler $LINENO' ERR
|
||||||
@@ -150,14 +158,7 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Cutover: stop service (free DB connections), migrate, swap .next..."
|
echo "Migrating staged release while the current app stays online..."
|
||||||
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.
|
|
||||||
cd "${STAGE}"
|
cd "${STAGE}"
|
||||||
MIGRATE_OK=0
|
MIGRATE_OK=0
|
||||||
for i in $(seq 1 10); do
|
for i in $(seq 1 10); do
|
||||||
@@ -179,7 +180,8 @@ jobs:
|
|||||||
# Keep env, uploads, and deps we are about to replace from stage.
|
# Keep env, uploads, and deps we are about to replace from stage.
|
||||||
git clean -fd \
|
git clean -fd \
|
||||||
-e .env -e .env.local -e .env.production -e .env*.local \
|
-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
|
if ! git diff --exit-code HEAD -- src >/dev/null; then
|
||||||
echo "ERROR: live src/ still differs from HEAD after reset:" >&2
|
echo "ERROR: live src/ still differs from HEAD after reset:" >&2
|
||||||
@@ -188,18 +190,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "Verified live src/ matches HEAD"
|
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
|
# 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.
|
# have PORT=3000 from an older start, while .env (and nginx) expect 3002.
|
||||||
# Export PORT before start so the process matches health checks.
|
# Export PORT before start so the process matches health checks.
|
||||||
@@ -220,14 +210,25 @@ jobs:
|
|||||||
fi
|
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}"
|
cd "${LIVE}"
|
||||||
# Cutover already stopped the app, but failed deploys can leave orphans
|
|
||||||
# on 3002 (or an old PM2 env still bound to 3000).
|
# Rename both current artifacts so cutover and rollback stay fast.
|
||||||
pm2 stop next --kill-timeout 10000 2>/dev/null || true
|
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 "${PORT}"
|
||||||
free_tcp_port 3000
|
free_tcp_port 3000
|
||||||
sleep 1
|
echo "Starting PM2 with a clean PORT=${PORT} listener..."
|
||||||
pm2 delete next 2>/dev/null || true
|
pm2 delete next 2>/dev/null || true
|
||||||
PORT="${PORT}" pm2 start pnpm --name next -- start
|
PORT="${PORT}" pm2 start pnpm --name next -- start
|
||||||
pm2 save 2>/dev/null || true
|
pm2 save 2>/dev/null || true
|
||||||
@@ -262,8 +263,8 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Cleaning stage worktree and previous .next backup..."
|
echo "Cleaning stage worktree and previous artifact backups..."
|
||||||
rm -rf "${LIVE}/.next.prev"
|
rm -rf "${LIVE}/.next.prev" "${LIVE}/node_modules.prev"
|
||||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||||
STAGE=""
|
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.
|
||||||
+1
-1
@@ -93,7 +93,7 @@
|
|||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"postcss": "^8.5.25",
|
"postcss": "^8.5.25",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"tsx": "^4.23.1",
|
"tsx": "^4.23.2",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vite": "8.2.0",
|
"vite": "8.2.0",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
|
|||||||
Generated
+16
-16
@@ -190,17 +190,17 @@ importers:
|
|||||||
specifier: ^4.3.3
|
specifier: ^4.3.3
|
||||||
version: 4.3.3
|
version: 4.3.3
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.23.1
|
specifier: ^4.23.2
|
||||||
version: 4.23.1
|
version: 4.23.2
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^7.0.2
|
specifier: ^7.0.2
|
||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
vite:
|
vite:
|
||||||
specifier: 8.2.0
|
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:
|
vitest:
|
||||||
specifier: 4.1.10
|
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:
|
packages:
|
||||||
|
|
||||||
@@ -3924,8 +3924,8 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
[email protected].1:
|
[email protected].2:
|
||||||
resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==}
|
resolution: {integrity: sha512-Cj3EO8dIn8hXjlSFh/dnJOKAFNE52YMw4rZX0HqlnIdcBoombYNd1Z0ALUpbaLilH6fdDpYqs9GZtsyfkfBjPg==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -5806,7 +5806,7 @@ snapshots:
|
|||||||
obug: 2.1.4
|
obug: 2.1.4
|
||||||
std-env: 4.2.0
|
std-env: 4.2.0
|
||||||
tinyrainbow: 3.1.1
|
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]':
|
'@vitest/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5817,13 +5817,13 @@ snapshots:
|
|||||||
chai: 6.2.2
|
chai: 6.2.2
|
||||||
tinyrainbow: 3.1.1
|
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:
|
dependencies:
|
||||||
'@vitest/spy': 4.1.10
|
'@vitest/spy': 4.1.10
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
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]':
|
'@vitest/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6091,7 +6091,7 @@ snapshots:
|
|||||||
'@drizzle-team/brocli': 0.10.2
|
'@drizzle-team/brocli': 0.10.2
|
||||||
'@esbuild-kit/esm-loader': 2.6.5
|
'@esbuild-kit/esm-loader': 2.6.5
|
||||||
esbuild: 0.25.12
|
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])):
|
[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:
|
optionalDependencies:
|
||||||
@@ -7274,7 +7274,7 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected].1:
|
[email protected].2:
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.28.1
|
esbuild: 0.28.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -7345,7 +7345,7 @@ snapshots:
|
|||||||
typescript: 7.0.2
|
typescript: 7.0.2
|
||||||
optional: true
|
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:
|
dependencies:
|
||||||
lightningcss: 1.33.0
|
lightningcss: 1.33.0
|
||||||
picomatch: 4.0.5
|
picomatch: 4.0.5
|
||||||
@@ -7358,13 +7358,13 @@ snapshots:
|
|||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.7.0
|
jiti: 2.7.0
|
||||||
terser: 5.49.0
|
terser: 5.49.0
|
||||||
tsx: 4.23.1
|
tsx: 4.23.2
|
||||||
yaml: 2.9.0
|
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:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.10
|
'@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/pretty-format': 4.1.10
|
||||||
'@vitest/runner': 4.1.10
|
'@vitest/runner': 4.1.10
|
||||||
'@vitest/snapshot': 4.1.10
|
'@vitest/snapshot': 4.1.10
|
||||||
@@ -7381,7 +7381,7 @@ snapshots:
|
|||||||
tinyexec: 1.2.4
|
tinyexec: 1.2.4
|
||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
tinyrainbow: 3.1.1
|
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
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.1
|
'@opentelemetry/api': 1.9.1
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ import {
|
|||||||
parseAdminNavConfig,
|
parseAdminNavConfig,
|
||||||
} from "@/lib/admin-nav-config";
|
} from "@/lib/admin-nav-config";
|
||||||
import { db, User } from "@/lib/db";
|
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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
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.
|
// statically rendered. Child admin pages inherit this — leaf force-dynamic is redundant.
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -31,12 +31,7 @@ export default async function AdminLayout({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
let csrfToken = "";
|
const csrfToken = await readCsrfCookieToken();
|
||||||
try {
|
|
||||||
csrfToken = await setCsrfCookie();
|
|
||||||
} catch {
|
|
||||||
csrfToken = "";
|
|
||||||
}
|
|
||||||
if (await siteSettings.getBool("force_staff_2fa", false)) {
|
if (await siteSettings.getBool("force_staff_2fa", false)) {
|
||||||
const u = await db
|
const u = await db
|
||||||
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
|
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
|
||||||
|
|||||||
@@ -148,6 +148,14 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
|
|||||||
type: "text",
|
type: "text",
|
||||||
placeholder: "E:\\path\\to\\Nitro-Files",
|
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",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -17,7 +17,7 @@ import { LanguageSwitcher } from "@/components/language-switcher";
|
|||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { requireMod } from "@/lib/admin/guard";
|
import { requireMod } from "@/lib/admin/guard";
|
||||||
import { db, User } from "@/lib/db";
|
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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
@@ -25,12 +25,7 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export default async function ModLayout({ children }: { children: ReactNode }) {
|
export default async function ModLayout({ children }: { children: ReactNode }) {
|
||||||
const staff = await requireMod();
|
const staff = await requireMod();
|
||||||
let csrfToken = "";
|
const csrfToken = await readCsrfCookieToken();
|
||||||
try {
|
|
||||||
csrfToken = await setCsrfCookie();
|
|
||||||
} catch {
|
|
||||||
csrfToken = "";
|
|
||||||
}
|
|
||||||
if (await siteSettings.getBool("force_staff_2fa", false)) {
|
if (await siteSettings.getBool("force_staff_2fa", false)) {
|
||||||
const [u] = await db
|
const [u] = await db
|
||||||
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
|
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
CaptchaWidget,
|
CaptchaWidget,
|
||||||
readCaptchaToken,
|
readCaptchaToken,
|
||||||
} from "@/components/auth/captcha-widget";
|
} from "@/components/auth/captcha-widget";
|
||||||
|
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
||||||
|
|
||||||
export function HomeLoginForm({
|
export function HomeLoginForm({
|
||||||
captcha = { provider: "none" },
|
captcha = { provider: "none" },
|
||||||
@@ -49,12 +50,14 @@ export function HomeLoginForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const res = await signIn("credentials", {
|
const res = await signInWithTransientRetry(() =>
|
||||||
username,
|
signIn("credentials", {
|
||||||
password,
|
username,
|
||||||
code,
|
password,
|
||||||
redirect: false,
|
code,
|
||||||
});
|
redirect: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
if (!res || res.error) {
|
if (!res || res.error) {
|
||||||
setError(
|
setError(
|
||||||
needs2fa ? "Invalid 2FA code" : "Invalid username or password",
|
needs2fa ? "Invalid 2FA code" : "Invalid username or password",
|
||||||
@@ -62,6 +65,8 @@ export function HomeLoginForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
|
} catch {
|
||||||
|
setError(needs2fa ? "Invalid 2FA code" : "Invalid username or password");
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CaptchaWidget,
|
CaptchaWidget,
|
||||||
readCaptchaToken,
|
readCaptchaToken,
|
||||||
} from "@/components/auth/captcha-widget";
|
} from "@/components/auth/captcha-widget";
|
||||||
|
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
||||||
|
|
||||||
export function LoginForm({
|
export function LoginForm({
|
||||||
captcha = { provider: "none" },
|
captcha = { provider: "none" },
|
||||||
@@ -52,12 +53,14 @@ export function LoginForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const res = await signIn("credentials", {
|
const res = await signInWithTransientRetry(() =>
|
||||||
username,
|
signIn("credentials", {
|
||||||
password,
|
username,
|
||||||
code,
|
password,
|
||||||
redirect: false,
|
code,
|
||||||
});
|
redirect: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
if (!res || res.error) {
|
if (!res || res.error) {
|
||||||
setError(
|
setError(
|
||||||
needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"),
|
needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"),
|
||||||
@@ -65,6 +68,8 @@ export function LoginForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.href = "/me";
|
window.location.href = "/me";
|
||||||
|
} catch {
|
||||||
|
setError(needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"));
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-24
@@ -1,31 +1,93 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { resolve } from "node:path";
|
import { adminFetch } from "./admin-fetch";
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
describe("admin CSRF wiring", () => {
|
const TOKEN = "a".repeat(64);
|
||||||
it("defaults CSRF on for mutating withAdmin handlers", () => {
|
const REFRESHED_TOKEN = "b".repeat(64);
|
||||||
const source = readFileSync(
|
|
||||||
resolve(process.cwd(), "src/lib/api-handler.ts"),
|
function installDocument(initialToken: string | null) {
|
||||||
"utf8",
|
let token = initialToken;
|
||||||
);
|
const meta = {
|
||||||
expect(source).toContain("options.requireCsrf !== false");
|
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", () => {
|
afterEach(() => {
|
||||||
const source = readFileSync(
|
vi.unstubAllGlobals();
|
||||||
resolve(process.cwd(), "src/app/admin/layout.tsx"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
expect(source).toContain("setCsrfCookie");
|
|
||||||
expect(source).toContain('meta name="csrf-token"');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("provides adminFetch helper that sets x-csrf-token", () => {
|
it("bootstraps a missing CSRF token before a mutating request", async () => {
|
||||||
const source = readFileSync(
|
installDocument(null);
|
||||||
resolve(process.cwd(), "src/lib/admin-fetch.ts"),
|
const fetchMock = vi
|
||||||
"utf8",
|
.fn<typeof fetch>()
|
||||||
);
|
.mockResolvedValueOnce(jsonResponse({ ok: true, token: TOKEN }))
|
||||||
expect(source).toContain("x-csrf-token");
|
.mockResolvedValueOnce(jsonResponse({ ok: true }));
|
||||||
expect(source).toContain("getCsrfToken");
|
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
@@ -1,4 +1,9 @@
|
|||||||
const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
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">`. */
|
/** Read the CSRF token injected by the admin layout `<meta name="csrf-token">`. */
|
||||||
export function getCsrfToken(): string | null {
|
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.
|
* Same-origin fetch for admin APIs. Attaches `x-csrf-token` on mutating methods.
|
||||||
*/
|
*/
|
||||||
export function adminFetch(
|
export async function adminFetch(
|
||||||
input: RequestInfo | URL,
|
input: RequestInfo | URL,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const method = (init?.method ?? "GET").toUpperCase();
|
const method = (init?.method ?? "GET").toUpperCase();
|
||||||
const headers = new Headers(init?.headers);
|
const headers = new Headers(init?.headers);
|
||||||
if (MUTATING.has(method)) {
|
const mutating = MUTATING.has(method);
|
||||||
const token = getCsrfToken();
|
if (mutating) {
|
||||||
if (token) headers.set("x-csrf-token", token);
|
const token = getCsrfToken() ?? (await bootstrapCsrfToken());
|
||||||
|
headers.set("x-csrf-token", token);
|
||||||
}
|
}
|
||||||
return fetch(input, {
|
|
||||||
|
const requestInit = {
|
||||||
...init,
|
...init,
|
||||||
headers,
|
headers,
|
||||||
credentials: init?.credentials ?? "same-origin",
|
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 });
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ const liveVar = "${" + "LIVE}";
|
|||||||
const stageVar = "${" + "STAGE}";
|
const stageVar = "${" + "STAGE}";
|
||||||
const userVar = "${" + "DEPLOY_USER}";
|
const userVar = "${" + "DEPLOY_USER}";
|
||||||
const groupVar = "${" + "DEPLOY_GROUP}";
|
const groupVar = "${" + "DEPLOY_GROUP}";
|
||||||
|
const cutoverStartedVar = "${" + "CUTOVER_STARTED}";
|
||||||
|
|
||||||
function toContainLiteral(workflow: string, literal: string) {
|
function toContainLiteral(workflow: string, literal: string) {
|
||||||
return workflow.indexOf(literal) >= 0;
|
return workflow.indexOf(literal) >= 0;
|
||||||
@@ -66,7 +67,7 @@ describe("production deploy workflow", () => {
|
|||||||
expect(deployJob).toContain("pnpm typecheck");
|
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(deployJob).toContain("mv .next .next.prev");
|
||||||
expect(toContainLiteral(deployJob, `mv "${stageVar}/.next" .next`)).toBe(
|
expect(toContainLiteral(deployJob, `mv "${stageVar}/.next" .next`)).toBe(
|
||||||
true,
|
true,
|
||||||
@@ -74,20 +75,47 @@ describe("production deploy workflow", () => {
|
|||||||
expect(
|
expect(
|
||||||
toContainLiteral(deployJob, `mv "${stageVar}/node_modules" node_modules`),
|
toContainLiteral(deployJob, `mv "${stageVar}/node_modules" node_modules`),
|
||||||
).toBe(true);
|
).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 .next to previous artifact");
|
||||||
|
expect(deployJob).toContain(
|
||||||
|
"Rolling back node_modules to previous artifact",
|
||||||
|
);
|
||||||
const buildAt = deployJob.indexOf("pnpm build");
|
const buildAt = deployJob.indexOf("pnpm build");
|
||||||
const stopAt = deployJob.indexOf("pm2 stop next");
|
const stopAt = deployJob.indexOf("pm2 stop next");
|
||||||
const migrateAt = deployJob.indexOf("pnpm db:migrate");
|
const migrateAt = deployJob.indexOf("pnpm db:migrate");
|
||||||
|
const resetAt = deployJob.indexOf("git reset --hard origin/main");
|
||||||
const startLabelAt = deployJob.indexOf("Starting PM2");
|
const startLabelAt = deployJob.indexOf("Starting PM2");
|
||||||
const startAt = deployJob.indexOf(
|
const startAt = deployJob.indexOf(
|
||||||
"pm2 start pnpm --name next -- start",
|
"pm2 start pnpm --name next -- start",
|
||||||
startLabelAt,
|
startLabelAt,
|
||||||
);
|
);
|
||||||
expect(buildAt).toBeGreaterThan(-1);
|
expect(buildAt).toBeGreaterThan(-1);
|
||||||
expect(stopAt).toBeGreaterThan(buildAt);
|
expect(migrateAt).toBeGreaterThan(buildAt);
|
||||||
expect(migrateAt).toBeGreaterThan(stopAt);
|
expect(resetAt).toBeGreaterThan(migrateAt);
|
||||||
expect(startLabelAt).toBeGreaterThan(migrateAt);
|
expect(stopAt).toBeGreaterThan(resetAt);
|
||||||
|
expect(startLabelAt).toBeGreaterThan(stopAt);
|
||||||
expect(startAt).toBeGreaterThan(startLabelAt);
|
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)", () => {
|
it("does not override onlyBuiltDependencies (uses pnpm-workspace.yaml)", () => {
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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). */
|
/** Sets the CSRF cookie when possible; returns token or empty string (never throws). */
|
||||||
export async function setCsrfCookie(): Promise<string> {
|
export async function setCsrfCookie(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,8 @@ export const DEFAULT_NITRO_FILES_ROOT = path.join(
|
|||||||
"DEV",
|
"DEV",
|
||||||
"Nitro-Files",
|
"Nitro-Files",
|
||||||
);
|
);
|
||||||
|
export const DEFAULT_GAMEDATA_ROOT =
|
||||||
|
process.platform === "win32" ? "" : "/var/www/Gamedata";
|
||||||
export const DEFAULT_FURNI_ICON_DIR = path.join(
|
export const DEFAULT_FURNI_ICON_DIR = path.join(
|
||||||
/*turbopackIgnore: true*/ process.cwd(),
|
/*turbopackIgnore: true*/ process.cwd(),
|
||||||
"public/swf/dcr/hof_furni/icons",
|
"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 {
|
function dirsFromNitroFilesRoot(root: string): FurniAssetDirs {
|
||||||
return {
|
return {
|
||||||
swfDir: path.join(/*turbopackIgnore: true*/ root, "swf/dcr/hof_furni"),
|
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> {
|
export async function getFurniAssetDirs(): Promise<FurniAssetDirs> {
|
||||||
const [swfDir, iconDir, nitroDir] = await Promise.all([
|
const [swfDir, iconDir, nitroDir] = await Promise.all([
|
||||||
siteSettings
|
siteSettings
|
||||||
@@ -99,10 +122,16 @@ export async function getFurniAssetDirs(): Promise<FurniAssetDirs> {
|
|||||||
|
|
||||||
export async function getFurniAssetWriteTargets(): Promise<FurniAssetWriteTargets> {
|
export async function getFurniAssetWriteTargets(): Promise<FurniAssetWriteTargets> {
|
||||||
const primary = await getFurniAssetDirs();
|
const primary = await getFurniAssetDirs();
|
||||||
const nitroFilesRoot = await getNitroFilesRoot();
|
const [nitroFilesRoot, gamedataRoot] = await Promise.all([
|
||||||
const mirrorCandidates = nitroFilesRoot
|
getNitroFilesRoot(),
|
||||||
? [dirsFromNitroFilesRoot(nitroFilesRoot)]
|
getGamedataRoot(),
|
||||||
: [];
|
]);
|
||||||
|
const mirrorCandidates = [
|
||||||
|
...(nitroFilesRoot ? [dirsFromNitroFilesRoot(nitroFilesRoot)] : []),
|
||||||
|
...(gamedataRoot
|
||||||
|
? [dirsFromGamedataRoot(gamedataRoot, primary.swfDir)]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
const primaryKey = {
|
const primaryKey = {
|
||||||
swfDir:
|
swfDir:
|
||||||
@@ -119,18 +148,26 @@ export async function getFurniAssetWriteTargets(): Promise<FurniAssetWriteTarget
|
|||||||
: primary.nitroDir,
|
: primary.nitroDir,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const seenCandidates = new Set<string>();
|
||||||
const mirrorDirs = mirrorCandidates.filter((dirs) => {
|
const mirrorDirs = mirrorCandidates.filter((dirs) => {
|
||||||
const swfDir = normalizeDir(dirs.swfDir, dirs.swfDir);
|
const swfDir = normalizeDir(dirs.swfDir, dirs.swfDir);
|
||||||
const iconDir = normalizeDir(dirs.iconDir, dirs.iconDir);
|
const iconDir = normalizeDir(dirs.iconDir, dirs.iconDir);
|
||||||
const nitroDir = normalizeDir(dirs.nitroDir, dirs.nitroDir);
|
const nitroDir = normalizeDir(dirs.nitroDir, dirs.nitroDir);
|
||||||
return (
|
const isDifferent =
|
||||||
(process.platform === "win32" ? swfDir.toLowerCase() : swfDir) !==
|
(process.platform === "win32" ? swfDir.toLowerCase() : swfDir) !==
|
||||||
primaryKey.swfDir ||
|
primaryKey.swfDir ||
|
||||||
(process.platform === "win32" ? iconDir.toLowerCase() : iconDir) !==
|
(process.platform === "win32" ? iconDir.toLowerCase() : iconDir) !==
|
||||||
primaryKey.iconDir ||
|
primaryKey.iconDir ||
|
||||||
(process.platform === "win32" ? nitroDir.toLowerCase() : nitroDir) !==
|
(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 {
|
return {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { existsSync, promises as fs } from "node:fs";
|
import { existsSync, promises as fs } from "node:fs";
|
||||||
import path from "node:path";
|
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 type { HabboItFurniEntry } from "@/lib/services/habbo-furnidata-cache";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import type { FurniMetadata } from "@/lib/services/swf-to-nitro";
|
import type { FurniMetadata } from "@/lib/services/swf-to-nitro";
|
||||||
@@ -31,7 +34,10 @@ async function getFurnitureDataWritePaths(): Promise<string[]> {
|
|||||||
const primary = await getFurnitureDataPath();
|
const primary = await getFurnitureDataPath();
|
||||||
const configuredMirror =
|
const configuredMirror =
|
||||||
(await siteSettings.get("furni_data_mirror_path", "")) ?? "";
|
(await siteSettings.get("furni_data_mirror_path", "")) ?? "";
|
||||||
const nitroFilesRoot = await getNitroFilesRoot();
|
const [nitroFilesRoot, gamedataRoot] = await Promise.all([
|
||||||
|
getNitroFilesRoot(),
|
||||||
|
getGamedataRoot(),
|
||||||
|
]);
|
||||||
const mirror = configuredMirror.trim()
|
const mirror = configuredMirror.trim()
|
||||||
? configuredMirror
|
? configuredMirror
|
||||||
: nitroFilesRoot
|
: 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>();
|
const seen = new Set<string>();
|
||||||
return paths.filter((p) => {
|
return paths.filter((p) => {
|
||||||
const key = process.platform === "win32" ? p.toLowerCase() : p;
|
const key = process.platform === "win32" ? p.toLowerCase() : p;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { existsSync, promises as fs } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { db, ItemsBase } from "@/lib/db";
|
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 { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
|
||||||
import {
|
import {
|
||||||
allocateCatalogItemId,
|
allocateCatalogItemId,
|
||||||
@@ -43,6 +43,36 @@ const MIGRATIONS_DIR = path.resolve(
|
|||||||
|
|
||||||
let itemsBaseIdAllocChain: Promise<unknown> = Promise.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>(
|
async function allocateItemsBaseId<T>(
|
||||||
insertFn: (nextId: number) => Promise<T>,
|
insertFn: (nextId: number) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
@@ -200,7 +230,8 @@ export async function uploadSingleFurni(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ensureDirectories();
|
await ensureDirectories();
|
||||||
const { iconDir, nitroDir } = await getFurniAssetDirs();
|
const assetTargets = await getFurniAssetWriteTargets();
|
||||||
|
const { iconDir, nitroDir } = assetTargets;
|
||||||
|
|
||||||
const nitroFileName = `${classname}.nitro`;
|
const nitroFileName = `${classname}.nitro`;
|
||||||
const nitroPath = path.join(
|
const nitroPath = path.join(
|
||||||
@@ -225,6 +256,24 @@ export async function uploadSingleFurni(params: {
|
|||||||
await fs.writeFile(iconPath, iconBuffer);
|
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 xdim = userXdim ?? Number(nitroMeta?.xdim ?? 1);
|
||||||
const ydim = userYdim ?? Number(nitroMeta?.ydim ?? 1);
|
const ydim = userYdim ?? Number(nitroMeta?.ydim ?? 1);
|
||||||
|
|
||||||
@@ -248,6 +297,9 @@ export async function uploadSingleFurni(params: {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
await fs.unlink(nitroPath).catch(() => {});
|
await fs.unlink(nitroPath).catch(() => {});
|
||||||
if (iconPath) await fs.unlink(iconPath).catch(() => {});
|
if (iconPath) await fs.unlink(iconPath).catch(() => {});
|
||||||
|
await Promise.all(
|
||||||
|
mirroredPaths.map((file) => fs.unlink(file).catch(() => {})),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
classname,
|
classname,
|
||||||
|
|||||||
Reference in new issue
Block a user