Auto-fix furni asset ownership after import
CI / check (push) Successful in 44s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m17s

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.
This commit is contained in:
openhands committed 2026-08-18 19:52:32 +02:00
1 parent c95ad4a37f
commit 18825115a4
3 files changed
+53 -1

No files matched your search

+9
View File
@@ -17,6 +17,7 @@ import {
deleteImportedItem, deleteImportedItem,
downloadFile, downloadFile,
ensureDirectories, ensureDirectories,
ensureFurniOwnership,
IMPORTED_PAGE_CAPTION_SAVE, IMPORTED_PAGE_CAPTION_SAVE,
importSingleFurni, importSingleFurni,
reconcileImportedOfferIds, reconcileImportedOfferIds,
@@ -407,6 +408,9 @@ export const POST = withAdmin(
// Reconcile offer_id with the furnidata sprite id across the tree // Reconcile offer_id with the furnidata sprite id across the tree
const reconciled = await reconcileImportedOfferIds(); 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 succeeded = results.filter((r) => r.ok).length;
const failed = results.filter((r) => !r.ok).length; const failed = results.filter((r) => !r.ok).length;
@@ -416,6 +420,7 @@ export const POST = withAdmin(
failed, failed,
results, results,
offerIdsFixed: reconciled.fixed, 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 // Reconcile offer_id with the furnidata sprite id across the tree
const reconciled = await reconcileImportedOfferIds(); const reconciled = await reconcileImportedOfferIds();
// Keep asset + furnidata folders readable by the web server
const ownership = await ensureFurniOwnership();
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "furni_import", action: "furni_import",
@@ -489,6 +497,7 @@ export const POST = withAdmin(
id: result.itemId, id: result.itemId,
catalogItemId: result.catalogItemId, catalogItemId: result.catalogItemId,
offerIdsFixed: reconciled.fixed, offerIdsFixed: reconciled.fixed,
ownershipFixed: ownership.fixed,
spriteCount: result.spriteCount, spriteCount: result.spriteCount,
warnings: result.warnings, warnings: result.warnings,
}); });
+1 -1
View File
@@ -35,7 +35,7 @@ export async function getFurnitureDataPath(): Promise<string> {
return path.join(/*turbopackIgnore: true*/ process.cwd(), "public", relative); return path.join(/*turbopackIgnore: true*/ process.cwd(), "public", relative);
} }
async function getFurnitureDataWritePaths(): Promise<string[]> { export async function getFurnitureDataWritePaths(): Promise<string[]> {
const primary = await getFurnitureDataPath(); const primary = await getFurnitureDataPath();
const configuredMirror = const configuredMirror =
(await siteSettings.get("furni_data_mirror_path", "")) ?? ""; (await siteSettings.get("furni_data_mirror_path", "")) ?? "";
+43
View File
@@ -1,5 +1,7 @@
import { execFile } from "node:child_process";
import { existsSync, promises as fs } from "node:fs"; import { existsSync, promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util";
import { and, eq, type SQL, sql } from "drizzle-orm"; import { and, eq, type SQL, sql } from "drizzle-orm";
import { CatalogPages, db, ItemsBase, LogsFurniImports } from "@/lib/db"; import { CatalogPages, db, ItemsBase, LogsFurniImports } from "@/lib/db";
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel"; import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
@@ -15,6 +17,7 @@ import {
appendFurniEntry, appendFurniEntry,
buildFurniEntry, buildFurniEntry,
findFurniDataIdConflict, findFurniDataIdConflict,
getFurnitureDataWritePaths,
removeFurniEntry, removeFurniEntry,
} from "@/lib/services/furni-data"; } from "@/lib/services/furni-data";
import { import {
@@ -303,6 +306,46 @@ export async function reconcileImportedOfferIds(): Promise<{
return { fixed: Number(result.affectedRows ?? 0) }; 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 * Allocate the next `catalog_items.id` and run the INSERT through a
* process-level async mutex so concurrent imports in the same Node process * process-level async mutex so concurrent imports in the same Node process