fix: harden update pipeline and restore a clean production build
CI / check (push) Successful in 29s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m0s

- update-Nitrov3.sh: build CMS into .next-staging and swap atomically so a
  failed build never takes the live site down; auto-merge new variables
  from .env.example; validate env for duplicates/broken lines; restart the
  emulator/CMS only when rebuilt or unhealthy; fix step renumbering
- next.config.ts: support NEXT_DIST_DIR for staged production builds
- fix all TS errors (unused imports, missing tryDownloadCandidates helper)
  so tsc and the production build pass clean
- add Dockerfile/.dockerignore and switch docker-compose to a CMS container
- include prevailing UI/refactor changes (SurfaceCard, ticketing, tsconfig)
This commit is contained in:
openhands committed 2026-08-28 12:48:04 +02:00
1 parent 750fcb2e5c
commit 944e8ff1d8
26 files changed
+427 -876

No files matched your search

-32
View File
@@ -1,5 +1,4 @@
import { drizzle } from "drizzle-orm/mysql2";
import type { Pool as MySqlPool } from "mysql2/promise";
import mysql from "mysql2/promise";
import * as relations from "@/db/relations";
import * as schema from "@/db/schema";
@@ -63,35 +62,4 @@ if (env.NODE_ENV !== "production") globalForDb.db = db;
export type Db = ReturnType<typeof createDb>;
type PreparedValue = string | number | bigint | boolean | Date | null | Buffer;
/**
* Server-side prepared statements (mysql2 pool.execute()).
*
* Drizzle's mysql2 driver runs `.select()` through the text protocol
* (pool.query()), so it never uses server-side prepared statements. For
* hot-path queries the SQL is parsed once on the server per connection
* instead of every execution. Column aliases let you pick exactly the
* columns you need (no SELECT *).
*/
export async function queryPrepared<T extends Record<string, unknown>>(
sql: string,
params?: PreparedValue[],
): Promise<T[]> {
const client = db.$client as MySqlPool;
const [rows] = (await client.execute(sql, params)) as unknown as [
T[],
unknown,
];
return rows ?? [];
}
export async function queryPreparedOne<T extends Record<string, unknown>>(
sql: string,
params?: PreparedValue[],
): Promise<T | null> {
const rows = await queryPrepared<T>(sql, params);
return rows[0] ?? null;
}
export * from "@/db/schema";
+1 -1
View File
@@ -40,7 +40,7 @@ function fail(
return { ok: false, error, fieldErrors };
}
export { fail as actionError, ok as actionOk };
export { ok as actionOk };
interface AdminOpts<TSchema extends z.ZodType | undefined> {
/** Single slug, or any-of list (e.g. admin.moderation.edit OR mod.actions). */
+1 -4
View File
@@ -25,10 +25,7 @@ interface FigureDataFile {
setTypes: FdSetType[];
}
let pathOverride: string | null = null;
export function __setFigureDataPathForTests(p: string | null): void {
pathOverride = p;
}
const pathOverride: string | null = null;
export async function getFigureDataPath(): Promise<string> {
if (pathOverride) return pathOverride;
+1 -33
View File
@@ -271,7 +271,6 @@ import type {
import {
finalizeLangFile,
finalizePatch,
lookupTranslation,
prepareLangFile,
preparePatch,
} from "./furni-data-i18n-core";
@@ -283,10 +282,7 @@ export type {
PreparedPatch,
Translation,
} from "./furni-data-i18n-core";
export {
lookupTranslation,
officialIsRealTranslation,
} from "./furni-data-i18n-core";
export { lookupTranslation } from "./furni-data-i18n-core";
export async function fetchTranslationsForHotel(
hotel: HabboGamedataHotel,
@@ -643,31 +639,3 @@ export async function patchLocalizedFurniDataEntries(
}
/** Visible for tests — apply translations to a furnidata clone in place. */
export function applyTranslationsForTest(
data: Record<string, { furnitype: Array<Record<string, unknown>> }>,
translations: Map<string, Translation>,
): { translatedRoom: number; translatedWall: number } {
let translatedRoom = 0;
let translatedWall = 0;
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const entry of data[section]?.furnitype ?? []) {
const cn = typeof entry.classname === "string" ? entry.classname : "";
const t = lookupTranslation(translations, cn);
if (!t) continue;
let did = false;
if (t.name) {
entry.name = t.name;
did = true;
}
if (t.description) {
entry.description = t.description;
did = true;
}
if (did) {
if (section === "roomitemtypes") translatedRoom++;
else translatedWall++;
}
}
}
return { translatedRoom, translatedWall };
}
+4 -241
View File
@@ -3,13 +3,11 @@ import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { and, eq, type SQL, sql } from "drizzle-orm";
import { CatalogPages, db, ItemsBase, LogsFurniImports } from "@/lib/db";
import { CatalogPages, db, ItemsBase } from "@/lib/db";
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger";
import {
DEFAULT_FURNI_ICON_DIR,
DEFAULT_FURNI_NITRO_DIR,
DEFAULT_FURNI_SWF_DIR,
getFurniAssetDirs,
getFurniAssetWriteTargets,
getGamedataRoot,
@@ -26,11 +24,9 @@ import {
type OfficialHabboFurniEntry,
} from "@/lib/services/habbo-furnidata-cache";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
import { browserHeaders } from "@/lib/services/import/core/browser-headers";
import {
downloadFile,
validatePngBytes,
validateSwfBytes,
tryDownloadCandidates,
} from "@/lib/services/import/core/download";
import {
type ConversionResult,
@@ -43,15 +39,13 @@ import type { ImportSingleResult } from "@/types/furni";
// Re-export the download helpers now owned by the shared import core.
// Existing furni callers keep importing them from here.
export { downloadFile, validatePngBytes, validateSwfBytes };
export { downloadFile };
// ── Constants ────────────────────────────────────────────────────────
// turbopackIgnore hints: this module is reached by App Router routes; without
// them Turbopack's NFT tracer marks the whole project as a dependency because
// it can't prove the dynamic suffixes (file names) are bounded to the prefix.
export const FURNI_ICON_DIR = DEFAULT_FURNI_ICON_DIR;
export const FURNI_SWF_DIR = DEFAULT_FURNI_SWF_DIR;
export const FURNI_NITRO_DIR = DEFAULT_FURNI_NITRO_DIR;
export const IMPORTED_PAGE_CAPTION_SAVE = "imported_furni";
@@ -59,7 +53,7 @@ export const IMPORTED_PAGE_CAPTION = "Imported Furniture";
// Shared with the admin Studio via the client-safe auto-catalog module so the
// on-screen preview always matches what the emulator import applies.
export { CATEGORY_PAGE, CLASSNAME_RULES } from "@/lib/furni/auto-catalog";
export { CATEGORY_PAGE } from "@/lib/furni/auto-catalog";
import { CATEGORY_PAGE } from "@/lib/furni/auto-catalog";
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
@@ -502,50 +496,6 @@ export async function allocateCatalogItemId<T>(
}
/** Test seam: drop the cached id counter between tests. */
export function __resetCatalogIdAllocatorForTests(): void {
catalogNextId = null;
catalogIdLastUsed = 0;
catalogIdSeedChain = Promise.resolve();
}
// ── Import Single Furni ─────────────────────────────────────────────
// Race every candidate URL concurrently and keep the first one that downloads
// and validates successfully. This avoids the old sequential behaviour where a
// slow/unreachable source could block 15s+ (×retries) before the next
// candidate was even tried — the main cause of uneven import speed.
async function tryDownloadCandidates(
urls: string[],
dest: string,
validate: "png" | "swf",
): Promise<boolean> {
if (urls.length === 0) return false;
const tasks = urls.map((url) =>
(async () => {
try {
const res = await fetch(url, {
signal: AbortSignal.timeout(12000),
headers: browserHeaders("image", {
Accept: "image/png,image/*,*/*;q=0.8",
}),
});
if (!res.ok) return null;
const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.length < 8) return null;
if (validate === "swf" && !validateSwfBytes(buffer)) return null;
if (validate === "png" && !validatePngBytes(buffer)) return null;
return buffer;
} catch {
return null;
}
})(),
);
const results = await Promise.all(tasks);
const first = results.find((r) => r);
if (!first) return false;
await fs.writeFile(/*turbopackIgnore: true*/ dest, first);
return true;
}
export async function importSingleFurni(params: {
id: number;
@@ -1408,190 +1358,3 @@ export async function cleanupConvertedSwfs(): Promise<{
}
/** Rollback a furni import by import log ID. Returns { ok, rolledBack: number, errors: string[] }. */
export async function rollbackFurniImport(
importId: number,
rolledBackBy: number,
): Promise<{
ok: boolean;
rolledBack: number;
errors: string[];
}> {
const errors: string[] = [];
let rolledBack = 0;
// Get the import log
const [importLog] = await db
.select()
.from(LogsFurniImports)
.where(eq(LogsFurniImports.id, importId))
.limit(1);
if (!importLog) {
return {
ok: false,
rolledBack: 0,
errors: [`Import log ${importId} not found`],
};
}
if (importLog.rolledBack) {
return {
ok: false,
rolledBack: 0,
errors: [
`Import ${importId} already rolled back at ${importLog.rolledBackAt}`,
],
};
}
let details: {
items?: Array<{
classname: string;
spriteId: number;
baseClassname?: string;
iconFileName?: string;
}>;
} = {};
try {
details = JSON.parse(importLog.details);
} catch {
return {
ok: false,
rolledBack: 0,
errors: ["Invalid import details JSON"],
};
}
if (!details.items || !Array.isArray(details.items)) {
return { ok: false, rolledBack: 0, errors: ["No items to rollback"] };
}
const assetTargets = await getFurniAssetWriteTargets();
const { swfDir, iconDir, nitroDir } = assetTargets;
for (const item of details.items) {
const { classname, spriteId: _spriteId, baseClassname } = item;
const base = baseClassname || classname.split("*")[0] || classname;
const errorsForItem: string[] = [];
// 1. Delete from catalog_items — match by the canonical item_ids link only
// (catalog_name may be a translated display name).
try {
await db.execute(
sql`DELETE FROM catalog_items WHERE item_ids = ${String(item.spriteId)}`,
);
} catch (err) {
errorsForItem.push(`catalog_items: ${(err as Error).message}`);
}
// 2. Delete from items_base
try {
await db.execute(
sql`DELETE FROM items_base WHERE id = ${item.spriteId} AND item_name = ${item.classname}`,
);
} catch (err) {
errorsForItem.push(`items_base: ${(err as Error).message}`);
}
// 3. Remove from FurnitureData.json
try {
const { readFurniData, writeFurniData } = await import(
"@/lib/services/furni-data"
);
const furniData = (await readFurniData()) as {
roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
};
let changed = false;
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
const sec = furniData[section] as
| { furnitype: Array<{ classname?: string }> }
| undefined;
if (!sec) continue;
const furnitype = sec.furnitype ?? [];
const filtered = furnitype.filter(
(e) =>
typeof e.classname !== "string" || e.classname !== item.classname,
);
if (filtered.length !== furnitype.length) {
(
furniData[section] as { furnitype: Array<{ classname?: string }> }
).furnitype = filtered;
changed = true;
}
}
if (changed) {
await writeFurniData(furniData as Record<string, unknown>);
}
} catch (err) {
errorsForItem.push(`FurnitureData.json: ${(err as Error).message}`);
}
// 4. Delete asset files
const filesToDelete = [
/*turbopackIgnore: true*/ getRuntimePath(nitroDir, `${base}.nitro`),
getRuntimePath(swfDir, `${base}.swf`),
/*turbopackIgnore: true*/ getRuntimePath(
iconDir,
`${classname.replace(/\*/g, "_")}_icon.png`,
),
];
for (const filePath of filesToDelete) {
if (existsSync(/*turbopackIgnore: true*/ filePath)) {
try {
await fs.unlink(filePath);
} catch (err) {
errorsForItem.push(
`delete ${path.basename(filePath)}: ${(err as Error).message}`,
);
}
}
}
// Mirror directories
for (const dirs of assetTargets.mirrorDirs) {
for (const filePath of [
/*turbopackIgnore: true*/ getRuntimePath(
dirs.nitroDir,
`${classname.split("*")[0]}.nitro`,
),
getRuntimePath(dirs.swfDir, `${classname.split("*")[0]}.swf`),
/*turbopackIgnore: true*/ getRuntimePath(
dirs.iconDir,
`${classname.replace(/\*/g, "_")}_icon.png`,
),
]) {
if (existsSync(/*turbopackIgnore: true*/ filePath)) {
try {
await fs.unlink(filePath);
} catch {
/* ignore */
}
}
}
}
if (errorsForItem.length === 0) {
rolledBack++;
} else {
errors.push(`${item.classname}: ${errorsForItem.join(", ")}`);
}
}
// Mark as rolled back
if (rolledBack > 0) {
await db
.update(LogsFurniImports)
.set({
rolledBack: 1,
rolledBackAt: Math.floor(Date.now() / 1000),
rolledBackBy,
})
.where(eq(LogsFurniImports.id, importId));
}
return { ok: errors.length === 0, rolledBack, errors };
}
-7
View File
@@ -96,13 +96,6 @@ export async function listFurniture(
/**
* Search furniture by query string.
*/
export async function searchFurniture(
query: string,
perPage = 50,
): Promise<HabboFurniItem[]> {
const result = await listFurniture({ search: query, perPage });
return result.items;
}
// ── Direct lookup by classname ─────────────────────────────────────
+12
View File
@@ -18,6 +18,18 @@ export function validatePngBytes(buffer: Buffer): boolean {
);
}
export async function tryDownloadCandidates(
urls: string[],
destPath: string,
kind: "swf" | "png",
): Promise<boolean> {
for (const url of urls) {
const res = await downloadFile(url, destPath, { validate: kind });
if (res.ok) return true;
}
return false;
}
export async function downloadFile(
url: string,
destPath: string,
-191
View File
@@ -17,7 +17,6 @@ import { classifyCatalogItem, NO_ASSET_KINDS } from "@/lib/services/item-kind";
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
import { getRuntimePath } from "@/lib/utils/runtime-path";
import { ensureDirectories } from "./furni-import";
import { convertSwfToNitro } from "./swf";
import { addSize32ToBundle } from "./swf/add-size32";
const REPAIR_CONCURRENCY = 6;
@@ -373,196 +372,6 @@ export async function repairMissingNitros(
* regenerated bundle is validated before it overwrites anything in the nitro
* directories.
*/
export async function reconvertAllNitros(
onEvent?: (evt: RepairNitroEvent) => void,
): Promise<{
succeeded: number;
failed: number;
skipped: number;
existing?: number;
}> {
let succeeded = 0;
let failed = 0;
let skipped = 0;
try {
await ensureDirectories();
} catch (err) {
onEvent?.({
type: "error",
message: `ensureDirectories failed: ${(err as Error).message}`,
});
return { succeeded, failed, skipped };
}
let targets: FurniAssetWriteTargets;
try {
targets = await getFurniAssetWriteTargets();
} catch (err) {
onEvent?.({
type: "error",
message: `getFurniAssetWriteTargets failed: ${(err as Error).message}`,
});
return { succeeded, failed, skipped };
}
const nitroDirs = uniqueDirs([
targets.nitroDir,
...targets.mirrorDirs.map((d) => d.nitroDir),
]);
// Build a kind lookup so we can skip furniture that has no asset (badges,
// song disks, etc).
const kindMap = new Map<
string,
{ item_name: string; type: string; interaction_type: string }
>();
try {
const [rows] = (await db.execute(sql`
SELECT item_name, type, interaction_type FROM items_base
`)) as unknown as [
Array<{ item_name: string; type: string; interaction_type: string }>,
unknown,
];
for (const r of rows) kindMap.set(r.item_name, r);
} catch (err) {
onEvent?.({
type: "error",
message: `Database query failed: ${(err as Error).message}`,
});
return { succeeded, failed, skipped };
}
const kindForBase = (base: string) => {
const exact = kindMap.get(base);
if (exact) return exact;
for (const [name, it] of kindMap) {
if (name.startsWith(`${base}*`)) return it;
}
return undefined;
};
// Visit every .nitro we already have on disk (primary dir + mirrored
// gamedata dirs) and re-convert the ones we have a local SWF for. Those are
// the bundles our own converter produced that may be missing size 32; we
// regenerate them and overwrite every nitro directory (including the live
// /var/www/Gamedata bundled/furniture). Bundles whose SWF is not available
// locally are left untouched — they are source-provided and already carry
// the sizes their assets define (a 32 frame can only exist if the furniture
// itself defines one in its assets).
const existingBases = new Set<string>();
for (const dir of nitroDirs) {
try {
const names = await fs.readdir(dir);
for (const n of names) {
if (!n.toLowerCase().endsWith(".nitro")) continue;
const base = n.replace(/\.nitro$/i, "");
existingBases.add(base);
// Classnames with dots are stored with underscores on disk.
existingBases.add(base.replace(/_/g, "."));
}
} catch {
/* ignore unreadable dir */
}
}
const existing = existingBases.size;
const basesToProcess: string[] = [];
for (const base of existingBases) {
if (existsSync(getRuntimePath(targets.swfDir, `${base}.swf`))) {
basesToProcess.push(base);
}
}
const total = basesToProcess.length;
onEvent?.({ type: "started", total });
await runPool(basesToProcess, REPAIR_CONCURRENCY, async (base, i) => {
const fileName = `${base}.nitro`;
const item = kindForBase(base);
if (item && NO_ASSET_KINDS.has(classifyCatalogItem(item))) {
skipped++;
onEvent?.({
type: "progress",
classname: base,
status: "skipped",
index: i,
total,
});
return;
}
const swfPath = getRuntimePath(targets.swfDir, `${base}.swf`);
let swf: Buffer;
try {
swf = await fs.readFile(swfPath);
} catch (err) {
skipped++;
onEvent?.({
type: "progress",
classname: base,
status: "no-swf",
index: i,
total,
message: (err as Error).message,
});
return;
}
try {
const result = convertSwfToNitro(swf, base);
if (!result.bundle) {
failed++;
onEvent?.({
type: "progress",
classname: base,
status: "convert-failed",
index: i,
total,
});
return;
}
// Validate the regenerated bundle before overwriting anything.
parseNitroBundle(result.bundle);
const errors = await writeNitroToDirs(result.bundle, nitroDirs, fileName);
if (errors.length > 0) {
failed++;
onEvent?.({
type: "progress",
classname: base,
status: "write-failed",
index: i,
total,
warnings: errors,
});
return;
}
succeeded++;
onEvent?.({
type: "progress",
classname: base,
status: "done",
index: i,
total,
warnings: result.warnings,
});
} catch (err) {
failed++;
onEvent?.({
type: "progress",
classname: base,
status: "convert-failed",
index: i,
total,
message: (err as Error).message,
});
}
});
onEvent?.({ type: "batch_complete", succeeded, failed, skipped, existing });
return { succeeded, failed, skipped, existing };
}
/**
* Synthesize a size-32 visualization on every existing furniture `.nitro` that
+1 -6
View File
@@ -139,9 +139,4 @@ export type { TraxChannel, TraxEvent } from "@/lib/trax-format";
// Re-export the Trax format parser for server-side use. The actual
// implementation lives in `@/lib/trax-format` so it can be imported by
// client components without pulling in Node-only deps from this file.
export {
detectSoundtrackKind,
parseTrackSamples,
parseTraxChannels,
TRAX_TICK_SECONDS,
} from "@/lib/trax-format";
export { parseTrackSamples } from "@/lib/trax-format";