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

@@ -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 { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { publicCatalogConfig } from "@/lib/services/catalog-git-config";
import {
catalogExportEnabled,
catalogExportQueue,
catalogExportStatus,
scheduleCatalogExport,
} from "@/lib/services/catalog-git-queue";
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () =>
apiOk(await catalogExportStatus()),
);
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
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 () => {
if (!catalogExportEnabled())
return apiError("Catalog repository export is not configured", 400);
await catalogExportQueue().request();
await scheduleCatalogExport();
return apiOk(await catalogExportStatus());
});
@@ -125,6 +125,7 @@ export const POST = withAdmin(
revision: item.revision ?? 0,
category: item.category ?? "unknown",
skipFurniDataWrite: false,
repairExisting: body.repairExisting === true,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl,
iconBaseUrl: source?.iconBaseUrl,
+2
View File
@@ -507,6 +507,7 @@ export const POST = withAdmin(
nitroBaseUrl: source?.nitroBaseUrl || undefined,
iconBaseUrl: source?.iconBaseUrl || undefined,
skipFurniDataWrite: false,
repairExisting: body.repairExisting === true,
});
if (result.ok) {
@@ -679,6 +680,7 @@ export const POST = withAdmin(
revision: revision ?? 0,
category: category ?? "unknown",
updateExisting: updateExisting === true,
repairExisting: body.repairExisting === true,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl || undefined,
iconBaseUrl: source?.iconBaseUrl || undefined,