Support Gitea configuration and safe Catalog Studio furniture completion
CI / check (push) Successful in 1m31s
CI / deploy (push) Successful in 1m18s
CI / e2e (push) Successful in 21s

This commit is contained in:
Simo committed 2026-09-05 13:33:40 +02:00
1 parent 26f071117b
commit 9f791ba284
22 files changed
+1120 -166

No files matched your search

+3
View File
@@ -71,6 +71,9 @@ RUN if [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f ya
# --- Runtime stage --- # --- Runtime stage ---
FROM node:26.8.1-bookworm-slim AS runner FROM node:26.8.1-bookworm-slim AS runner
# Catalog Studio publishes to self-hosted Git repositories over HTTPS.
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
# The CMS writes to bind-mounted host directories (/var/www/Gamedata is owned by # The CMS writes to bind-mounted host directories (/var/www/Gamedata is owned by
# the host's www-data user, UID/GID 33). The node base image already ships a # the host's www-data user, UID/GID 33). The node base image already ships a
# www-data user with UID/GID 33, which matches that ownership — so we run as # www-data user with UID/GID 33, which matches that ownership — so we run as
+111 -115
View File
@@ -1,4 +1,5 @@
"use client"; "use client";
import "./nitro-editor.css";
import { import {
Box, Box,
@@ -240,6 +241,7 @@ export function NitroEditorDialog({
setMetadata(JSON.parse(JSON.stringify(originalMetadata))); setMetadata(JSON.parse(JSON.stringify(originalMetadata)));
setFlags({ ...originalFlags }); setFlags({ ...originalFlags });
setJsonText(JSON.stringify(originalMetadata, null, 2)); setJsonText(JSON.stringify(originalMetadata, null, 2));
setJsonError(null);
toast.info("Reset to original values"); toast.info("Reset to original values");
}; };
@@ -334,7 +336,9 @@ export function NitroEditorDialog({
const hasChanges = const hasChanges =
metadata && metadata &&
originalMetadata && originalMetadata &&
(JSON.stringify(metadata) !== JSON.stringify(originalMetadata) || ((activeTab === "json"
? jsonText !== JSON.stringify(originalMetadata, null, 2)
: JSON.stringify(metadata) !== JSON.stringify(originalMetadata)) ||
JSON.stringify(flags) !== JSON.stringify(originalFlags)); JSON.stringify(flags) !== JSON.stringify(originalFlags));
// Furni preview URL // Furni preview URL
@@ -344,13 +348,16 @@ export function NitroEditorDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] flex flex-col p-0 gap-0"> <DialogContent
data-nitro-editor
className="sm:max-w-4xl h-[min(840px,92dvh)] max-h-[92dvh] flex flex-col overflow-hidden p-0 gap-0"
>
{/* Header with preview */} {/* Header with preview */}
<DialogHeader className="px-6 pt-5 pb-4 border-b bg-muted/30"> <DialogHeader className="px-6 pt-5 pb-4 border-b bg-muted/30">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{/* Furni preview */} {/* Furni preview */}
<div <div
className="w-20 h-20 rounded-lg border bg-card flex items-center justify-center shrink-0 overflow-hidden" className="w-16 h-16 rounded-lg border bg-card flex items-center justify-center shrink-0 overflow-hidden"
style={{ style={{
backgroundImage: "url(/images/furni_floor.png)", backgroundImage: "url(/images/furni_floor.png)",
backgroundRepeat: "repeat", backgroundRepeat: "repeat",
@@ -433,69 +440,100 @@ export function NitroEditorDialog({
{/* ── Visual Tab ── */} {/* ── Visual Tab ── */}
<TabsContent <TabsContent
value="visual" value="visual"
className="overflow-y-auto flex-1 px-6 py-4 space-y-5" className="min-h-0 overflow-y-auto flex-1 px-4 sm:px-6 py-4 space-y-4"
> >
{/* Dimensions + Interaction in 2-column layout */} {/* Dimensions + Interaction in 2-column layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5"> <div className="grid grid-cols-1 md:grid-cols-2 items-start gap-4">
{/* Dimensions card */} <div className="space-y-4">
<section className="rounded-lg border bg-card p-4"> {/* Dimensions card */}
<div className="flex items-center gap-2 mb-3"> <section className="rounded-lg border bg-card p-4">
<Ruler className="w-4 h-4 text-muted-foreground" /> <div className="flex items-center gap-2 mb-3">
<h3 className="text-sm font-medium">Dimensions</h3> <Ruler className="w-4 h-4 text-muted-foreground" />
</div> <h3 className="text-sm font-medium">Dimensions</h3>
<div className="grid grid-cols-3 gap-3">
<div>
<Label
htmlFor="dim-x"
className="text-xs text-muted-foreground"
>
X (width)
</Label>
<Input
id="dim-x"
type="number"
min={1}
value={dims.x}
onChange={(e) => setDim("x", e.target.value)}
className="mt-1"
/>
</div> </div>
<div> <div className="grid grid-cols-3 gap-3">
<Label <div>
htmlFor="dim-y" <Label
className="text-xs text-muted-foreground" htmlFor="dim-x"
> className="text-xs text-muted-foreground"
Y (depth) >
</Label> X (width)
<Input </Label>
id="dim-y" <Input
type="number" id="dim-x"
min={1} type="number"
value={dims.y} min={1}
onChange={(e) => setDim("y", e.target.value)} value={dims.x}
className="mt-1" onChange={(e) => setDim("x", e.target.value)}
/> className="mt-1"
/>
</div>
<div>
<Label
htmlFor="dim-y"
className="text-xs text-muted-foreground"
>
Y (depth)
</Label>
<Input
id="dim-y"
type="number"
min={1}
value={dims.y}
onChange={(e) => setDim("y", e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label
htmlFor="dim-z"
className="text-xs text-muted-foreground"
>
Z (height)
</Label>
<Input
id="dim-z"
type="number"
step={0.01}
min={0}
value={dims.z}
onChange={(e) => setDim("z", e.target.value)}
className="mt-1"
/>
</div>
</div> </div>
<div> </section>
<Label
htmlFor="dim-z"
className="text-xs text-muted-foreground"
>
Z (height)
</Label>
<Input
id="dim-z"
type="number"
step={0.01}
min={0}
value={dims.z}
onChange={(e) => setDim("z", e.target.value)}
className="mt-1"
/>
</div>
</div>
</section>
{/* Available rotations */}
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<Compass className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Directions</h3>
<span className="ml-auto text-xs text-muted-foreground">
{directions.length} / 8 active
</span>
</div>
<div className="grid grid-cols-4 gap-2">
{ALL_DIRECTIONS.map((dir) => (
<button
key={dir}
type="button"
className="nitro-direction"
aria-label={`Direction ${dir} (${DIR_LABELS[dir]})`}
aria-pressed={directions.includes(dir)}
onClick={() => toggleDirection(dir)}
>
<strong>{DIR_LABELS[dir]}</strong>
<span>{dir}</span>
</button>
))}
</div>
<p className="mt-3 text-xs text-muted-foreground">
Choose the available rotations. Keep at least one
direction active.
</p>
</section>
</div>
{/* Interaction card */} {/* Interaction card */}
<section className="rounded-lg border bg-card p-4"> <section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
@@ -506,6 +544,7 @@ export function NitroEditorDialog({
<div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors"> <div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors">
<span className="text-sm">Can Stand On</span> <span className="text-sm">Can Stand On</span>
<Switch <Switch
aria-label="Can Stand On"
checked={flags.canstandon} checked={flags.canstandon}
onCheckedChange={(v) => onCheckedChange={(v) =>
setFlags((f) => ({ ...f, canstandon: !!v })) setFlags((f) => ({ ...f, canstandon: !!v }))
@@ -515,6 +554,7 @@ export function NitroEditorDialog({
<div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors"> <div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors">
<span className="text-sm">Can Sit On</span> <span className="text-sm">Can Sit On</span>
<Switch <Switch
aria-label="Can Sit On"
checked={flags.cansiton} checked={flags.cansiton}
onCheckedChange={(v) => onCheckedChange={(v) =>
setFlags((f) => ({ ...f, cansiton: !!v })) setFlags((f) => ({ ...f, cansiton: !!v }))
@@ -524,6 +564,7 @@ export function NitroEditorDialog({
<div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors"> <div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors">
<span className="text-sm">Can Lay On</span> <span className="text-sm">Can Lay On</span>
<Switch <Switch
aria-label="Can Lay On"
checked={flags.canlayon} checked={flags.canlayon}
onCheckedChange={(v) => onCheckedChange={(v) =>
setFlags((f) => ({ ...f, canlayon: !!v })) setFlags((f) => ({ ...f, canlayon: !!v }))
@@ -581,58 +622,6 @@ export function NitroEditorDialog({
</section> </section>
</div> </div>
{/* Directions - visual compass */}
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<Compass className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Directions</h3>
<span className="text-xs text-muted-foreground ml-auto">
{directions.length} active
</span>
</div>
<div className="flex items-center justify-center">
<div className="relative w-48 h-48">
{/* Compass circle */}
<div className="absolute inset-0 rounded-full border-2 border-muted" />
<div className="absolute inset-4 rounded-full border border-muted/50" />
{/* Direction buttons arranged in circle */}
{ALL_DIRECTIONS.map((dir) => {
const angle = (dir * 45 - 90) * (Math.PI / 180);
const r = 80;
const x = 96 + r * Math.cos(angle) - 18;
const y = 96 + r * Math.sin(angle) - 18;
const isActive = directions.includes(dir);
return (
<button
type="button"
key={dir}
className={`absolute w-9 h-9 rounded-full border-2 flex flex-col items-center justify-center text-xs font-medium transition-all ${
isActive
? "bg-primary text-primary-foreground border-primary shadow-md scale-110"
: "bg-card text-muted-foreground border-muted hover:border-foreground/30 hover:scale-105"
}`}
style={{ left: x, top: y }}
onClick={() => toggleDirection(dir)}
>
<span className="text-[9px] leading-none">
{DIR_LABELS[dir]}
</span>
<span className="text-[8px] leading-none opacity-60">
{dir}
</span>
</button>
);
})}
{/* Center label */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs text-muted-foreground font-medium">
{directions.length}/8
</span>
</div>
</div>
</div>
</section>
{/* Types */} {/* Types */}
<section className="rounded-lg border bg-card p-4"> <section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
@@ -858,7 +847,8 @@ export function NitroEditorDialog({
> >
<div className="relative flex-1 min-h-0"> <div className="relative flex-1 min-h-0">
<Textarea <Textarea
className="h-full min-h-[400px] font-mono text-xs resize-none" aria-label="Nitro metadata JSON"
className="h-full min-h-0 font-mono text-xs resize-none"
value={jsonText} value={jsonText}
onChange={(e) => handleJsonTextChange(e.target.value)} onChange={(e) => handleJsonTextChange(e.target.value)}
spellCheck={false} spellCheck={false}
@@ -903,7 +893,13 @@ export function NitroEditorDialog({
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={saving || loading || !metadata} disabled={
saving ||
loading ||
!metadata ||
!hasChanges ||
(activeTab === "json" && !!jsonError)
}
className="gap-1.5" className="gap-1.5"
> >
{saving ? ( {saving ? (
@@ -0,0 +1,63 @@
[data-nitro-editor] {
color: var(--admin-text);
background: var(--admin-surface);
border-color: var(--admin-border);
}
[data-nitro-editor] section,
[data-nitro-editor] .border,
[data-nitro-editor] .border-b,
[data-nitro-editor] .border-t {
border-color: var(--admin-border);
}
[data-nitro-editor] section {
background: var(--admin-canvas);
}
[data-nitro-editor] h3 {
font-size: 14px;
margin: 0;
}
[data-nitro-editor] [data-slot="switch"] {
width: 40px;
height: 24px;
background: var(--admin-border);
border: 1px solid var(--admin-text-muted);
}
[data-nitro-editor] [data-slot="switch"][data-checked] {
background: var(--admin-accent);
border-color: var(--admin-accent);
}
[data-nitro-editor] [data-slot="switch-thumb"] {
width: 18px;
height: 18px;
background: var(--admin-text);
color: var(--admin-canvas);
left: 2px;
}
[data-nitro-editor] [data-slot="switch-thumb"][data-checked] {
left: calc(100% - 20px);
}
[data-nitro-editor] .nitro-direction {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
min-height: 52px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: var(--admin-surface);
color: var(--admin-text-muted);
font-size: 12px;
}
[data-nitro-editor] .nitro-direction[aria-pressed="true"] {
border-color: var(--admin-accent);
background: var(--admin-accent);
color: var(--admin-accent-foreground);
}
[data-nitro-editor] .nitro-direction:focus-visible {
outline: 2px solid var(--admin-accent);
outline-offset: 3px;
}
[data-nitro-editor] [data-slot="dialog-footer"] {
flex-shrink: 0;
}
@@ -3,7 +3,11 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import { CatalogGitConfiguration } from "./catalog-git-configuration";
type ExportStatus = { type ExportStatus = {
remote?: string;
branch?: string;
enabled: boolean; enabled: boolean;
pending: number; pending: number;
active: number; active: number;
@@ -13,7 +17,11 @@ type ExportStatus = {
error?: string; error?: string;
}; };
export function CatalogExportClient() { export function CatalogExportClient({
canConfigure = false,
}: {
canConfigure?: boolean;
}) {
const [status, setStatus] = useState<ExportStatus | null>(null); const [status, setStatus] = useState<ExportStatus | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -55,26 +63,34 @@ export function CatalogExportClient() {
<h2 className="text-lg font-semibold">Catalog repository export</h2> <h2 className="text-lg font-semibold">Catalog repository export</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Automatically publishes bundles, icons, gamedata, translations and Automatically publishes bundles, icons, gamedata, translations and
catalog SQL to Epicnabbo-Catalogus-Updated-Daily (Beta-3). catalog SQL to your configured Gitea repository.
</p> </p>
{canConfigure && (
<CatalogGitConfiguration onSaved={() => void refresh()} />
)}
{status?.remote && (
<p className="text-xs break-all">
{status.remote} · {status.branch}
</p>
)}
<div role="status" className="text-sm" aria-live="polite"> <div role="status" className="text-sm" aria-live="polite">
{!status {!status
? "Loading…" ? "Loading…"
: !status.enabled : !status.enabled
? "Not configured on this server." ? "Export disabled. Configure the Gitea connection below."
: status.active : status.active
? `Waiting for ${status.active} operation(s) to finish.` ? `Waiting for ${status.active} operation(s) to finish.`
: status.running : status.running
? "Export in progress…" ? "Export in progress…"
: status.pending : status.pending
? `${status.pending} update(s) queued for the worker.` ? `${status.pending} update(s) queued for export.`
: "No pending updates."} : "No pending updates."}
{status?.commit && ( {status?.commit && (
<p> <p>
Last published commit:{" "} Last published commit:{" "}
<a <a
className="underline" className="underline"
href={`https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily/commit/${status.commit}`} href={`${status.remote?.replace(/\.git$/, "")}/commit/${status.commit}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
@@ -0,0 +1,169 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { adminFetch } from "@/lib/admin-fetch";
interface Configuration {
provider: string;
enabled: boolean;
remote: string;
branch: string;
username: string;
hasToken: boolean;
}
export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
const [config, setConfig] = useState<Configuration | null>(null),
[token, setToken] = useState(""),
[busy, setBusy] = useState(false),
[error, setError] = useState(""),
[message, setMessage] = useState("");
useEffect(() => {
const abort = new AbortController();
void (async () => {
try {
const res = await adminFetch("/api/admin/catalog-export/config", {
signal: abort.signal,
cache: "no-store",
});
if (!res.ok)
throw new Error(
"Unable to read Git settings. Settings edit permission is required.",
);
const data = await res.json();
if (!abort.signal.aborted) setConfig(data);
} catch (error) {
if (!abort.signal.aborted) setError((error as Error).message);
}
})();
return () => abort.abort();
}, []);
async function submit(action: "save" | "test") {
if (!config || busy) return;
setBusy(true);
setError("");
setMessage("");
try {
const res = await adminFetch("/api/admin/catalog-export/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...config, token, action }),
});
const data = await res.json();
if (!res.ok)
throw new Error(data.error || "Git configuration request failed");
if (action === "save") {
setConfig(data);
setToken("");
setMessage("Gitea configuration saved.");
onSaved();
} else setMessage(data.message);
} catch (error) {
setError((error as Error).message);
} finally {
setBusy(false);
}
}
return (
<details
className="rounded-lg border border-[var(--admin-border)] p-3"
open
>
<summary className="cursor-pointer font-semibold">
Gitea connection settings
</summary>
<p className="my-3 text-sm text-[var(--admin-text-muted)]">
Connect your self-hosted Gitea repository over HTTPS. The CMS manages
its own checkout; no Git-specific .env settings are needed.
</p>
{config && (
<fieldset disabled={busy} className="space-y-3">
<p className="text-sm">
Provider: <strong>Gitea self-hosted</strong>
</p>
<label htmlFor="catalog-git-1" className="block text-sm">
Repository URL
<Input
id="catalog-git-1"
type="url"
value={config.remote}
onChange={(event) =>
setConfig({ ...config, remote: event.target.value })
}
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label htmlFor="catalog-git-2" className="block text-sm">
Branch
<Input
id="catalog-git-2"
value={config.branch}
onChange={(event) =>
setConfig({ ...config, branch: event.target.value })
}
/>
</label>
<label htmlFor="catalog-git-3" className="block text-sm">
Gitea username
<Input
id="catalog-git-3"
autoComplete="off"
value={config.username}
onChange={(event) =>
setConfig({ ...config, username: event.target.value })
}
/>
</label>
</div>
<label htmlFor="catalog-git-4" className="block text-sm">
Gitea access token
<Input
id="catalog-git-4"
type="password"
autoComplete="new-password"
value={token}
placeholder={
config.hasToken
? "Token saved — leave blank to keep it"
: "Enter a token with repository write access"
}
onChange={(event) => setToken(event.target.value)}
/>
</label>
<p className="text-xs text-[var(--admin-text-muted)]">
The token is stored encrypted and is never returned to the browser.
Changing repository or username requires entering the token again.
</p>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={config.enabled}
onChange={(event) =>
setConfig({ ...config, enabled: event.target.checked })
}
/>
Automatically export catalog changes
</label>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => void submit("test")}>
Test connection
</Button>
<Button onClick={() => void submit("save")}>
{busy ? "Working…" : "Save Gitea settings"}
</Button>
</div>
</fieldset>
)}
{error && (
<p role="alert" className="mt-3 text-sm text-[var(--admin-warning)]">
{error}
</p>
)}
{message && (
<p role="status" className="mt-3 text-sm">
{message}
</p>
)}
</details>
);
}
+7 -1
View File
@@ -14,7 +14,13 @@ export default async function StudioSyncPage(_props: {
} }
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<CatalogExportClient /> <CatalogExportClient
canConfigure={canAccess(
permissions,
PERMS.SETTINGS_EDIT,
session.user.rank,
)}
/>
<h1 className="text-2xl font-bold tracking-tight">Sync All Sources</h1> <h1 className="text-2xl font-bold tracking-tight">Sync All Sources</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
Scan configured sources and automatically import any furniture that is Scan configured sources and automatically import any furniture that is
@@ -0,0 +1,89 @@
// @vitest-environment node
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ connection: vi.fn(), guard: vi.fn() }));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (options: unknown, handler: unknown) => {
mocks.guard(options);
return handler;
},
}));
vi.mock("@/lib/services/catalog-git-managed", () => ({
testCatalogConnection: mocks.connection,
}));
import { PERMS } from "@/lib/permission-slugs";
import { GET, POST } from "./route";
let root: string;
const input = {
action: "save",
enabled: true,
remote: "https://gitea.example/user/catalog",
branch: "main",
username: "user",
token: "test-private-token",
};
const request = (data = input) =>
new NextRequest("http://localhost/api/admin/catalog-export/config", {
method: "POST",
body: JSON.stringify(data),
});
describe("Gitea configuration endpoint", () => {
beforeEach(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), "catalog-config-test-"));
vi.stubEnv("CATALOG_GIT_STATE_DIR", root);
vi.stubEnv("AUTH_SECRET", "test-secret");
mocks.connection.mockReset().mockResolvedValue(undefined);
});
afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(root, { recursive: true, force: true });
});
it("requires settings permission and never returns the token", async () => {
expect(mocks.guard).toHaveBeenCalledWith({
permission: PERMS.SETTINGS_EDIT,
});
const response = await POST(request());
expect(response.status).toBe(200);
const data = await response.json();
expect(data.hasToken).toBe(true);
expect(data.token).toBeUndefined();
expect(data.encryptedToken).toBeUndefined();
expect(
await fs.readFile(path.join(root, "config.json"), "utf8"),
).not.toContain(input.token);
expect(
await (
await GET(
new NextRequest("http://localhost/api/admin/catalog-export/config"),
)
).text(),
).not.toContain(input.token);
});
it("does not save unverified enabled configurations", async () => {
mocks.connection.mockRejectedValue(new Error("Cannot connect"));
expect((await POST(request())).status).toBe(400);
expect(await fs.readdir(root)).toEqual([]);
});
it("rejects configuration changes during an export", async () => {
await fs.writeFile(path.join(root, "worker.lock"), "active");
expect((await POST(request())).status).toBe(409);
expect(mocks.connection).not.toHaveBeenCalled();
});
it("prevents moving queued changes to a different repository", async () => {
await POST(request());
await fs.writeFile(path.join(root, "change.pending"), "pending");
expect(
(
await POST(
request({ ...input, remote: "https://other.example/user/repo" }),
)
).status,
).toBe(409);
});
});
@@ -0,0 +1,91 @@
import { promises as fs } from "node:fs";
import { hostname } from "node:os";
import path from "node:path";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permission-slugs";
import {
catalogStateRoot,
publicCatalogConfig,
readManagedCatalogConfig,
saveCatalogConfig,
validateCatalogConfig,
} from "@/lib/services/catalog-git-config";
import { testCatalogConnection } from "@/lib/services/catalog-git-managed";
export const GET = withAdmin({ permission: PERMS.SETTINGS_EDIT }, async () =>
apiOk(publicCatalogConfig()),
);
export const POST = withAdmin(
{ permission: PERMS.SETTINGS_EDIT },
async (request) => {
const body = await request.json().catch(() => null);
if (!body || !["save", "test"].includes(body.action))
return apiError("Choose save or test", 400);
let config: ReturnType<typeof validateCatalogConfig>;
try {
config = validateCatalogConfig(body, readManagedCatalogConfig());
} catch (error) {
return apiError((error as Error).message, 400);
}
if (body.action === "test") {
try {
await testCatalogConnection(config);
return apiOk({
message:
"Gitea repository and branch are readable. Write access is checked when exporting.",
});
} catch (error) {
return apiError((error as Error).message, 400);
}
}
const root = catalogStateRoot();
await fs.mkdir(root, { recursive: true });
const lock = await fs
.open(path.join(root, "worker.lock"), "wx")
.catch((error: NodeJS.ErrnoException) => {
if (error.code === "EEXIST") return null;
throw error;
});
if (!lock)
return apiError("An export is running. Retry after it finishes.", 409);
try {
await lock.writeFile(
JSON.stringify({ pid: process.pid, host: hostname() }),
);
const entries = await fs.readdir(root);
if (entries.some((name) => name.endsWith(".active")))
return apiError(
"Wait for active catalog operations to finish before changing Git configuration.",
409,
);
const previous = readManagedCatalogConfig();
if (
previous &&
(previous.remote !== config.remote ||
previous.branch !== config.branch) &&
entries.some((name) => name.endsWith(".pending"))
)
return apiError(
"Export pending updates before changing repository or branch.",
409,
);
if (config.enabled) {
try {
await testCatalogConnection(config);
} catch (error) {
return apiError((error as Error).message, 400);
}
}
await saveCatalogConfig(config);
if (
previous &&
(previous.remote !== config.remote || previous.branch !== config.branch)
)
await fs.rm(path.join(root, "status.json"), { force: true });
return apiOk(publicCatalogConfig(config));
} finally {
await lock.close();
await fs.rm(path.join(root, "worker.lock"), { force: true });
}
},
);
+16 -3
View File
@@ -1,18 +1,31 @@
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response"; import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { publicCatalogConfig } from "@/lib/services/catalog-git-config";
import { import {
catalogExportEnabled, catalogExportEnabled,
catalogExportQueue, catalogExportQueue,
catalogExportStatus, catalogExportStatus,
scheduleCatalogExport,
} from "@/lib/services/catalog-git-queue"; } from "@/lib/services/catalog-git-queue";
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
apiOk(await catalogExportStatus()), const status = await catalogExportStatus();
); if (
status.enabled &&
status.pending > 0 &&
status.active === 0 &&
!("running" in status && status.running) &&
!("error" in status && status.error)
)
await scheduleCatalogExport();
const config = publicCatalogConfig();
return apiOk({ ...status, remote: config.remote, branch: config.branch });
});
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => { export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
if (!catalogExportEnabled()) if (!catalogExportEnabled())
return apiError("Catalog repository export is not configured", 400); return apiError("Catalog repository export is not configured", 400);
await catalogExportQueue().request(); await catalogExportQueue().request();
await scheduleCatalogExport();
return apiOk(await catalogExportStatus()); return apiOk(await catalogExportStatus());
}); });
@@ -125,6 +125,7 @@ export const POST = withAdmin(
revision: item.revision ?? 0, revision: item.revision ?? 0,
category: item.category ?? "unknown", category: item.category ?? "unknown",
skipFurniDataWrite: false, skipFurniDataWrite: false,
repairExisting: body.repairExisting === true,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl, sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl, nitroBaseUrl: source?.nitroBaseUrl,
iconBaseUrl: source?.iconBaseUrl, iconBaseUrl: source?.iconBaseUrl,
+2
View File
@@ -507,6 +507,7 @@ export const POST = withAdmin(
nitroBaseUrl: source?.nitroBaseUrl || undefined, nitroBaseUrl: source?.nitroBaseUrl || undefined,
iconBaseUrl: source?.iconBaseUrl || undefined, iconBaseUrl: source?.iconBaseUrl || undefined,
skipFurniDataWrite: false, skipFurniDataWrite: false,
repairExisting: body.repairExisting === true,
}); });
if (result.ok) { if (result.ok) {
@@ -679,6 +680,7 @@ export const POST = withAdmin(
revision: revision ?? 0, revision: revision ?? 0,
category: category ?? "unknown", category: category ?? "unknown",
updateExisting: updateExisting === true, updateExisting: updateExisting === true,
repairExisting: body.repairExisting === true,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl, sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl || undefined, nitroBaseUrl: source?.nitroBaseUrl || undefined,
iconBaseUrl: source?.iconBaseUrl || undefined, iconBaseUrl: source?.iconBaseUrl || undefined,
@@ -42,11 +42,7 @@ export function ImportReview({
const complete = items.every((item) => localByName.has(item.classname)); const complete = items.every((item) => localByName.has(item.classname));
const ready = items.filter((item) => { const ready = items.filter((item) => {
const local = localByName.get(item.classname); const local = localByName.get(item.classname);
return ( return local && compareFurniture(item, local).state !== "conflict";
local &&
local.sql.length === 0 &&
compareFurniture(item, local).state !== "conflict"
);
}); });
const blocked = items.filter((item) => { const blocked = items.filter((item) => {
const local = localByName.get(item.classname); const local = localByName.get(item.classname);
@@ -119,7 +115,7 @@ export function ImportReview({
<> <>
<p role="status" className="text-sm"> <p role="status" className="text-sm">
{ready.length} ready to import · {existing} already in SQL {ready.length} ready to import · {existing} already in SQL
(skipped) · {blocked} conflicts (skipped) (complete missing data) · {blocked} conflicts (skipped)
</p> </p>
{inspection.items.some( {inspection.items.some(
(item) => (item) =>
@@ -184,7 +180,10 @@ export function ImportReview({
<> <>
<p className="text-sm"> <p className="text-sm">
Only the {ready.length} eligible items below will be Only the {ready.length} eligible items below will be
submitted. Existing SQL items and conflicts are excluded. submitted. Existing furniture keeps its local IDs, SQL
settings and catalog offers. Missing assets and catalog
entries are completed; furnidata is refreshed. Conflicts are
excluded.
</p> </p>
<p className="text-xs text-[var(--admin-text-muted)]"> <p className="text-xs text-[var(--admin-text-muted)]">
Automatic categories and prices below are estimates. Source Automatic categories and prices below are estimates. Source
@@ -692,6 +692,7 @@ export function StudioClient({
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
repairExisting: true,
id: item.id, id: item.id,
classname: item.classname, classname: item.classname,
name: item.name, name: item.name,
@@ -779,6 +780,7 @@ export function StudioClient({
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
sourceId: activeSource || undefined, sourceId: activeSource || undefined,
repairExisting: true,
items: toImport.map((i) => ({ items: toImport.map((i) => ({
id: i.id, id: i.id,
classname: i.classname, classname: i.classname,
@@ -2323,6 +2325,16 @@ export function StudioClient({
</Button> </Button>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<Button
variant="outline"
className="w-full gap-1.5"
disabled={importingId !== null}
onClick={() =>
setReview({ items: [detail], single: true })
}
>
Complete missing data
</Button>
{!detail.nitroExists && ( {!detail.nitroExists && (
<> <>
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]"> <p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
+2 -2
View File
@@ -5,8 +5,8 @@ import { canAccess, getApiAdminContext } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
import { import {
beginCatalogExport,
catalogExportEnabled, catalogExportEnabled,
catalogExportQueue,
isCatalogMutation, isCatalogMutation,
} from "@/lib/services/catalog-git-queue"; } from "@/lib/services/catalog-git-queue";
@@ -92,7 +92,7 @@ export function withAdmin(
catalogExportEnabled() && catalogExportEnabled() &&
isCatalogMutation(request.method, request.nextUrl.pathname) isCatalogMutation(request.method, request.nextUrl.pathname)
) { ) {
finishExport = await catalogExportQueue().begin(); finishExport = await beginCatalogExport();
} }
const response = await handler(request, context, routeContext); const response = await handler(request, context, routeContext);
if ( if (
@@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
catalogGitEnvironment,
decryptCatalogToken,
publicCatalogConfig,
validateCatalogConfig,
} from "./catalog-git-config";
const input = {
enabled: true,
remote: "https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily",
branch: "Beta-3",
username: "remco",
token: "private-token",
};
describe("Gitea catalog configuration", () => {
beforeEach(() => vi.stubEnv("AUTH_SECRET", "test-auth-secret"));
afterEach(() => vi.unstubAllEnvs());
it("supports self-hosted Gitea and encrypts the token", () => {
const config = validateCatalogConfig(input, null);
expect(config.remote).toBe(`${input.remote}.git`);
expect(config.encryptedToken).not.toContain(input.token);
expect(decryptCatalogToken(config.encryptedToken)).toBe(input.token);
expect(JSON.stringify(publicCatalogConfig(config))).not.toContain(
input.token,
);
expect(publicCatalogConfig(config).provider).toBe("gitea");
});
it("preserves saved token only for the same repository and user", () => {
const previous = validateCatalogConfig(input, null);
expect(
validateCatalogConfig({ ...input, token: "" }, previous).encryptedToken,
).toBe(previous.encryptedToken);
expect(() =>
validateCatalogConfig(
{ ...input, token: "", remote: "https://other.example/user/repo" },
previous,
),
).toThrow();
});
it("rejects embedded credentials and unsafe branches", () => {
expect(() =>
validateCatalogConfig(
{ ...input, remote: "https://user:secret@host/user/repo" },
null,
),
).toThrow();
for (const branch of [
"--help",
"main..evil",
"../branch",
"a.lock",
"a\\b",
])
expect(() => validateCatalogConfig({ ...input, branch }, null)).toThrow();
});
it("supplies credentials through environment and disables redirects", () => {
const config = validateCatalogConfig(input, null);
const env = catalogGitEnvironment(config);
expect(env.GIT_CONFIG_KEY_0).toBe(
"http.https://gitlab.epicnabbo.nl/.extraHeader",
);
expect(env.GIT_CONFIG_VALUE_1).toBe("false");
});
});
+177
View File
@@ -0,0 +1,177 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
randomUUID,
} from "node:crypto";
import { promises as fs, readFileSync } from "node:fs";
import path from "node:path";
import { CATALOG_BRANCH, CATALOG_REMOTE } from "./catalog-git-core";
export interface ManagedCatalogConfig {
enabled: boolean;
remote: string;
branch: string;
username: string;
encryptedToken: string;
}
export function catalogStateRoot() {
return (
process.env.CATALOG_GIT_STATE_DIR ||
path.join(process.cwd(), "storage", "catalog-git")
);
}
export function readManagedCatalogConfig(): ManagedCatalogConfig | null {
try {
return JSON.parse(
readFileSync(path.join(catalogStateRoot(), "config.json"), "utf8"),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw new Error("Catalog Git configuration cannot be read");
}
}
function key() {
const secret = process.env.AUTH_SECRET;
if (!secret)
throw new Error("The application authentication secret is unavailable");
return createHash("sha256").update(secret).digest();
}
export function encryptCatalogToken(token: string) {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key(), iv);
const value = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
return [iv, cipher.getAuthTag(), value]
.map((part) => part.toString("base64"))
.join(".");
}
export function decryptCatalogToken(value: string) {
if (!value) return "";
const [iv, tag, data] = value
.split(".")
.map((part) => Buffer.from(part, "base64"));
const cipher = createDecipheriv("aes-256-gcm", key(), iv);
cipher.setAuthTag(tag);
return Buffer.concat([cipher.update(data), cipher.final()]).toString("utf8");
}
export function validateCatalogConfig(
input: unknown,
previous: ManagedCatalogConfig | null,
): ManagedCatalogConfig {
const value = input as Record<string, unknown>;
if (
!value ||
typeof value.remote !== "string" ||
typeof value.branch !== "string" ||
typeof value.username !== "string" ||
typeof value.enabled !== "boolean"
)
throw new Error(
"Repository, branch, username and enabled state are required",
);
let url: URL;
try {
url = new URL(value.remote.trim());
} catch {
throw new Error("Enter a valid HTTPS Gitea repository URL");
}
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.search ||
url.hash ||
!/^\/(?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+(?:\.git)?\/?$/.test(url.pathname)
)
throw new Error(
"Use an HTTPS repository URL without credentials or query parameters",
);
const remote = `${url
.toString()
.replace(/\/$/, "")
.replace(/\.git$/, "")}.git`;
const branch = value.branch.trim(),
username = value.username.trim();
if (
!branch ||
branch.startsWith("-") ||
branch.startsWith("/") ||
branch.endsWith("/") ||
branch.endsWith(".") ||
branch.includes("..") ||
branch.includes("//") ||
branch.includes("@{") ||
/[\s~^:?*[\\]/.test(branch) ||
[...branch].some(
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
) ||
branch
.split("/")
.some((part) => part.startsWith(".") || part.endsWith(".lock")) ||
branch === "@"
)
throw new Error("Enter a valid Git branch");
if (!username || /[\r\n:]/.test(username))
throw new Error("Enter a valid Gitea username");
const token = typeof value.token === "string" ? value.token.trim() : "";
if (/[\r\n]/.test(token)) throw new Error("Invalid access token");
const same = previous?.remote === remote && previous.username === username;
const encryptedToken = token
? encryptCatalogToken(token)
: same
? previous.encryptedToken
: "";
if (value.enabled && !encryptedToken)
throw new Error("Provide a Gitea token before enabling export");
return { enabled: value.enabled, remote, branch, username, encryptedToken };
}
export function publicCatalogConfig(config = readManagedCatalogConfig()) {
return {
provider: "gitea",
enabled: config?.enabled ?? false,
remote: config?.remote ?? CATALOG_REMOTE,
branch: config?.branch ?? CATALOG_BRANCH,
username: config?.username ?? "",
hasToken: !!config?.encryptedToken,
};
}
export async function saveCatalogConfig(config: ManagedCatalogConfig) {
const root = catalogStateRoot();
await fs.mkdir(root, { recursive: true });
const temp = path.join(root, `config-${randomUUID()}.tmp`);
try {
await fs.writeFile(temp, JSON.stringify(config), {
mode: 0o600,
flag: "wx",
});
await fs.rename(temp, path.join(root, "config.json"));
} finally {
await fs.rm(temp, { force: true });
}
}
export function catalogGitEnvironment(
config: ManagedCatalogConfig,
): NodeJS.ProcessEnv {
const token = decryptCatalogToken(config.encryptedToken);
return {
...process.env,
GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_COUNT: "2",
GIT_CONFIG_KEY_0: `http.${new URL(config.remote).origin}/.extraHeader`,
GIT_CONFIG_VALUE_0:
"Authorization: Basic " +
Buffer.from(`${config.username}:${token}`).toString("base64"),
GIT_CONFIG_KEY_1: "http.followRedirects",
GIT_CONFIG_VALUE_1: "false",
};
}
export function managedCatalogCheckout(config: ManagedCatalogConfig) {
return path.join(
catalogStateRoot(),
"checkouts",
createHash("sha256")
.update(`${config.remote}\n${config.branch}`)
.digest("hex")
.slice(0, 24),
);
}
+2 -1
View File
@@ -107,6 +107,7 @@ export async function publishCatalogFiles(options: {
remote: string; remote: string;
branch: string; branch: string;
files: CatalogFile[]; files: CatalogFile[];
env?: NodeJS.ProcessEnv;
}) { }) {
const { checkout, remote, branch, files } = options; const { checkout, remote, branch, files } = options;
const git = async (...args: string[]) => { const git = async (...args: string[]) => {
@@ -114,7 +115,7 @@ export async function publishCatalogFiles(options: {
cwd: checkout, cwd: checkout,
timeout: 120_000, timeout: 120_000,
maxBuffer: 16 * 1024 * 1024, maxBuffer: 16 * 1024 * 1024,
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, env: { ...process.env, ...options.env, GIT_TERMINAL_PROMPT: "0" },
}); });
return stdout.trim(); return stdout.trim();
}; };
+15 -4
View File
@@ -1,18 +1,22 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import { hostname } from "node:os"; import { hostname } from "node:os";
import path from "node:path"; import path from "node:path";
import {
catalogGitEnvironment,
readManagedCatalogConfig,
} from "./catalog-git-config";
import { import {
CATALOG_BRANCH, CATALOG_BRANCH,
CATALOG_REMOTE, CATALOG_REMOTE,
publishCatalogFiles, publishCatalogFiles,
recoverCatalogQueue, recoverCatalogQueue,
} from "./catalog-git-core"; } from "./catalog-git-core";
import { ensureManagedCatalogCheckout } from "./catalog-git-managed";
import { catalogExportEnabled, catalogExportQueue } from "./catalog-git-queue"; import { catalogExportEnabled, catalogExportQueue } from "./catalog-git-queue";
import { createCatalogSnapshot } from "./catalog-git-snapshot"; import { createCatalogSnapshot } from "./catalog-git-snapshot";
export async function runCatalogExport() { export async function runCatalogExport() {
const checkout = process.env.CATALOG_GIT_CHECKOUT?.trim(); if (!catalogExportEnabled()) return;
if (!catalogExportEnabled() || !checkout) return;
const queue = catalogExportQueue(); const queue = catalogExportQueue();
await queue.entries(); await queue.entries();
await recoverCatalogQueue(queue.root); await recoverCatalogQueue(queue.root);
@@ -51,10 +55,17 @@ export async function runCatalogExport() {
// Any operation started/completed during capture invalidates the snapshot. // Any operation started/completed during capture invalidates the snapshot.
const current = await queue.batch(); const current = await queue.batch();
if (!current || JSON.stringify(current) !== JSON.stringify(batch)) return; if (!current || JSON.stringify(current) !== JSON.stringify(batch)) return;
const config = readManagedCatalogConfig();
if (config && !config.enabled) return;
const checkout = config
? await ensureManagedCatalogCheckout(config)
: process.env.CATALOG_GIT_CHECKOUT?.trim();
if (!checkout) return;
const commit = await publishCatalogFiles({ const commit = await publishCatalogFiles({
checkout, checkout,
remote: CATALOG_REMOTE, remote: config?.remote ?? CATALOG_REMOTE,
branch: CATALOG_BRANCH, branch: config?.branch ?? CATALOG_BRANCH,
env: config ? catalogGitEnvironment(config) : undefined,
files, files,
}); });
await queue.complete(batch); await queue.complete(batch);
+72
View File
@@ -0,0 +1,72 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import {
catalogGitEnvironment,
type ManagedCatalogConfig,
managedCatalogCheckout,
} from "./catalog-git-config";
const exec = promisify(execFile);
export async function testCatalogConnection(config: ManagedCatalogConfig) {
try {
const result = await exec(
"git",
[
"ls-remote",
"--exit-code",
"--heads",
config.remote,
`refs/heads/${config.branch}`,
],
{
env: catalogGitEnvironment(config),
timeout: 30000,
maxBuffer: 1024 * 1024,
},
);
if (!result.stdout.trim()) throw new Error("Missing branch");
} catch {
throw new Error(
"Cannot read this Gitea repository and branch. Check the URL, username, token and server Git installation.",
);
}
}
export async function ensureManagedCatalogCheckout(
config: ManagedCatalogConfig,
) {
const checkout = managedCatalogCheckout(config);
try {
await fs.access(path.join(checkout, ".git"));
return checkout;
} catch {
/* first export */
}
await fs.mkdir(path.dirname(checkout), { recursive: true });
const temporary = `${checkout}.clone-${randomUUID()}`;
try {
await exec(
"git",
[
"clone",
"--single-branch",
"--branch",
config.branch,
"--",
config.remote,
temporary,
],
{
env: catalogGitEnvironment(config),
timeout: 600000,
maxBuffer: 4 * 1024 * 1024,
},
);
await fs.rename(temporary, checkout);
return checkout;
} finally {
await fs.rm(temporary, { recursive: true, force: true });
}
}
+30 -6
View File
@@ -1,15 +1,19 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import {
catalogStateRoot,
readManagedCatalogConfig,
} from "./catalog-git-config";
import { CatalogExportQueue } from "./catalog-git-core"; import { CatalogExportQueue } from "./catalog-git-core";
export function catalogExportEnabled() { export function catalogExportEnabled() {
return Boolean(process.env.CATALOG_GIT_CHECKOUT?.trim()); const config = readManagedCatalogConfig();
return config
? config.enabled
: Boolean(process.env.CATALOG_GIT_CHECKOUT?.trim());
} }
export function catalogExportQueue() { export function catalogExportQueue() {
return new CatalogExportQueue( return new CatalogExportQueue(catalogStateRoot());
process.env.CATALOG_GIT_STATE_DIR ||
path.join(process.cwd(), "storage", "catalog-git"),
);
} }
export function isCatalogMutation(method: string, pathname: string) { export function isCatalogMutation(method: string, pathname: string) {
return ( return (
@@ -45,10 +49,30 @@ export async function withCatalogExport<T>(
operation: () => Promise<T>, operation: () => Promise<T>,
): Promise<T> { ): Promise<T> {
if (!catalogExportEnabled()) return operation(); if (!catalogExportEnabled()) return operation();
const finish = await catalogExportQueue().begin(); const finish = await beginCatalogExport();
try { try {
return await operation(); return await operation();
} finally { } finally {
await finish(); await finish();
} }
} }
export async function scheduleCatalogExport() {
if (!readManagedCatalogConfig()?.enabled) return;
try {
const { after } = await import("next/server");
after(async () => {
const { runCatalogExport } = await import("./catalog-git-export");
await runCatalogExport();
});
} catch {
/* Standalone worker contexts use their scheduled retry. */
}
}
export async function beginCatalogExport() {
const finish = await catalogExportQueue().begin();
return async () => {
await finish();
await scheduleCatalogExport();
};
}
@@ -0,0 +1,120 @@
// @vitest-environment node
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execute: vi.fn(),
select: vi.fn(),
stat: vi.fn(),
conflict: vi.fn(),
build: vi.fn(),
append: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
db: { select: mocks.select, execute: mocks.execute },
ItemsBase: {
id: "id",
itemName: "item_name",
spriteId: "sprite_id",
type: "type",
},
CatalogPages: {},
}));
vi.mock("node:fs", () => ({
existsSync: () => true,
promises: {
stat: mocks.stat,
readFile: vi.fn().mockResolvedValue(Buffer.alloc(256)),
},
}));
vi.mock("@/lib/services/furni-asset-dirs", () => ({
DEFAULT_FURNI_NITRO_DIR: "/nitro",
getFurniAssetWriteTargets: async () => ({
swfDir: "/swf",
nitroDir: "/nitro",
iconDir: "/icons",
mirrorDirs: [],
}),
}));
vi.mock("@/lib/services/furni-data", () => ({
findFurniDataIdConflict: mocks.conflict,
buildFurniEntry: mocks.build,
appendFurniEntry: mocks.append,
}));
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
lookupOfficialHabboFurni: async () => null,
}));
vi.mock("@/lib/services/habbo-gamedata-hotel", () => ({
getHabboGamedataHotel: async () => "it",
}));
vi.mock("@/lib/services/import/core/download", () => ({
downloadFile: vi.fn(),
tryDownloadCandidates: async () => false,
}));
vi.mock("@/lib/services/swf-to-nitro", () => ({
parseNitroBundle: () => ({ json: {} }),
extractIconFromSwfAsync: async () => null,
}));
import { importSingleFurni } from "./furni-import";
const input = {
id: 42,
classname: "recycler_scrapbookA",
name: "Scrapbook",
description: "Book",
type: "flooritem",
revision: 1,
category: "unknown",
repairExisting: true,
};
const dialect = new MySqlDialect();
function queries() {
return mocks.execute.mock.calls.map(([query]) => dialect.sqlToQuery(query));
}
describe("completion of existing furniture", () => {
beforeEach(() => {
vi.clearAllMocks();
const rows = [
[{ id: 2000037257, spriteId: 9001, type: "s" }],
[{ itemName: input.classname }],
];
mocks.select.mockImplementation(() => ({
from: () => ({ where: () => ({ limit: async () => rows.shift() }) }),
}));
mocks.stat.mockResolvedValue({ size: 256 });
mocks.conflict.mockResolvedValue(null);
mocks.execute.mockResolvedValue([[{ id: 123, page_id: "15" }], []]);
mocks.build.mockImplementation((value) => value);
});
it("reuses local item and sprite IDs without rewriting SQL settings or offers", async () => {
const result = await importSingleFurni(input);
expect(result.ok).toBe(true);
expect(result.itemId).toBe(2000037257);
expect(mocks.conflict).toHaveBeenCalledWith(9001, input.classname);
expect(mocks.build).toHaveBeenCalledWith(
expect.objectContaining({ id: 9001, spriteId: 9001 }),
);
expect(mocks.append).toHaveBeenCalled();
expect(
queries().every((query) => query.sql.trim().startsWith("SELECT")),
).toBe(true);
expect(queries()[0].params).toContain("2000037257");
});
it("never deletes existing SQL when the nitro cannot be recovered", async () => {
mocks.stat.mockRejectedValue(new Error("missing"));
const result = await importSingleFurni(input);
expect(result.ok).toBe(false);
expect(result.error).toContain("preserved");
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.append).not.toHaveBeenCalled();
});
it("rejects an occupied furnidata sprite before any mutation", async () => {
mocks.conflict.mockResolvedValue("other_furniture");
const result = await importSingleFurni(input);
expect(result.ok).toBe(false);
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.append).not.toHaveBeenCalled();
});
});
+46 -22
View File
@@ -510,6 +510,7 @@ export async function importSingleFurni(params: {
category: string; category: string;
skipFurniDataWrite?: boolean; skipFurniDataWrite?: boolean;
updateExisting?: boolean; updateExisting?: boolean;
repairExisting?: boolean;
onProgress?: (status: string) => void; onProgress?: (status: string) => void;
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */ /** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
sourceSwfBaseUrl?: string; sourceSwfBaseUrl?: string;
@@ -527,6 +528,7 @@ export async function importSingleFurni(params: {
category, category,
skipFurniDataWrite, skipFurniDataWrite,
updateExisting, updateExisting,
repairExisting,
onProgress, onProgress,
sourceSwfBaseUrl, sourceSwfBaseUrl,
nitroBaseUrl: sourceNitroBaseUrl, nitroBaseUrl: sourceNitroBaseUrl,
@@ -536,18 +538,22 @@ export async function importSingleFurni(params: {
// Check if already exists by classname OR by spriteId (primary key collision). // Check if already exists by classname OR by spriteId (primary key collision).
const [existsByName] = await db const [existsByName] = await db
.select({ id: ItemsBase.id }) .select({
id: ItemsBase.id,
spriteId: ItemsBase.spriteId,
type: ItemsBase.type,
})
.from(ItemsBase) .from(ItemsBase)
.where(eq(ItemsBase.itemName, classname)) .where(eq(ItemsBase.itemName, classname))
.limit(1); .limit(1);
if (existsByName && !updateExisting) { if (existsByName && !updateExisting && !repairExisting) {
return { return {
ok: false, ok: false,
warnings, warnings,
error: `Item "${classname}" already exists (id: ${existsByName.id})`, error: `Item "${classname}" already exists (id: ${existsByName.id})`,
}; };
} }
if (existsByName && existsByName.id !== originalId) { if (existsByName && existsByName.id !== originalId && !repairExisting) {
// The classname already occupies a different spriteId. Reassigning the // The classname already occupies a different spriteId. Reassigning the
// id would orphan every reference (catalog item_ids, player items, // id would orphan every reference (catalog item_ids, player items,
// furnidata), so this can never be an "update". // furnidata), so this can never be an "update".
@@ -557,10 +563,14 @@ export async function importSingleFurni(params: {
error: `Item "${classname}" already exists with id ${existsByName.id} — cannot reassign to spriteId ${originalId}`, error: `Item "${classname}" already exists with id ${existsByName.id} — cannot reassign to spriteId ${originalId}`,
}; };
} }
const itemId = repairExisting && existsByName ? existsByName.id : originalId;
const spriteId =
repairExisting && existsByName ? existsByName.spriteId : originalId;
const preservingExisting = !!(repairExisting && existsByName);
const [existsById] = await db const [existsById] = await db
.select({ itemName: ItemsBase.itemName }) .select({ itemName: ItemsBase.itemName })
.from(ItemsBase) .from(ItemsBase)
.where(eq(ItemsBase.id, originalId)) .where(eq(ItemsBase.id, itemId))
.limit(1); .limit(1);
if (existsById && (!existsByName || existsById.itemName !== classname)) { if (existsById && (!existsByName || existsById.itemName !== classname)) {
// The spriteId is owned by a different classname. Even with // The spriteId is owned by a different classname. Even with
@@ -575,17 +585,20 @@ export async function importSingleFurni(params: {
const isUpdate = !!existsByName; const isUpdate = !!existsByName;
// ── Check if spriteId already used in FurnitureData.json ── // ── Check if spriteId already used in FurnitureData.json ──
const takenBy = await findFurniDataIdConflict(originalId, classname); const takenBy = await findFurniDataIdConflict(spriteId, classname);
if (takenBy && !updateExisting) { if (takenBy) {
return { return {
ok: false, ok: false,
warnings, warnings,
error: `spriteId ${originalId} already used by "${takenBy}" in FurnitureData.json`, error: `spriteId ${spriteId} already used by "${takenBy}" in FurnitureData.json`,
}; };
} }
const itemType = type === "wallitem" ? "i" : "s"; const itemType = preservingExisting
const spriteId = originalId; ? existsByName.type
: type === "wallitem"
? "i"
: "s";
// ── Cross-reference with configured official Habbo furnidata ────── // ── Cross-reference with configured official Habbo furnidata ──────
let officialHabbo: OfficialHabboFurniEntry | null = null; let officialHabbo: OfficialHabboFurniEntry | null = null;
@@ -608,7 +621,11 @@ export async function importSingleFurni(params: {
// ── Insert into DB with enriched data ────────────────────────────── // ── Insert into DB with enriched data ──────────────────────────────
onProgress?.("writing_db"); onProgress?.("writing_db");
try { try {
if (isUpdate) { if (preservingExisting) {
warnings.push(
`Existing furniture #${itemId}: SQL settings and catalog offers preserved`,
);
} else if (isUpdate) {
// NOTE: live Arcturus `items_base` has no description/revision/rare columns — // NOTE: live Arcturus `items_base` has no description/revision/rare columns —
// those metadata fields are persisted in FurnitureData.json (see entry below), // those metadata fields are persisted in FurnitureData.json (see entry below),
// not in the DB. Only refresh the columns this import manages. // not in the DB. Only refresh the columns this import manages.
@@ -704,8 +721,12 @@ export async function importSingleFurni(params: {
// Download icon + SWF concurrently (independent assets). Each tries its // Download icon + SWF concurrently (independent assets). Each tries its
// candidate URLs in order until one succeeds. // candidate URLs in order until one succeeds.
const [iconOkResult, swfOk] = await Promise.all([ const [iconOkResult, swfOk] = await Promise.all([
tryDownloadCandidates(iconUrls, iconPath, "png"), preservingExisting && existsSync(iconPath)
tryDownloadCandidates(swfUrls, swfPath, "swf"), ? Promise.resolve(true)
: tryDownloadCandidates(iconUrls, iconPath, "png"),
preservingExisting && existsSync(swfPath)
? Promise.resolve(true)
: tryDownloadCandidates(swfUrls, swfPath, "swf"),
]); ]);
let iconOk = iconOkResult; let iconOk = iconOkResult;
@@ -799,16 +820,19 @@ export async function importSingleFurni(params: {
} }
} }
try { try {
await db.execute( if (!isUpdate)
sql`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`, await db.execute(
); sql`DELETE FROM items_base WHERE id = ${itemId} AND item_name = ${classname}`,
);
} catch { } catch {
/* best effort cleanup */ /* best effort cleanup */
} }
return { return {
ok: false, ok: false,
warnings, warnings,
error: "Import rolled back: no .nitro file could be created", error: isUpdate
? "No .nitro file could be created; existing furniture was preserved"
: "Import rolled back: no .nitro file could be created",
}; };
} }
@@ -924,7 +948,7 @@ export async function importSingleFurni(params: {
// Update items_base with ALL enriched data // Update items_base with ALL enriched data
try { try {
const update = buildItemsBaseEnrichmentUpdate({ const update = buildItemsBaseEnrichmentUpdate({
id: spriteId, id: itemId,
dims, dims,
cansiton: autoInteraction.canSit, cansiton: autoInteraction.canSit,
canlayon: autoInteraction.canLay, canlayon: autoInteraction.canLay,
@@ -933,7 +957,7 @@ export async function importSingleFurni(params: {
interactionType, interactionType,
interactionModesCount, interactionModesCount,
}); });
await db.execute(update); if (!preservingExisting) await db.execute(update);
} catch (err) { } catch (err) {
warnings.push( warnings.push(
`items_base dimension update failed: ${(err as Error).message}`, `items_base dimension update failed: ${(err as Error).message}`,
@@ -972,16 +996,16 @@ export async function importSingleFurni(params: {
let catalogItemId: number | null = null; let catalogItemId: number | null = null;
let catalogUpdated = false; let catalogUpdated = false;
try { try {
const itemIdStr = String(spriteId); const itemIdStr = String(itemId);
// Check if this item already exists anywhere in the catalog // Check if this item already exists anywhere in the catalog
const [existingCatalog] = (await db.execute(sql` const [existingCatalog] = (await db.execute(sql`
SELECT id, page_id FROM catalog_items WHERE item_ids = ${itemIdStr} LIMIT 1 SELECT id, page_id FROM catalog_items WHERE FIND_IN_SET(${itemIdStr}, REPLACE(item_ids, ';', ',')) > 0 LIMIT 1
`)) as unknown as [Array<{ id: number; page_id: string }>, unknown]; `)) as unknown as [Array<{ id: number; page_id: string }>, unknown];
if (existingCatalog.length > 0) { if (existingCatalog.length > 0) {
catalogItemId = existingCatalog[0].id; catalogItemId = existingCatalog[0].id;
if (updateExisting) { if (updateExisting && !preservingExisting) {
// Update existing catalog entry: price, page, etc. // Update existing catalog entry: price, page, etc.
const categoryPageId = await getOrCreateCategoryPage( const categoryPageId = await getOrCreateCategoryPage(
classname, classname,
@@ -1025,7 +1049,7 @@ export async function importSingleFurni(params: {
return { return {
ok: true, ok: true,
itemId: spriteId, itemId,
catalogItemId, catalogItemId,
catalogUpdated, catalogUpdated,
dimensions: dims, dimensions: dims,