feat: add .nitro upload + sync-all + repair-icons features
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 32s

- Upload .nitro bundles directly with full DB, catalog, and furnidata integration
- Generate SQL migration files on upload (optional)
- Auto-sync missing furniture from all configured clone sources (SSE batch)
- Repair missing icons by extracting from local .nitro or downloading from sources
- All behind ASSETS_IMPORT permission
This commit is contained in:
openhands committed 2026-07-21 14:53:26 +02:00
1 parent c9144dc293
commit 804daeffca
13 files changed
+1484 -1

No files matched your search

@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { UploadFurniClient } from "./upload-furni-client";
export default async function UploadFurniPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Upload .nitro Furniture
</h1>
<p className="text-muted-foreground mt-1">
Upload a .nitro bundle and optional icon to import furniture into the
hotel. The database entry, catalog item, and FurnitureData.json will
be updated automatically.
</p>
</div>
<UploadFurniClient />
</div>
);
}
@@ -0,0 +1,331 @@
"use client";
import { AlertCircle, Check, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { adminFetch } from "@/lib/admin-fetch";
interface UploadResult {
itemId: number;
catalogItemId: number | null;
classname: string;
warnings: string[];
}
export function UploadFurniClient() {
const nitroRef = useRef<HTMLInputElement>(null);
const iconRef = useRef<HTMLInputElement>(null);
const [classname, setClassname] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [itemType, setItemType] = useState<"s" | "i">("s");
const [xdim, setXdim] = useState(1);
const [ydim, setYdim] = useState(1);
const [canstandon, setCanstandon] = useState(false);
const [cansiton, setCansiton] = useState(false);
const [canlayon, setCanlayon] = useState(false);
const [interactionType, setInteractionType] = useState("default");
const [customparams, setCustomparams] = useState("");
const [nitroFile, setNitroFile] = useState<File | null>(null);
const [iconFile, setIconFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<UploadResult | null>(null);
const [error, setError] = useState("");
function resetForm() {
setClassname("");
setName("");
setDescription("");
setItemType("s");
setXdim(1);
setYdim(1);
setCanstandon(false);
setCansiton(false);
setCanlayon(false);
setInteractionType("default");
setCustomparams("");
setNitroFile(null);
setIconFile(null);
setResult(null);
setError("");
if (nitroRef.current) nitroRef.current.value = "";
if (iconRef.current) iconRef.current.value = "";
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setUploading(true);
setError("");
setResult(null);
try {
const fd = new FormData();
fd.set("classname", classname.trim());
fd.set("name", name.trim());
fd.set("description", description.trim());
fd.set("itemType", itemType);
fd.set("xdim", String(xdim));
fd.set("ydim", String(ydim));
fd.set("canstandon", String(canstandon));
fd.set("cansiton", String(cansiton));
fd.set("canlayon", String(canlayon));
fd.set("interactionType", interactionType.trim());
fd.set("customparams", customparams.trim());
if (nitroFile) fd.set("nitro", nitroFile);
if (iconFile) fd.set("icon", iconFile);
const res = await adminFetch("/api/admin/import/furni/upload", {
method: "POST",
body: fd,
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Upload failed");
toast.error(data.error || "Upload failed");
return;
}
setResult(data);
toast.success(`${data.classname} imported successfully`);
} catch {
const msg = "Network error";
setError(msg);
toast.error(msg);
} finally {
setUploading(false);
}
}
return (
<div className="space-y-6">
{!result ? (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-xl border bg-card p-5 space-y-5">
<div className="text-sm font-medium">Furniture Details</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="classname">Classname *</Label>
<Input
id="classname"
placeholder="e.g. rare_dragon"
value={classname}
onChange={(e) => setClassname(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
placeholder="e.g. Dragon Egg"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Optional description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
</div>
<div className="grid gap-4 sm:grid-cols-4">
<div className="space-y-1.5">
<Label htmlFor="itemType">Type *</Label>
<select
id="itemType"
value={itemType}
onChange={(e) => setItemType(e.target.value as "s" | "i")}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="s">Floor (s)</option>
<option value="i">Wall (i)</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="xdim">Width (x)</Label>
<Input
id="xdim"
type="number"
min={1}
max={64}
value={xdim}
onChange={(e) => setXdim(Number(e.target.value))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ydim">Length (y)</Label>
<Input
id="ydim"
type="number"
min={1}
max={64}
value={ydim}
onChange={(e) => setYdim(Number(e.target.value))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="interactionType">Interaction</Label>
<Input
id="interactionType"
placeholder="default"
value={interactionType}
onChange={(e) => setInteractionType(e.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-4 flex-wrap">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={canstandon}
onChange={(e) => setCanstandon(e.target.checked)}
className="h-4 w-4 rounded border-gray-300"
/>
<span className="text-sm">Can stand on</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={cansiton}
onChange={(e) => setCansiton(e.target.checked)}
className="h-4 w-4 rounded border-gray-300"
/>
<span className="text-sm">Can sit on</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={canlayon}
onChange={(e) => setCanlayon(e.target.checked)}
className="h-4 w-4 rounded border-gray-300"
/>
<span className="text-sm">Can lay on</span>
</label>
</div>
<div className="space-y-1.5">
<Label htmlFor="customparams">Custom params</Label>
<Input
id="customparams"
placeholder="Optional custom parameters"
value={customparams}
onChange={(e) => setCustomparams(e.target.value)}
/>
</div>
</div>
<div className="rounded-xl border bg-card p-5 space-y-4">
<div className="text-sm font-medium">Files</div>
<div className="space-y-1.5">
<Label htmlFor="nitro">.nitro file *</Label>
<Input
ref={nitroRef}
id="nitro"
type="file"
accept=".nitro"
onChange={(e) => setNitroFile(e.target.files?.[0] ?? null)}
required
/>
<p className="text-xs text-muted-foreground">
The .nitro bundle file (max 50MB)
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="icon">Icon (PNG)</Label>
<Input
ref={iconRef}
id="icon"
type="file"
accept=".png,image/png"
onChange={(e) => setIconFile(e.target.files?.[0] ?? null)}
/>
<p className="text-xs text-muted-foreground">
Optional 64x64 PNG icon (max 2MB). Leave empty to skip.
</p>
</div>
</div>
{error && (
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
<div className="flex gap-3">
<Button type="submit" disabled={uploading}>
{uploading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{uploading ? "Importing…" : "Import Furniture"}
</Button>
</div>
</form>
) : (
<div className="rounded-xl border bg-card p-6 space-y-4">
<div className="flex items-center gap-2">
<Check className="w-5 h-5 text-[var(--admin-success)]" />
<span className="text-lg font-semibold">Import Successful</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex gap-2">
<span className="text-muted-foreground w-32">Classname:</span>
<code className="font-mono">{result.classname}</code>
</div>
<div className="flex gap-2">
<span className="text-muted-foreground w-32">Item ID:</span>
<code className="font-mono">{result.itemId}</code>
</div>
{result.catalogItemId && (
<div className="flex gap-2">
<span className="text-muted-foreground w-32">Catalog ID:</span>
<code className="font-mono">{result.catalogItemId}</code>
</div>
)}
{result.warnings.length > 0 && (
<div className="space-y-1 pt-2">
<span className="text-muted-foreground font-medium">
Warnings:
</span>
{result.warnings.map((w) => (
<div
key={w}
className="flex items-center gap-1.5 text-yellow-600 dark:text-yellow-400"
>
<AlertCircle className="w-3 h-3 shrink-0" />
<span>{w}</span>
</div>
))}
</div>
)}
</div>
<Button variant="outline" onClick={resetForm}>
Import Another
</Button>
</div>
)}
</div>
);
}
+21
View File
@@ -3,8 +3,11 @@ import {
Cat,
Copy,
Dumbbell,
ImageIcon,
Puzzle,
RefreshCw,
Shirt,
Upload,
} from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
@@ -31,6 +34,12 @@ export default async function ImportPage() {
icon: Puzzle,
description: t("furniDescription"),
},
{
href: "/admin/import/furni/upload",
label: t("upload"),
icon: Upload,
description: t("uploadDescription"),
},
{
href: "/admin/import/clothing",
label: t("clothing"),
@@ -55,6 +64,18 @@ export default async function ImportPage() {
icon: Copy,
description: t("cloneDescription"),
},
{
href: "/admin/import/sync",
label: t("sync"),
icon: RefreshCw,
description: t("syncDescription"),
},
{
href: "/admin/import/repair-icons",
label: t("repairIcons"),
icon: ImageIcon,
description: t("repairIconsDescription"),
},
] as const;
return (
@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { RepairIconsClient } from "./repair-icons-client";
export default async function RepairIconsPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Repair Missing Icons
</h1>
<p className="text-muted-foreground mt-1">
Scan all existing furniture and download any missing icon files. Icons
are extracted from local .nitro bundles or fetched from configured
clone sources.
</p>
</div>
<RepairIconsClient />
</div>
);
}
@@ -0,0 +1,159 @@
"use client";
import { ImageIcon, Loader2, Wrench } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
interface RepairEvent {
type: string;
classname?: string;
status?: string;
succeeded?: number;
failed?: number;
}
export function RepairIconsClient() {
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [log, setLog] = useState<string[]>([]);
const [result, setResult] = useState<{
succeeded: number;
failed: number;
} | null>(null);
const startRepair = useCallback(async () => {
setRunning(true);
setProgress(null);
setLog([]);
setResult(null);
try {
const res = await adminFetch("/api/admin/import/furni/repair-icons", {
method: "POST",
});
if (!res.body) {
toast.error("No response stream");
setRunning(false);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let succeeded = 0;
let failed = 0;
let totalItems = 0;
while (true) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
try {
const evt = JSON.parse(part.slice(6)) as RepairEvent;
if (
evt.type === "item_progress" &&
(evt.status === "done" || evt.status === "failed")
) {
totalItems++;
if (evt.status === "done") succeeded++;
else failed++;
setProgress({ done: succeeded + failed, total: totalItems });
setLog((prev) => [
...prev,
`${evt.status === "done" ? "✓" : "✗"} ${evt.classname ?? ""}`,
]);
}
if (evt.type === "batch_complete") {
succeeded = evt.succeeded ?? succeeded;
failed = evt.failed ?? failed;
}
} catch {
// skip parse errors
}
}
}
setResult({ succeeded, failed });
if (succeeded > 0) toast.success(`${succeeded} icons repaired`);
if (failed > 0) toast.error(`${failed} icons could not be obtained`);
} catch {
toast.error("Repair request failed");
} finally {
setRunning(false);
}
}, []);
return (
<div className="space-y-4">
{!result && (
<Button onClick={startRepair} disabled={running} size="lg">
{running ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Wrench className="w-4 h-4 mr-2" />
)}
{running
? `Repairing… ${progress ? `${progress.done}/${progress.total}` : ""}`
: "Start Icon Repair"}
</Button>
)}
{progress && (
<div className="text-sm text-muted-foreground">
Processed {progress.done} items
{progress.total > 0 ? ` out of ${progress.total}` : ""}
</div>
)}
{log.length > 0 && (
<div className="rounded-xl border bg-card p-4 max-h-80 overflow-y-auto space-y-0.5">
{log.map((line) => (
<div key={line} className="text-xs font-mono text-muted-foreground">
{line}
</div>
))}
</div>
)}
{result && (
<div className="rounded-xl border bg-card p-5 space-y-3">
<div className="flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-[var(--admin-success)]" />
<span className="text-lg font-semibold">Repair Complete</span>
</div>
<div className="text-sm space-y-1">
<p>
<span className="text-muted-foreground">Icons downloaded:</span>{" "}
{result.succeeded}
</p>
<p>
<span className="text-muted-foreground">Failed:</span>{" "}
{result.failed}
</p>
</div>
</div>
)}
{!running && !result && (
<div className="rounded-xl border bg-card p-6 text-center text-muted-foreground text-sm">
<ImageIcon className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p>
Scans all furniture in the database and downloads any missing icon
files. Icons are extracted from local .nitro bundles or downloaded
from configured clone sources.
</p>
</div>
)}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { SyncAllClient } from "./sync-all-client";
export default async function SyncAllPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Sync All Sources
</h1>
<p className="text-muted-foreground mt-1">
Scan all configured clone sources and automatically import
any furniture that is not yet present in your hotel. Missing
.nitro files, icons, database entries, catalog items, and
FurnitureData.json will be added automatically.
</p>
</div>
<SyncAllClient />
</div>
);
}
@@ -0,0 +1,176 @@
"use client";
import { Check, CloudDownload, Loader2 } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
interface SyncEvent {
type: string;
classname?: string;
status?: string;
succeeded?: number;
failed?: number;
}
export function SyncAllClient() {
const [syncing, setSyncing] = useState(false);
const [progress, setProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [log, setLog] = useState<string[]>([]);
const [result, setResult] = useState<{
succeeded: number;
failed: number;
sources: number;
} | null>(null);
const startSync = useCallback(async () => {
setSyncing(true);
setProgress(null);
setLog([]);
setResult(null);
try {
const res = await adminFetch("/api/admin/import/clone/sync-all", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!res.body) {
toast.error("No response stream");
setSyncing(false);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let succeeded = 0;
let failed = 0;
let totalItems = 0;
while (true) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
try {
const evt = JSON.parse(part.slice(6)) as SyncEvent;
if (
evt.type === "item_progress" &&
(evt.status === "done" || evt.status === "failed")
) {
totalItems++;
if (evt.status === "done") succeeded++;
else failed++;
setProgress({ done: succeeded + failed, total: totalItems });
setLog((prev) => [
...prev,
`${evt.status === "done" ? "✓" : "✗"} ${evt.classname ?? ""}`,
]);
}
if (evt.type === "batch_complete") {
succeeded = evt.succeeded ?? succeeded;
failed = evt.failed ?? failed;
}
} catch {
// skip parse errors
}
}
}
// Check for skipped state (all up to date)
try {
const parsed = JSON.parse(
buf.includes("data:") ? (buf.split("data:").pop() ?? "{}") : "{}",
);
if (parsed.skipped) {
toast.info(parsed.message || "All sources are up to date");
setResult({ succeeded: 0, failed: 0, sources: 0 });
setSyncing(false);
return;
}
} catch {
// not a skipped response
}
setResult({ succeeded, failed, sources: 1 });
if (succeeded > 0) toast.success(`${succeeded} items synced`);
if (failed > 0) toast.error(`${failed} items failed`);
} catch {
toast.error("Sync request failed");
} finally {
setSyncing(false);
}
}, []);
return (
<div className="space-y-4">
{!result && (
<Button onClick={startSync} disabled={syncing} size="lg">
{syncing ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<CloudDownload className="w-4 h-4 mr-2" />
)}
{syncing
? `Syncing… ${progress ? `${progress.done}/${progress.total}` : ""}`
: "Start Sync All Sources"}
</Button>
)}
{progress && (
<div className="text-sm text-muted-foreground">
Processed {progress.done} items
{progress.total > 0 ? ` out of ${progress.total}` : ""}
</div>
)}
{log.length > 0 && (
<div className="rounded-xl border bg-card p-4 max-h-80 overflow-y-auto space-y-0.5">
{log.map((line) => (
<div key={line} className="text-xs font-mono text-muted-foreground">
{line}
</div>
))}
</div>
)}
{result && result.sources > 0 && (
<div className="rounded-xl border bg-card p-5 space-y-3">
<div className="flex items-center gap-2">
<Check className="w-5 h-5 text-[var(--admin-success)]" />
<span className="text-lg font-semibold">Sync Complete</span>
</div>
<div className="text-sm space-y-1">
<p>
<span className="text-muted-foreground">Succeeded:</span>{" "}
{result.succeeded}
</p>
<p>
<span className="text-muted-foreground">Failed:</span>{" "}
{result.failed}
</p>
</div>
</div>
)}
{!syncing && !result && (
<div className="rounded-xl border bg-card p-6 text-center text-muted-foreground text-sm">
<CloudDownload className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p>
Click the button above to scan all clone sources for missing
furniture and import it automatically.
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,153 @@
import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logAudit } from "@/lib/services/audit";
import {
cloneSingleFurni,
fetchSourceFurnidata,
} from "@/lib/services/clone-import";
import { listSources } from "@/lib/services/clone-sources";
import { runSseBatch } from "@/lib/services/import/core/sse-batch";
function sseEvent(data: unknown): string {
return `data: ${JSON.stringify(data)}\n\n`;
}
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
const body = await request.json().catch(() => ({}));
const sourceIdFilter: string | undefined = body.sourceId || undefined;
const allSources = await listSources();
const sources = sourceIdFilter
? allSources.filter((s) => s.id === sourceIdFilter)
: allSources;
if (sources.length === 0) {
return new Response(sseEvent({ type: "error", message: "No sources configured" }), {
status: 400,
headers: { "Content-Type": "text/event-stream" },
});
}
const allItems: Array<{
classname: string;
sourceName: string;
sourceId: string;
}> = [];
for (const source of sources) {
let entries: Awaited<ReturnType<typeof fetchSourceFurnidata>>;
try {
entries = await fetchSourceFurnidata(source.furnidataUrl);
} catch {
continue;
}
const classnames = entries.map((e) => e.classname);
if (classnames.length === 0) continue;
const placeholders = classnames.map(() => "?").join(",");
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
...classnames,
);
const have = new Set(rows.map((r) => r.item_name));
for (const entry of entries) {
if (!have.has(entry.classname)) {
allItems.push({
classname: entry.classname,
sourceName: source.name,
sourceId: source.id,
});
}
}
}
if (allItems.length === 0) {
return new Response(
sseEvent({
type: "batch_complete",
succeeded: 0,
failed: 0,
skipped: true,
message: "All sources are up to date",
}),
{ headers: { "Content-Type": "text/event-stream" } },
);
}
if (allItems.length > 2000) {
return new Response(
sseEvent({
type: "error",
message: `Too many items to sync (${allItems.length}). Sync individual sources instead.`,
}),
{
status: 400,
headers: { "Content-Type": "text/event-stream" },
},
);
}
return runSseBatch({
items: allItems,
concurrency: 2,
labelOf: (it) => `${it.sourceName}/${it.classname}`,
worker: async (it, _index, report) => {
const allSrc = await listSources();
const source = allSrc.find((s) => s.id === it.sourceId);
if (!source) {
return { ok: false, error: "source not found" };
}
let allEntries: Awaited<ReturnType<typeof fetchSourceFurnidata>>;
try {
allEntries = await fetchSourceFurnidata(source.furnidataUrl);
} catch (err) {
return {
ok: false,
error: `furnidata fetch failed: ${(err as Error).message}`,
};
}
const entry = allEntries.find((e) => e.classname === it.classname);
if (!entry) {
return {
ok: false,
error: "classname not found in source furnidata",
};
}
const result = await cloneSingleFurni({
source,
entry,
onProgress: report,
});
if (result.ok) {
logAudit({
userId: ctx.session.user.id,
action: "auto_sync_furni",
target: "FurniAsset",
targetId: 0,
after: {
classname: it.classname,
sourceId: it.sourceId,
sourceName: it.sourceName,
},
});
}
return {
ok: result.ok,
warnings: result.warnings,
error: result.error,
};
},
});
},
);
@@ -0,0 +1,33 @@
import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
import { repairMissingIcons } from "@/lib/services/repair-icons";
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (data: unknown) => {
try {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
);
} catch {
/* stream closed */
}
};
await repairMissingIcons(send);
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
});
@@ -0,0 +1,92 @@
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { uploadSingleFurni } from "@/lib/services/upload-import";
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
const formData = await request.formData();
const classname = (formData.get("classname") as string)?.trim();
const name = (formData.get("name") as string)?.trim();
const description = (formData.get("description") as string)?.trim() || "";
const itemType = formData.get("itemType") as string;
const xdim = formData.get("xdim")
? Number(formData.get("xdim"))
: undefined;
const ydim = formData.get("ydim")
? Number(formData.get("ydim"))
: undefined;
const canstandon = formData.get("canstandon") === "true";
const cansiton = formData.get("cansiton") === "true";
const canlayon = formData.get("canlayon") === "true";
const interactionType =
(formData.get("interactionType") as string)?.trim() || "default";
const customparams = (formData.get("customparams") as string)?.trim() || "";
const generateSql = formData.get("generateSql") === "true";
const nitroFile = formData.get("nitro") as File | null;
const iconFile = formData.get("icon") as File | null;
if (!classname) return apiError("classname is required", 400);
if (!name) return apiError("name is required", 400);
if (!itemType || !["s", "i"].includes(itemType))
return apiError("itemType must be 's' or 'i'", 400);
if (!nitroFile || nitroFile.size === 0)
return apiError(".nitro file is required", 400);
if (nitroFile.size > 50 * 1024 * 1024)
return apiError(".nitro file too large (max 50MB)", 400);
const nitroBuffer = Buffer.from(await nitroFile.arrayBuffer());
let iconBuffer: Buffer | null = null;
if (iconFile && iconFile.size > 0) {
if (iconFile.size > 2 * 1024 * 1024)
return apiError("Icon file too large (max 2MB)", 400);
iconBuffer = Buffer.from(await iconFile.arrayBuffer());
}
const result = await uploadSingleFurni({
classname,
name,
description,
itemType: itemType as "s" | "i",
xdim,
ydim,
canstandon,
cansiton,
canlayon,
interactionType,
customparams,
nitroBuffer,
iconBuffer,
generateSql,
});
if (!result.ok) {
return apiError(result.error || "Upload failed", 400);
}
logAudit({
userId: ctx.session.user.id,
action: "furni_upload",
target: "FurniAsset",
targetId: result.itemId ?? 0,
after: {
classname: result.classname,
itemId: result.itemId,
catalogItemId: result.catalogItemId,
},
});
return apiOk({
itemId: result.itemId,
catalogItemId: result.catalogItemId,
classname: result.classname,
warnings: result.warnings,
sqlFile: result.sqlFile,
});
},
);
+126
View File
@@ -0,0 +1,126 @@
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { prisma } from "@/lib/prisma";
import { extractFurniIconPng } from "@/lib/services/clone-icon";
import { listSources } from "@/lib/services/clone-sources";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import { downloadFile } from "@/lib/services/import/core/download";
import { ensureDirectories } from "./furni-import";
export interface RepairEvent {
type: "item_progress" | "batch_complete" | "error";
classname?: string;
status?: string;
index?: number;
message?: string;
warnings?: string[];
succeeded?: number;
failed?: number;
duration?: number;
}
export async function repairMissingIcons(
onEvent?: (evt: RepairEvent) => void,
): Promise<{ succeeded: number; failed: number }> {
await ensureDirectories();
const { iconDir, nitroDir } = await getFurniAssetDirs();
const items = await prisma.$queryRaw<
Array<{ id: number; item_name: string }>
>`SELECT id, item_name FROM items_base ORDER BY id`;
if (items.length === 0) {
onEvent?.({
type: "batch_complete",
succeeded: 0,
failed: 0,
duration: 0,
});
return { succeeded: 0, failed: 0 };
}
let succeeded = 0;
let failed = 0;
for (let i = 0; i < items.length; i++) {
const { item_name: classname } = items[i];
const iconPath = path.join(iconDir, `${classname}_icon.png`);
if (existsSync(iconPath)) continue;
onEvent?.({
type: "item_progress",
classname,
status: "started",
index: i,
});
let ok = false;
const warnings: string[] = [];
const nitroPath = path.join(nitroDir, `${classname}.nitro`);
if (existsSync(nitroPath)) {
try {
const nitroBuf = await fs.readFile(nitroPath);
const icon = extractFurniIconPng(nitroBuf);
if (icon) {
await fs.writeFile(iconPath, icon);
ok = true;
} else {
warnings.push("no icon asset in .nitro bundle");
}
} catch (err) {
warnings.push(`extract from .nitro failed: ${(err as Error).message}`);
}
} else {
warnings.push(".nitro file not found on disk");
}
if (!ok) {
const sources = await listSources();
for (const source of sources) {
try {
const dl = await downloadFile(
`${source.iconBaseUrl}/${classname}_icon.png`,
iconPath,
{ maxRetries: 1, validate: "png" },
);
if (dl.ok) {
ok = true;
break;
}
} catch {
warnings.push(`download from ${source.name} failed`);
}
}
}
if (ok) {
succeeded++;
onEvent?.({
type: "item_progress",
classname,
status: "done",
index: i,
warnings: warnings.length ? warnings : undefined,
});
} else {
failed++;
onEvent?.({
type: "item_progress",
classname,
status: "failed",
index: i,
message: warnings[0] || "could not obtain icon",
});
}
}
onEvent?.({
type: "batch_complete",
succeeded,
failed,
});
return { succeeded, failed };
}
+307
View File
@@ -0,0 +1,307 @@
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { prisma } from "@/lib/prisma";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
import {
allocateCatalogItemId,
autoPriceFurni,
ensureDirectories,
getOrCreateCategoryPage,
} from "@/lib/services/furni-import";
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
export interface UploadResult {
ok: boolean;
itemId?: number;
catalogItemId?: number | null;
classname: string;
warnings: string[];
error?: string;
sqlFile?: string;
}
export interface UploadParams {
classname: string;
name: string;
description?: string;
itemType: "s" | "i";
xdim?: number;
ydim?: number;
canstandon?: boolean;
cansiton?: boolean;
canlayon?: boolean;
interactionType?: string;
customparams?: string;
}
const MIGRATIONS_DIR = path.resolve(process.cwd(), "prisma/migrations");
let itemsBaseIdAllocChain: Promise<unknown> = Promise.resolve();
async function allocateItemsBaseId<T>(
insertFn: (nextId: number) => Promise<T>,
): Promise<T> {
const prev = itemsBaseIdAllocChain;
let settle!: () => void;
itemsBaseIdAllocChain = new Promise<void>((r) => {
settle = r;
});
await prev.catch(() => {});
try {
const idRow = await prisma.$queryRaw<Array<{ next: number }>>`
SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base`;
const nextId = Number(idRow[0]?.next ?? 1);
return await insertFn(nextId);
} finally {
settle();
}
}
function escapeSql(val: string | number): string {
if (typeof val === "number") return String(val);
return `'${val.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
}
async function findNextMigrationNumber(): Promise<number> {
try {
const entries = await fs.readdir(MIGRATIONS_DIR);
let max = 0;
for (const entry of entries) {
const match = entry.match(/^(\d+)_/);
if (match) {
const num = Number.parseInt(match[1], 10);
if (num > max) max = num;
}
}
return max + 1;
} catch {
return 1;
}
}
async function writeSqlMigration(params: {
classname: string;
name: string;
itemType: string;
xdim: number;
ydim: number;
stackHeight: number;
allowStack: string;
cansiton: boolean;
canlayon: boolean;
canstandon: boolean;
interactionType: string;
customparams: string;
price: { credits: number; points: number; pointsType: number };
}): Promise<string> {
const num = await findNextMigrationNumber();
const padded = String(num).padStart(4, "0");
const className = params.classname.replace(/[^a-zA-Z0-9_-]/g, "_");
const fileName = `${padded}_import_furni_${className}.sql`;
const filePath = path.join(MIGRATIONS_DIR, fileName);
const lines: string[] = [
`-- Migration: ${fileName}`,
`-- Description: Import furniture "${params.classname}"`,
`-- Generated: ${new Date().toISOString()}`,
"",
`INSERT INTO items_base`,
` (id, sprite_id, public_name, item_name, type, width, length, stack_height,`,
` allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)`,
`VALUES`,
` (@next_id, @next_id, ${escapeSql(params.name)}, ${escapeSql(params.classname)},`,
` ${escapeSql(params.itemType)}, ${params.xdim}, ${params.ydim}, ${params.stackHeight},`,
` ${params.allowStack}, ${params.cansiton ? "1" : "0"}, ${params.canlayon ? "1" : "0"}, ${params.canstandon ? "1" : "0"},`,
` ${escapeSql(params.interactionType)}, ${escapeSql(params.customparams)});`,
"",
`SET @next_id = LAST_INSERT_ID();`,
"",
`INSERT INTO catalog_items`,
` (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)`,
`VALUES`,
` (NULL, ${escapeSql("imp_other")}, @next_id, ${escapeSql(params.classname)},`,
` ${params.price.credits}, ${params.price.points}, ${params.price.pointsType}, 1, 1, '-1', '');`,
"",
];
const sql = lines.join("\n");
await fs.mkdir(MIGRATIONS_DIR, { recursive: true });
await fs.writeFile(filePath, sql, "utf-8");
return fileName;
}
export async function uploadSingleFurni(params: {
classname: string;
name: string;
description?: string;
itemType: "s" | "i";
xdim?: number;
ydim?: number;
canstandon?: boolean;
cansiton?: boolean;
canlayon?: boolean;
interactionType?: string;
customparams?: string;
nitroBuffer: Buffer;
iconBuffer?: Buffer | null;
generateSql?: boolean;
}): Promise<UploadResult> {
const {
classname,
name,
description = "",
itemType,
xdim: userXdim,
ydim: userYdim,
canstandon = false,
cansiton = false,
canlayon = false,
interactionType: userInteractionType = "default",
customparams = "",
nitroBuffer,
iconBuffer,
generateSql = false,
} = params;
const warnings: string[] = [];
if (!/^[\w\-.*]+$/.test(classname)) {
return { ok: false, classname, warnings, error: "invalid classname" };
}
const existing = await prisma.$queryRaw<Array<{ id: number }>>`
SELECT id FROM items_base WHERE item_name = ${classname} LIMIT 1`;
if (existing.length > 0) {
return {
ok: false,
classname,
warnings,
error: "Item already exists in database",
};
}
let nitroMeta: Record<string, unknown> | null = null;
try {
const parsed = parseNitroBundle(nitroBuffer);
nitroMeta = parsed.json;
} catch {
return { ok: false, classname, warnings, error: "invalid .nitro bundle" };
}
await ensureDirectories();
const { iconDir, nitroDir } = await getFurniAssetDirs();
const nitroFileName = `${classname}.nitro`;
const nitroPath = path.join(nitroDir, nitroFileName);
if (existsSync(nitroPath)) {
return {
ok: false,
classname,
warnings,
error: ".nitro file already exists on disk",
};
}
await fs.writeFile(nitroPath, nitroBuffer);
let iconFileName: string | null = null;
let iconPath: string | null = null;
if (iconBuffer && iconBuffer.length > 0) {
iconFileName = `${classname}_icon.png`;
iconPath = path.join(iconDir, iconFileName);
await fs.writeFile(iconPath, iconBuffer);
}
const xdim = userXdim ?? Number(nitroMeta?.xdim ?? 1);
const ydim = userYdim ?? Number(nitroMeta?.ydim ?? 1);
const stackHeight = canlayon || cansiton ? 1.0 : canstandon ? 1.0 : 0.0;
const allowStack = stackHeight > 0 ? "1" : "0";
const price = autoPriceFurni(classname);
let newId: number;
try {
newId = await allocateItemsBaseId(async (nextId) => {
await prisma.$executeRaw`
INSERT INTO items_base
(id, sprite_id, public_name, item_name, type, width, length, stack_height,
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)
VALUES
(${nextId}, ${nextId}, ${name}, ${classname}, ${itemType}, ${xdim}, ${ydim}, ${stackHeight},
${allowStack}, ${cansiton ? "1" : "0"}, ${canlayon ? "1" : "0"}, ${canstandon ? "1" : "0"}, ${userInteractionType}, ${customparams})`;
return nextId;
});
} catch (err) {
await fs.unlink(nitroPath).catch(() => {});
if (iconPath) await fs.unlink(iconPath).catch(() => {});
return {
ok: false,
classname,
warnings,
error: `items_base insert failed: ${(err as Error).message}`,
};
}
try {
const furniEntry = buildFurniEntry({
id: newId,
classname,
revision: 0,
category: "unknown",
name,
description,
spriteId: newId,
dims: { x: xdim, y: ydim, z: stackHeight },
metadata: null,
});
await appendFurniEntry(furniEntry, itemType);
} catch (err) {
warnings.push(`FurnitureData append failed: ${(err as Error).message}`);
}
let catalogItemId: number | null = null;
try {
const pageId = await getOrCreateCategoryPage(classname, itemType);
catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => {
await prisma.$executeRaw`
INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)
VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')`;
return nextCatalogId;
});
} catch (err) {
warnings.push(`catalog entry failed: ${(err as Error).message}`);
}
let sqlFile: string | undefined;
if (generateSql) {
try {
sqlFile = await writeSqlMigration({
classname,
name,
itemType,
xdim,
ydim,
stackHeight,
allowStack,
cansiton,
canlayon,
canstandon,
interactionType: userInteractionType,
customparams,
price,
});
warnings.push(`SQL migration written: ${sqlFile}`);
} catch (err) {
warnings.push(`Failed to write SQL migration: ${(err as Error).message}`);
}
}
return {
ok: true,
itemId: newId,
catalogItemId,
classname,
warnings,
sqlFile,
};
}
+7 -1
View File
@@ -2481,6 +2481,8 @@
"badgesDescription": "Import and manage badge assets from external sources.",
"furni": "Furni",
"furniDescription": "Import furniture definitions, SWF files, and icons.",
"upload": "Upload .nitro",
"uploadDescription": "Upload .nitro bundles and icons directly without SWF conversion.",
"clothing": "Clothing",
"clothingDescription": "Import clothing and wearables for avatars.",
"effects": "Effects",
@@ -2488,7 +2490,11 @@
"pets": "Pets",
"petsDescription": "Import pet data and icons.",
"clone": "Clone",
"cloneDescription": "Clone existing items from one source to another."
"cloneDescription": "Clone existing items from one source to another.",
"sync": "Auto Sync",
"syncDescription": "Automatically import missing furniture from all configured sources.",
"repairIcons": "Repair Icons",
"repairIconsDescription": "Download any missing icon files for furniture already in the database."
},
"translations": {
"title": "Translations",