From 18825115a49b285ec8e743d303858cb344913180 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 18 Aug 2026 19:52:32 +0200 Subject: [PATCH] Auto-fix furni asset ownership after import After each single or batch import, chown the swf/icon/nitro asset directories and the FurnitureData.json folders to www-data:www-data so nginx and the emulator can read newly written files. Best-effort and non-blocking; the API response reports which folders were fixed. --- src/app/api/admin/import/furni/route.ts | 9 ++++++ src/lib/services/furni-data.ts | 2 +- src/lib/services/furni-import.ts | 43 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index 53c2cc32..2ea58076 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -17,6 +17,7 @@ import { deleteImportedItem, downloadFile, ensureDirectories, + ensureFurniOwnership, IMPORTED_PAGE_CAPTION_SAVE, importSingleFurni, reconcileImportedOfferIds, @@ -407,6 +408,9 @@ export const POST = withAdmin( // Reconcile offer_id with the furnidata sprite id across the tree const reconciled = await reconcileImportedOfferIds(); + // Keep asset + furnidata folders readable by the web server + const ownership = await ensureFurniOwnership(); + const succeeded = results.filter((r) => r.ok).length; const failed = results.filter((r) => !r.ok).length; @@ -416,6 +420,7 @@ export const POST = withAdmin( failed, results, offerIdsFixed: reconciled.fixed, + ownershipFixed: ownership.fixed, }); } @@ -468,6 +473,9 @@ export const POST = withAdmin( // Reconcile offer_id with the furnidata sprite id across the tree const reconciled = await reconcileImportedOfferIds(); + // Keep asset + furnidata folders readable by the web server + const ownership = await ensureFurniOwnership(); + logAudit({ userId: ctx.session.user.id, action: "furni_import", @@ -489,6 +497,7 @@ export const POST = withAdmin( id: result.itemId, catalogItemId: result.catalogItemId, offerIdsFixed: reconciled.fixed, + ownershipFixed: ownership.fixed, spriteCount: result.spriteCount, warnings: result.warnings, }); diff --git a/src/lib/services/furni-data.ts b/src/lib/services/furni-data.ts index 73e3015f..c06643f0 100644 --- a/src/lib/services/furni-data.ts +++ b/src/lib/services/furni-data.ts @@ -35,7 +35,7 @@ export async function getFurnitureDataPath(): Promise { return path.join(/*turbopackIgnore: true*/ process.cwd(), "public", relative); } -async function getFurnitureDataWritePaths(): Promise { +export async function getFurnitureDataWritePaths(): Promise { const primary = await getFurnitureDataPath(); const configuredMirror = (await siteSettings.get("furni_data_mirror_path", "")) ?? ""; diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index ea1fbc2a..850d0ed1 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -1,5 +1,7 @@ +import { execFile } from "node:child_process"; 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 { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel"; @@ -15,6 +17,7 @@ import { appendFurniEntry, buildFurniEntry, findFurniDataIdConflict, + getFurnitureDataWritePaths, removeFurniEntry, } from "@/lib/services/furni-data"; import { @@ -303,6 +306,46 @@ export async function reconcileImportedOfferIds(): Promise<{ return { fixed: Number(result.affectedRows ?? 0) }; } +const execFileAsync = promisify(execFile); + +/** + * Ensure the furni asset directories (icons, nitros, swf) and the folders + * holding FurnitureData.json are owned by www-data:www-data, so nginx and the + * emulator can always read freshly imported files. Best-effort: if chown is + * unavailable or fails (e.g. running unprivileged), a warning is logged and + * import continues. + */ +export async function ensureFurniOwnership(): Promise<{ fixed: string[] }> { + if (process.platform === "win32") return { fixed: [] }; + + const { swfDir, iconDir, nitroDir, mirrorDirs } = + await getFurniAssetWriteTargets(); + const furniDataPaths = await getFurnitureDataWritePaths(); + + const dirs = [ + swfDir, + iconDir, + nitroDir, + ...mirrorDirs.flatMap((m) => [m.swfDir, m.iconDir, m.nitroDir]), + ...furniDataPaths.map((p) => path.dirname(p)), + ] + .map((d) => path.normalize(d)) + .filter((d, i, arr) => arr.indexOf(d) === i && existsSync(d)); + + if (dirs.length === 0) return { fixed: [] }; + + try { + await execFileAsync("chown", ["-R", "www-data:www-data", ...dirs]); + return { fixed: dirs }; + } catch (err) { + logger.warn("[import-furni] chown to www-data failed", { + dirs, + error: (err as Error).message, + }); + return { fixed: [] }; + } +} + /** * Allocate the next `catalog_items.id` and run the INSERT through a * process-level async mutex so concurrent imports in the same Node process