1 Commits
Author SHA1 Message Date
remco 5fc3dc93ce chore(deps): update All dependencies
CI / check (pull_request) Successful in 23s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-08-02 07:00:42 +00:00
25 changed files with 124 additions and 1041 deletions

No files matched your search

+34 -35
View File
@@ -70,24 +70,16 @@ jobs:
error_handler() {
cd /var/www/atom-nexst 2>/dev/null || cd / || true
echo "!!! DEPLOYMENT FAILED on line $1 !!!" >&2
# 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
# 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
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
@@ -158,7 +150,14 @@ jobs:
exit 1
fi
echo "Migrating staged release while the current app stays online..."
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.
cd "${STAGE}"
MIGRATE_OK=0
for i in $(seq 1 10); do
@@ -180,8 +179,7 @@ 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 node_modules.prev -e .next -e .next.prev
-e storage -e public/cache -e node_modules -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
@@ -190,6 +188,18 @@ 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.
@@ -210,25 +220,14 @@ jobs:
fi
}
echo "Cutover: atomically swap artifacts and restart on PORT=${PORT}..."
CUTOVER_STARTED=1
pm2 stop next --kill-timeout 10000 || true
echo "Starting PM2 with a clean PORT=${PORT} listener..."
cd "${LIVE}"
# 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
# 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
free_tcp_port "${PORT}"
free_tcp_port 3000
echo "Starting PM2 with a clean PORT=${PORT} listener..."
sleep 1
pm2 delete next 2>/dev/null || true
PORT="${PORT}" pm2 start pnpm --name next -- start
pm2 save 2>/dev/null || true
@@ -263,8 +262,8 @@ jobs:
exit 1
fi
echo "Cleaning stage worktree and previous artifact backups..."
rm -rf "${LIVE}/.next.prev" "${LIVE}/node_modules.prev"
echo "Cleaning stage worktree and previous .next backup..."
rm -rf "${LIVE}/.next.prev"
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
STAGE=""
@@ -1,126 +0,0 @@
# 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
```
@@ -1,55 +0,0 @@
# 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
View File
@@ -93,7 +93,7 @@
"pino-pretty": "^13.1.3",
"postcss": "^8.5.25",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.2",
"tsx": "^4.23.1",
"typescript": "^7.0.2",
"vite": "8.2.0",
"vitest": "4.1.10"
+16 -16
View File
@@ -190,17 +190,17 @@ importers:
specifier: ^4.3.3
version: 4.3.3
tsx:
specifier: ^4.23.2
version: 4.23.2
specifier: ^4.23.1
version: 4.23.1
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].2)([email protected])
version: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].1)([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].2)([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].1)([email protected]))
packages:
@@ -3924,8 +3924,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
[email protected].2:
resolution: {integrity: sha512-Cj3EO8dIn8hXjlSFh/dnJOKAFNE52YMw4rZX0HqlnIdcBoombYNd1Z0ALUpbaLilH6fdDpYqs9GZtsyfkfBjPg==}
[email protected].1:
resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==}
engines: {node: '>=18.0.0'}
hasBin: true
@@ -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].2)([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].1)([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].2)([email protected]))':
'@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].1)([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].2)([email protected])
vite: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].1)([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.2
tsx: 4.23.1
[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:
@@ -7274,7 +7274,7 @@ snapshots:
[email protected]: {}
[email protected].2:
[email protected].1:
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].2)([email protected]):
[email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].1)([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.2
tsx: 4.23.1
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].2)([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].1)([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].2)([email protected]))
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].1)([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].2)([email protected])
vite: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].1)([email protected])
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
+8 -3
View File
@@ -17,11 +17,11 @@ import {
parseAdminNavConfig,
} from "@/lib/admin-nav-config";
import { db, User } from "@/lib/db";
import { readCsrfCookieToken } from "@/lib/foundation/security";
import { setCsrfCookie } from "@/lib/foundation/security";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
// Request-time auth, existing CSRF cookie, and optional 2FA gate cannot be
// Request-time auth: requireStaff, 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,7 +31,12 @@ export default async function AdminLayout({
children: ReactNode;
}) {
const staff = await requireStaff();
const csrfToken = await readCsrfCookieToken();
let csrfToken = "";
try {
csrfToken = await setCsrfCookie();
} catch {
csrfToken = "";
}
if (await siteSettings.getBool("force_staff_2fa", false)) {
const u = await db
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
@@ -148,14 +148,6 @@ 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
@@ -1,51 +0,0 @@
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
@@ -1,12 +0,0 @@
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;
});
+7 -2
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 { readCsrfCookieToken } from "@/lib/foundation/security";
import { setCsrfCookie } from "@/lib/foundation/security";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
@@ -25,7 +25,12 @@ export const dynamic = "force-dynamic";
export default async function ModLayout({ children }: { children: ReactNode }) {
const staff = await requireMod();
const csrfToken = await readCsrfCookieToken();
let csrfToken = "";
try {
csrfToken = await setCsrfCookie();
} catch {
csrfToken = "";
}
if (await siteSettings.getBool("force_staff_2fa", false)) {
const [u] = await db
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
+6 -11
View File
@@ -8,7 +8,6 @@ import {
CaptchaWidget,
readCaptchaToken,
} from "@/components/auth/captcha-widget";
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
export function HomeLoginForm({
captcha = { provider: "none" },
@@ -50,14 +49,12 @@ export function HomeLoginForm({
return;
}
}
const res = await signInWithTransientRetry(() =>
signIn("credentials", {
username,
password,
code,
redirect: false,
}),
);
const res = await signIn("credentials", {
username,
password,
code,
redirect: false,
});
if (!res || res.error) {
setError(
needs2fa ? "Invalid 2FA code" : "Invalid username or password",
@@ -65,8 +62,6 @@ export function HomeLoginForm({
return;
}
window.location.href = "/";
} catch {
setError(needs2fa ? "Invalid 2FA code" : "Invalid username or password");
} finally {
setPending(false);
}
+6 -11
View File
@@ -10,7 +10,6 @@ import {
CaptchaWidget,
readCaptchaToken,
} from "@/components/auth/captcha-widget";
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
export function LoginForm({
captcha = { provider: "none" },
@@ -53,14 +52,12 @@ export function LoginForm({
return;
}
}
const res = await signInWithTransientRetry(() =>
signIn("credentials", {
username,
password,
code,
redirect: false,
}),
);
const res = await signIn("credentials", {
username,
password,
code,
redirect: false,
});
if (!res || res.error) {
setError(
needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"),
@@ -68,8 +65,6 @@ export function LoginForm({
return;
}
window.location.href = "/me";
} catch {
setError(needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"));
} finally {
setPending(false);
}
+24 -86
View File
@@ -1,93 +1,31 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { adminFetch } from "./admin-fetch";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
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();
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");
});
afterEach(() => {
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"');
});
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");
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");
});
});
+6 -65
View File
@@ -1,9 +1,4 @@
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 {
@@ -15,76 +10,22 @@ 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 async function adminFetch(
export function adminFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
const method = (init?.method ?? "GET").toUpperCase();
const headers = new Headers(init?.headers);
const mutating = MUTATING.has(method);
if (mutating) {
const token = getCsrfToken() ?? (await bootstrapCsrfToken());
headers.set("x-csrf-token", token);
if (MUTATING.has(method)) {
const token = getCsrfToken();
if (token) headers.set("x-csrf-token", token);
}
const requestInit = {
return fetch(input, {
...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
@@ -1,47 +0,0 @@
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
@@ -1,25 +0,0 @@
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");
}
+4 -32
View File
@@ -6,7 +6,6 @@ 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;
@@ -67,7 +66,7 @@ describe("production deploy workflow", () => {
expect(deployJob).toContain("pnpm typecheck");
});
it("migrates while the current app is online, then swaps the built artifact", () => {
it("swaps a built .next artifact during a short service cutover", () => {
expect(deployJob).toContain("mv .next .next.prev");
expect(toContainLiteral(deployJob, `mv "${stageVar}/.next" .next`)).toBe(
true,
@@ -75,47 +74,20 @@ 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(migrateAt).toBeGreaterThan(buildAt);
expect(resetAt).toBeGreaterThan(migrateAt);
expect(stopAt).toBeGreaterThan(resetAt);
expect(startLabelAt).toBeGreaterThan(stopAt);
expect(stopAt).toBeGreaterThan(buildAt);
expect(migrateAt).toBeGreaterThan(stopAt);
expect(startLabelAt).toBeGreaterThan(migrateAt);
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
@@ -1,41 +0,0 @@
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,16 +139,6 @@ 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
@@ -1,87 +0,0 @@
// @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 -44
View File
@@ -7,8 +7,6 @@ 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",
@@ -65,16 +63,6 @@ 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"),
@@ -89,17 +77,6 @@ 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
@@ -122,16 +99,10 @@ export async function getFurniAssetDirs(): Promise<FurniAssetDirs> {
export async function getFurniAssetWriteTargets(): Promise<FurniAssetWriteTargets> {
const primary = await getFurniAssetDirs();
const [nitroFilesRoot, gamedataRoot] = await Promise.all([
getNitroFilesRoot(),
getGamedataRoot(),
]);
const mirrorCandidates = [
...(nitroFilesRoot ? [dirsFromNitroFilesRoot(nitroFilesRoot)] : []),
...(gamedataRoot
? [dirsFromGamedataRoot(gamedataRoot, primary.swfDir)]
: []),
];
const nitroFilesRoot = await getNitroFilesRoot();
const mirrorCandidates = nitroFilesRoot
? [dirsFromNitroFilesRoot(nitroFilesRoot)]
: [];
const primaryKey = {
swfDir:
@@ -148,26 +119,18 @@ 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);
const isDifferent =
return (
(process.platform === "win32" ? swfDir.toLowerCase() : swfDir) !==
primaryKey.swfDir ||
(process.platform === "win32" ? iconDir.toLowerCase() : iconDir) !==
primaryKey.iconDir ||
(process.platform === "win32" ? nitroDir.toLowerCase() : 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;
primaryKey.nitroDir
);
});
return {
-63
View File
@@ -1,63 +0,0 @@
// @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),
);
});
});
+3 -17
View File
@@ -1,9 +1,6 @@
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import {
getGamedataRoot,
getNitroFilesRoot,
} from "@/lib/services/furni-asset-dirs";
import { 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";
@@ -34,10 +31,7 @@ async function getFurnitureDataWritePaths(): Promise<string[]> {
const primary = await getFurnitureDataPath();
const configuredMirror =
(await siteSettings.get("furni_data_mirror_path", "")) ?? "";
const [nitroFilesRoot, gamedataRoot] = await Promise.all([
getNitroFilesRoot(),
getGamedataRoot(),
]);
const nitroFilesRoot = await getNitroFilesRoot();
const mirror = configuredMirror.trim()
? configuredMirror
: nitroFilesRoot
@@ -47,15 +41,7 @@ async function getFurnitureDataWritePaths(): Promise<string[]> {
)
: "";
const gamedataPath = gamedataRoot
? path.join(
/*turbopackIgnore: true*/ gamedataRoot,
"config/FurnitureData.json",
)
: "";
const paths = [primary, mirror, gamedataPath]
.filter(Boolean)
.map((p) => path.normalize(p));
const paths = [primary, mirror].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
@@ -1,139 +0,0 @@
// @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 -54
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 { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
import {
allocateCatalogItemId,
@@ -43,36 +43,6 @@ 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> {
@@ -230,8 +200,7 @@ export async function uploadSingleFurni(params: {
}
await ensureDirectories();
const assetTargets = await getFurniAssetWriteTargets();
const { iconDir, nitroDir } = assetTargets;
const { iconDir, nitroDir } = await getFurniAssetDirs();
const nitroFileName = `${classname}.nitro`;
const nitroPath = path.join(
@@ -256,24 +225,6 @@ 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);
@@ -297,9 +248,6 @@ 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,