Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fc3dc93ce |
No files matched your search
+34
-35
@@ -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
@@ -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"
|
||||
|
||||
Generated
+16
-16
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
Reference in new issue
Block a user