Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
This reverts commit488b6e57c4, reversing changes made tob506b4499a.
This commit is contained in:
1 parent
488b6e57c4
commit
b1ddda66ff
802 files changed
+61370
-76659
No files matched your search
@@ -156,7 +156,7 @@ async function emailStaff(input: SendAlertInput): Promise<boolean> {
|
||||
? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>`
|
||||
: "") +
|
||||
`<p style="margin-top:16px;color:#888;font-size:12px">` +
|
||||
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/ase/system/operations/alerts">view alerts</a></p>`;
|
||||
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/admin/alerts">view alerts</a></p>`;
|
||||
|
||||
try {
|
||||
return await sendMail(to, subject, html);
|
||||
|
||||
@@ -87,61 +87,6 @@ describe("logAudit", () => {
|
||||
expect(JSON.parse(data.diff).username).toEqual({ from: "foo", to: "bar" });
|
||||
});
|
||||
|
||||
it("redacts sensitive keys recursively in nested objects and arrays", async () => {
|
||||
insertValues.mockResolvedValue({ id: 1 });
|
||||
await logAudit({
|
||||
userId: 1,
|
||||
action: "update",
|
||||
target: "user",
|
||||
before: {
|
||||
profile: {
|
||||
authTicket: "private-ticket",
|
||||
credentials: { newPassword: "one-time-password" },
|
||||
},
|
||||
integrations: [{ api_key: "private-key" }],
|
||||
},
|
||||
});
|
||||
|
||||
const before = JSON.parse(insertValues.mock.calls[0][0].before);
|
||||
expect(before.profile.authTicket).toBe("[Redacted]");
|
||||
expect(before.profile.credentials.newPassword).toBe("[Redacted]");
|
||||
expect(before.integrations[0].api_key).toBe("[Redacted]");
|
||||
});
|
||||
|
||||
it("persists correlation, domain, outcome, reason, and IP evidence", async () => {
|
||||
insertValues.mockResolvedValue({ id: 1 });
|
||||
await logAudit({
|
||||
userId: 1,
|
||||
action: "ban",
|
||||
target: "user",
|
||||
correlationId: "corr-123",
|
||||
domain: "people",
|
||||
outcome: "denied",
|
||||
reason: "Policy requirement was not met",
|
||||
ipAddress: "127.0.0.1",
|
||||
});
|
||||
|
||||
expect(insertValues.mock.calls[0][0]).toMatchObject({
|
||||
correlationId: "corr-123",
|
||||
domain: "people",
|
||||
outcome: "denied",
|
||||
reason: "Policy requirement was not met",
|
||||
ipAddress: "127.0.0.1",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes through the injected transaction when one is supplied", async () => {
|
||||
const transactionValues = vi.fn().mockResolvedValue({ id: 1 });
|
||||
const transactionInsert = vi.fn(() => ({ values: transactionValues }));
|
||||
|
||||
await logAudit({ userId: 1, action: "update", target: "settings" }, {
|
||||
insert: transactionInsert,
|
||||
} as never);
|
||||
|
||||
expect(transactionInsert).toHaveBeenCalledOnce();
|
||||
expect(transactionValues).toHaveBeenCalledOnce();
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
it("omits diff when only before or after is missing", async () => {
|
||||
insertValues.mockResolvedValue({ id: 1 });
|
||||
await logAudit({
|
||||
@@ -241,21 +186,4 @@ describe("getAuditLogs", () => {
|
||||
const result = await getAuditLogs();
|
||||
expect(result.rows[0].username).toBe("User #99");
|
||||
});
|
||||
it("fails closed beyond the redaction depth cap without mutating the input", async () => {
|
||||
const sentinel = "raw-depth-secret";
|
||||
const deep = {
|
||||
a: { b: { c: { d: { e: { f: { g: { secret: sentinel } } } } } } },
|
||||
};
|
||||
insertValues.mockResolvedValue({ id: 1 });
|
||||
await logAudit({
|
||||
userId: 1,
|
||||
action: "update",
|
||||
target: "user",
|
||||
before: { deep, state: "before" },
|
||||
after: { deep, state: "after" },
|
||||
});
|
||||
const data = insertValues.mock.calls[0][0];
|
||||
expect(`${data.before}${data.after}${data.diff}`).not.toContain(sentinel);
|
||||
expect(deep.a.b.c.d.e.f.g.secret).toBe(sentinel);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,13 @@
|
||||
import { count, desc, inArray, like, or } from "drizzle-orm";
|
||||
import type { HousekeepingDomainId } from "@/features/housekeeping/migration/types";
|
||||
import { AdminAuditLog, type Db, db, User } from "@/lib/db";
|
||||
import { AdminAuditLog, db, User } from "@/lib/db";
|
||||
|
||||
export interface AuditEntry {
|
||||
interface AuditEntry {
|
||||
userId: number;
|
||||
action: string;
|
||||
target: string;
|
||||
targetId?: number;
|
||||
before?: Record<string, unknown>;
|
||||
after?: Record<string, unknown>;
|
||||
correlationId?: string;
|
||||
outcome?: "intent" | "success" | "failure" | "partial" | "denied";
|
||||
reason?: string;
|
||||
domain?: HousekeepingDomainId;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
export type HousekeepingAuditTransaction = Pick<Db, "insert">;
|
||||
|
||||
export interface HousekeepingAuditWriter {
|
||||
write(
|
||||
entry: AuditEntry,
|
||||
transaction?: HousekeepingAuditTransaction,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
const SENSITIVE_KEY_RE =
|
||||
@@ -30,8 +15,7 @@ const SENSITIVE_KEY_RE =
|
||||
const REDACTED = "[Redacted]";
|
||||
|
||||
function sanitizeAuditPayload(value: unknown, depth = 0): unknown {
|
||||
if (depth > 6) return REDACTED;
|
||||
if (value == null) return value;
|
||||
if (depth > 6 || value == null) return value;
|
||||
if (Array.isArray(value))
|
||||
return value.map((v) => sanitizeAuditPayload(v, depth + 1));
|
||||
if (typeof value !== "object") return value;
|
||||
@@ -63,10 +47,7 @@ function computeDiff(
|
||||
return Object.keys(diff).length > 0 ? diff : null;
|
||||
}
|
||||
|
||||
export async function logAudit(
|
||||
entry: AuditEntry,
|
||||
transaction?: HousekeepingAuditTransaction,
|
||||
): Promise<void> {
|
||||
export async function logAudit(entry: AuditEntry): Promise<void> {
|
||||
const sanitizedBefore = entry.before
|
||||
? (sanitizeAuditPayload(entry.before) as Record<string, unknown>)
|
||||
: undefined;
|
||||
@@ -75,8 +56,7 @@ export async function logAudit(
|
||||
: undefined;
|
||||
const diff = computeDiff(sanitizedBefore, sanitizedAfter);
|
||||
|
||||
const auditWriter: HousekeepingAuditTransaction = transaction ?? db;
|
||||
await auditWriter.insert(AdminAuditLog).values({
|
||||
await db.insert(AdminAuditLog).values({
|
||||
userId: entry.userId,
|
||||
action: entry.action,
|
||||
target: entry.target,
|
||||
@@ -84,19 +64,10 @@ export async function logAudit(
|
||||
before: sanitizedBefore ? JSON.stringify(sanitizedBefore) : null,
|
||||
after: sanitizedAfter ? JSON.stringify(sanitizedAfter) : null,
|
||||
diff: diff ? JSON.stringify(diff) : null,
|
||||
correlationId: entry.correlationId,
|
||||
outcome: entry.outcome,
|
||||
reason: entry.reason,
|
||||
domain: entry.domain,
|
||||
ipAddress: entry.ipAddress,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export const housekeepingAuditWriter: HousekeepingAuditWriter = {
|
||||
write: logAudit,
|
||||
};
|
||||
|
||||
interface GetLogsOptions {
|
||||
search?: string;
|
||||
page?: number;
|
||||
|
||||
@@ -134,16 +134,13 @@ async function fetchWithFallback(url: string): Promise<string> {
|
||||
export async function fetchSourceFurnidata(
|
||||
url: string,
|
||||
now = Date.now(),
|
||||
signal?: AbortSignal,
|
||||
): Promise<SourceFurni[]> {
|
||||
signal?.throwIfAborted();
|
||||
const hit = cache.get(url);
|
||||
if (hit && now && now - hit.ts < TTL) return hit.list;
|
||||
let body: string;
|
||||
try {
|
||||
const timeout = AbortSignal.timeout(30000);
|
||||
const res = await fetch(url, {
|
||||
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
headers: browserHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -162,8 +159,7 @@ export async function fetchSourceFurnidata(
|
||||
} else {
|
||||
body = await fetchWithFallback(url);
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
} catch {
|
||||
body = await fetchWithFallback(url);
|
||||
}
|
||||
if (body.trimStart().startsWith("<")) {
|
||||
@@ -247,13 +243,11 @@ export async function cloneSingleFurni(params: {
|
||||
source: CloneSource;
|
||||
entry: SourceFurni;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Skip the per-item FurnitureData.json write and return the entry instead,
|
||||
* so batch callers can append everything in one lock + write cycle. */
|
||||
deferFurniData?: boolean;
|
||||
}): Promise<CloneResult> {
|
||||
const { source, entry, onProgress, signal, deferFurniData } = params;
|
||||
signal?.throwIfAborted();
|
||||
const { source, entry, onProgress, deferFurniData } = params;
|
||||
const { classname, itemType } = entry;
|
||||
const warnings: string[] = [];
|
||||
|
||||
@@ -293,7 +287,6 @@ export async function cloneSingleFurni(params: {
|
||||
|
||||
// Download .nitro + icon directly from the source hotel.
|
||||
onProgress?.("downloading");
|
||||
signal?.throwIfAborted();
|
||||
const nitroPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${classname}.nitro`,
|
||||
@@ -307,7 +300,6 @@ export async function cloneSingleFurni(params: {
|
||||
nitroPath,
|
||||
{
|
||||
maxRetries: 2,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!dl.ok) {
|
||||
@@ -342,7 +334,6 @@ export async function cloneSingleFurni(params: {
|
||||
{
|
||||
maxRetries: 1,
|
||||
validate: "png",
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!iconDl.ok) {
|
||||
@@ -375,7 +366,6 @@ export async function cloneSingleFurni(params: {
|
||||
}
|
||||
|
||||
onProgress?.("writing_db");
|
||||
signal?.throwIfAborted();
|
||||
const stackHeight =
|
||||
entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0;
|
||||
// allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
|
||||
@@ -432,7 +422,6 @@ export async function cloneSingleFurni(params: {
|
||||
// AND our offerid. The source's offerid points at the source hotel's sprite
|
||||
// ids, which would silently break the emulator's catalog offer lookup.
|
||||
onProgress?.("writing_furnidata");
|
||||
signal?.throwIfAborted();
|
||||
const furniDataEntry = {
|
||||
...entry,
|
||||
id: newId,
|
||||
|
||||
@@ -89,10 +89,8 @@ export async function importClothingSet(params: {
|
||||
setType: string;
|
||||
setId: number;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportClothingSetResult> {
|
||||
const { setType, setId, onProgress, signal } = params;
|
||||
signal?.throwIfAborted();
|
||||
const { setType, setId, onProgress } = params;
|
||||
const warnings: string[] = [];
|
||||
|
||||
const [officialFd, officialLibs] = await Promise.all([
|
||||
@@ -138,17 +136,12 @@ export async function importClothingSet(params: {
|
||||
let hardFail = false;
|
||||
|
||||
for (const libId of requiredLibIds) {
|
||||
signal?.throwIfAborted();
|
||||
const officialLib = officialLibs.find((l) => l.id === libId);
|
||||
if (!officialLib) continue;
|
||||
|
||||
if (!localLibIds.has(libId)) {
|
||||
onProgress?.(`importing library ${libId}`);
|
||||
const result = await importSingleFigure({
|
||||
lib: libId,
|
||||
onProgress,
|
||||
signal,
|
||||
});
|
||||
const result = await importSingleFigure({ lib: libId, onProgress });
|
||||
if (!result.ok) {
|
||||
hardFail = true;
|
||||
warnings.push(
|
||||
@@ -176,7 +169,6 @@ export async function importClothingSet(params: {
|
||||
}
|
||||
|
||||
const mappedSet = mapOfficialSet(set);
|
||||
signal?.throwIfAborted();
|
||||
await mergeSet(setType, st.paletteId, mappedSet as never);
|
||||
|
||||
if (palette) {
|
||||
|
||||
@@ -76,10 +76,8 @@ export async function importSingleEffect(params: {
|
||||
type: string;
|
||||
revision: number;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportEffectResult> {
|
||||
const { id, lib, type, revision, onProgress, signal } = params;
|
||||
signal?.throwIfAborted();
|
||||
const { id, lib, type, revision, onProgress } = params;
|
||||
const warnings: string[] = [];
|
||||
await ensureEffectDirs();
|
||||
|
||||
@@ -102,10 +100,7 @@ export async function importSingleEffect(params: {
|
||||
error: `Could not resolve SWF URL: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
const dl = await downloadFile(swfUrl, swfPath, {
|
||||
validate: "swf",
|
||||
signal,
|
||||
});
|
||||
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
|
||||
if (!dl.ok)
|
||||
return {
|
||||
ok: false,
|
||||
@@ -116,7 +111,6 @@ export async function importSingleEffect(params: {
|
||||
|
||||
// Convert SWF → Nitro
|
||||
onProgress?.("converting");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||
const result = convertSwfToNitro(swfBuffer, lib);
|
||||
@@ -133,7 +127,6 @@ export async function importSingleEffect(params: {
|
||||
|
||||
// Merge EffectMap.json (last step — no orphan entry if anything above failed)
|
||||
onProgress?.("writing_map");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const entry: EffectMapEntry = { id, lib, type, revision };
|
||||
await mergeEffect(entry);
|
||||
|
||||
@@ -78,10 +78,8 @@ async function nitroPathFor(lib: string): Promise<string> {
|
||||
export async function importSingleFigure(params: {
|
||||
lib: string;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportFigureResult> {
|
||||
const { lib, onProgress, signal } = params;
|
||||
signal?.throwIfAborted();
|
||||
const { lib, onProgress } = params;
|
||||
const warnings: string[] = [];
|
||||
await ensureFigureDirs();
|
||||
|
||||
@@ -101,10 +99,7 @@ export async function importSingleFigure(params: {
|
||||
error: `Could not resolve SWF URL: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
const dl = await downloadFile(swfUrl, swfPath, {
|
||||
validate: "swf",
|
||||
signal,
|
||||
});
|
||||
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
|
||||
if (!dl.ok)
|
||||
return {
|
||||
ok: false,
|
||||
@@ -114,7 +109,6 @@ export async function importSingleFigure(params: {
|
||||
};
|
||||
|
||||
onProgress?.("converting");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||
const result = convertSwfToNitro(swfBuffer, lib);
|
||||
|
||||
@@ -403,13 +403,10 @@ async function syncDirMissingFiles(
|
||||
* so all imported furniture stays renderable in-game even when a mirror
|
||||
* write was skipped.
|
||||
*/
|
||||
export async function syncAssetsToGamedataBundle(
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
export async function syncAssetsToGamedataBundle(): Promise<{
|
||||
copiedNitros: string[];
|
||||
copiedIcons: string[];
|
||||
}> {
|
||||
signal?.throwIfAborted();
|
||||
if (process.platform === "win32")
|
||||
return { copiedNitros: [], copiedIcons: [] };
|
||||
|
||||
@@ -426,7 +423,6 @@ export async function syncAssetsToGamedataBundle(
|
||||
"bundled/furniture",
|
||||
);
|
||||
if (nitroDir !== nitroTarget) {
|
||||
signal?.throwIfAborted();
|
||||
const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) =>
|
||||
entry.endsWith(".nitro"),
|
||||
);
|
||||
@@ -438,7 +434,6 @@ export async function syncAssetsToGamedataBundle(
|
||||
"icons",
|
||||
);
|
||||
if (iconDir !== iconTarget) {
|
||||
signal?.throwIfAborted();
|
||||
const copied = await syncDirMissingFiles(iconDir, iconTarget, (entry) =>
|
||||
entry.endsWith("_icon.png"),
|
||||
);
|
||||
@@ -513,7 +508,6 @@ export async function importSingleFurni(params: {
|
||||
skipFurniDataWrite?: boolean;
|
||||
updateExisting?: boolean;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
|
||||
sourceSwfBaseUrl?: string;
|
||||
/** Per-source nitro bundle + icon base URL. */
|
||||
@@ -531,12 +525,10 @@ export async function importSingleFurni(params: {
|
||||
skipFurniDataWrite,
|
||||
updateExisting,
|
||||
onProgress,
|
||||
signal,
|
||||
sourceSwfBaseUrl,
|
||||
nitroBaseUrl: sourceNitroBaseUrl,
|
||||
iconBaseUrl: sourceIconBaseUrl,
|
||||
} = params;
|
||||
signal?.throwIfAborted();
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check if already exists by classname OR by spriteId (primary key collision).
|
||||
@@ -612,7 +604,6 @@ export async function importSingleFurni(params: {
|
||||
|
||||
// ── Insert into DB with enriched data ──────────────────────────────
|
||||
onProgress?.("writing_db");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
if (isUpdate) {
|
||||
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
|
||||
@@ -669,7 +660,6 @@ export async function importSingleFurni(params: {
|
||||
// fall back to base-classname icon (Nitro reuses base for all colors
|
||||
// when no color-specific icon exists).
|
||||
onProgress?.("downloading");
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const iconColorAware = classname.replace(/\*/g, "_");
|
||||
|
||||
@@ -711,8 +701,8 @@ export async function importSingleFurni(params: {
|
||||
// Download icon + SWF concurrently (independent assets). Each tries its
|
||||
// candidate URLs in order until one succeeds.
|
||||
const [iconOkResult, swfOk] = await Promise.all([
|
||||
tryDownloadCandidates(iconUrls, iconPath, "png", signal),
|
||||
tryDownloadCandidates(swfUrls, swfPath, "swf", signal),
|
||||
tryDownloadCandidates(iconUrls, iconPath, "png"),
|
||||
tryDownloadCandidates(swfUrls, swfPath, "swf"),
|
||||
]);
|
||||
let iconOk = iconOkResult;
|
||||
|
||||
@@ -725,10 +715,7 @@ export async function importSingleFurni(params: {
|
||||
!existsSync(/*turbopackIgnore: true*/ nitroPath)
|
||||
) {
|
||||
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
|
||||
const dl = await downloadFile(nitroUrl, nitroPath, {
|
||||
validate: "png",
|
||||
signal,
|
||||
});
|
||||
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "png" });
|
||||
if (dl.ok) nitroDownloadOk = true;
|
||||
}
|
||||
|
||||
@@ -757,7 +744,6 @@ export async function importSingleFurni(params: {
|
||||
|
||||
// ── Convert SWF to Nitro ──────────────────────────────────────────
|
||||
onProgress?.("converting");
|
||||
signal?.throwIfAborted();
|
||||
let conversionResult: ConversionResult | null = null;
|
||||
|
||||
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
|
||||
|
||||
@@ -635,21 +635,14 @@ export interface FixAllResult {
|
||||
|
||||
/** Run every repair in a sensible order, then return before/after health. */
|
||||
export async function fixEverything(
|
||||
opts: { dedupePages?: boolean; signal?: AbortSignal } = {},
|
||||
opts: { dedupePages?: boolean } = {},
|
||||
): Promise<FixAllResult> {
|
||||
opts.signal?.throwIfAborted();
|
||||
const healthBefore = await getFurniHealth();
|
||||
opts.signal?.throwIfAborted();
|
||||
const sprite = await fixSpriteIds();
|
||||
opts.signal?.throwIfAborted();
|
||||
const offers = await fixCatalogOffers();
|
||||
opts.signal?.throwIfAborted();
|
||||
const reconcile = await reconcileIds();
|
||||
opts.signal?.throwIfAborted();
|
||||
const dedup = await removeDuplicates(opts.dedupePages ?? true);
|
||||
opts.signal?.throwIfAborted();
|
||||
const align = await forceItemsBaseIdsToFurnidata(true);
|
||||
opts.signal?.throwIfAborted();
|
||||
const healthAfter = await getFurniHealth();
|
||||
return { healthBefore, sprite, offers, reconcile, dedup, align, healthAfter };
|
||||
}
|
||||
@@ -17,9 +17,7 @@ export async function writeBadgeToExternalTexts(
|
||||
code: string,
|
||||
name: string,
|
||||
description: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ImportBadgeResult> {
|
||||
signal?.throwIfAborted();
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
if (!gamedataRoot) {
|
||||
return {
|
||||
@@ -37,7 +35,6 @@ export async function writeBadgeToExternalTexts(
|
||||
const raw = await fs.readFile(extPath, "utf-8");
|
||||
texts = JSON.parse(raw) as Record<string, string>;
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw err;
|
||||
// File not present yet (fresh deployment) — start with an empty object.
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
console.warn(
|
||||
@@ -52,7 +49,6 @@ export async function writeBadgeToExternalTexts(
|
||||
}
|
||||
texts = {};
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (texts[nameKey] !== undefined) {
|
||||
return {
|
||||
@@ -67,11 +63,9 @@ export async function writeBadgeToExternalTexts(
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(extPath), { recursive: true });
|
||||
signal?.throwIfAborted();
|
||||
await fs.writeFile(extPath, JSON.stringify(texts, null, 4), "utf-8");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw err;
|
||||
console.warn(
|
||||
"[import-badges] Failed to update ExternalTexts.json:",
|
||||
(err as Error).message,
|
||||
@@ -89,9 +83,7 @@ export async function upsertWebsiteBadge(
|
||||
code: string,
|
||||
name: string,
|
||||
description: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(WebsiteBadges)
|
||||
@@ -115,28 +107,21 @@ export async function upsertWebsiteBadge(
|
||||
* Single import path: ExternalTexts.json + WebsiteBadges.
|
||||
* Callers should gate with ASSETS_IMPORT.
|
||||
*/
|
||||
export async function importBadgeSynced(
|
||||
input: {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<ImportBadgeResult> {
|
||||
signal?.throwIfAborted();
|
||||
export async function importBadgeSynced(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}): Promise<ImportBadgeResult> {
|
||||
const texts = await writeBadgeToExternalTexts(
|
||||
input.code,
|
||||
input.name,
|
||||
input.description,
|
||||
signal,
|
||||
);
|
||||
if (!texts.ok) return texts;
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
await upsertWebsiteBadge(input.code, input.name, input.description, signal);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
await upsertWebsiteBadge(input.code, input.name, input.description);
|
||||
} catch {
|
||||
return { ok: false, error: "Failed to import badge", status: 500 };
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,9 @@ export async function tryDownloadCandidates(
|
||||
urls: string[],
|
||||
destPath: string,
|
||||
kind: "swf" | "png",
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
for (const url of urls) {
|
||||
signal?.throwIfAborted();
|
||||
const res = await downloadFile(url, destPath, { validate: kind, signal });
|
||||
const res = await downloadFile(url, destPath, { validate: kind });
|
||||
if (res.ok) return true;
|
||||
}
|
||||
return false;
|
||||
@@ -35,26 +33,18 @@ export async function tryDownloadCandidates(
|
||||
export async function downloadFile(
|
||||
url: string,
|
||||
destPath: string,
|
||||
options?: {
|
||||
maxRetries?: number;
|
||||
validate?: "swf" | "png";
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
options?: { maxRetries?: number; validate?: "swf" | "png" },
|
||||
): Promise<{ ok: boolean; size: number }> {
|
||||
const maxRetries = options?.maxRetries ?? 3;
|
||||
const baseDelay = 1000;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
options?.signal?.throwIfAborted();
|
||||
if (attempt > 0) {
|
||||
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)));
|
||||
}
|
||||
const timeout = AbortSignal.timeout(15000);
|
||||
const res = await fetch(url, {
|
||||
signal: options?.signal
|
||||
? AbortSignal.any([options.signal, timeout])
|
||||
: timeout,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
headers: browserHeaders("image", {
|
||||
Accept: "image/png,image/*,*/*;q=0.8",
|
||||
}),
|
||||
@@ -92,7 +82,6 @@ export async function downloadFile(
|
||||
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer);
|
||||
return { ok: true, size: buffer.length };
|
||||
} catch (err) {
|
||||
if (options?.signal?.aborted) throw err;
|
||||
if (attempt === maxRetries) {
|
||||
console.warn(
|
||||
`[import-download] Download error ${url}:`,
|
||||
|
||||
@@ -21,53 +21,6 @@ export interface ModerationResult {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type ModerationAction =
|
||||
| { readonly action: "kick"; readonly userId: number }
|
||||
| {
|
||||
readonly action: "mute";
|
||||
readonly userId: number;
|
||||
readonly duration: number;
|
||||
}
|
||||
| { readonly action: "unmute"; readonly userId: number }
|
||||
| {
|
||||
readonly action: "alert";
|
||||
readonly userId: number;
|
||||
readonly message: string;
|
||||
}
|
||||
| { readonly action: "room-kick"; readonly roomId: number }
|
||||
| {
|
||||
readonly action: "broadcast";
|
||||
readonly message: string;
|
||||
readonly type: "hotel" | "staff";
|
||||
};
|
||||
|
||||
export interface ModerationActionTransport {
|
||||
disconnectUser(userId: number): Promise<boolean>;
|
||||
muteUser(userId: number, duration: number): Promise<boolean>;
|
||||
unmuteUser(userId: number): Promise<boolean>;
|
||||
alertUser(userId: number, message: string): Promise<boolean>;
|
||||
kickAll(roomId: number): Promise<boolean>;
|
||||
hotelAlert(message: string): Promise<boolean>;
|
||||
staffAlert(message: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
// Execute one already-authorized moderation effect and expose delivery truth.
|
||||
export async function executeModerationAction(
|
||||
transport: ModerationActionTransport,
|
||||
input: ModerationAction,
|
||||
): Promise<boolean> {
|
||||
if (input.action === "kick") return transport.disconnectUser(input.userId);
|
||||
if (input.action === "mute")
|
||||
return transport.muteUser(input.userId, input.duration);
|
||||
if (input.action === "unmute") return transport.unmuteUser(input.userId);
|
||||
if (input.action === "alert")
|
||||
return transport.alertUser(input.userId, input.message);
|
||||
if (input.action === "room-kick") return transport.kickAll(input.roomId);
|
||||
return input.type === "hotel"
|
||||
? transport.hotelAlert(input.message)
|
||||
: transport.staffAlert(input.message);
|
||||
}
|
||||
|
||||
const OPENAI_MODERATIONS_URL = "https://api.openai.com/v1/moderations";
|
||||
// Bound the AI call so a slow/hung endpoint can't stall a server action.
|
||||
const OPENAI_TIMEOUT_MS = 5_000;
|
||||
|
||||
@@ -45,10 +45,8 @@ function nitroPathFor(lib: string): string {
|
||||
export async function importSinglePet(params: {
|
||||
lib: string;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportPetResult> {
|
||||
const { lib, onProgress, signal } = params;
|
||||
signal?.throwIfAborted();
|
||||
const { lib, onProgress } = params;
|
||||
const warnings: string[] = [];
|
||||
await ensurePetDirs();
|
||||
|
||||
@@ -68,10 +66,7 @@ export async function importSinglePet(params: {
|
||||
error: `Could not resolve SWF URL: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
const dl = await downloadFile(swfUrl, swfPath, {
|
||||
validate: "swf",
|
||||
signal,
|
||||
});
|
||||
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
|
||||
if (!dl.ok)
|
||||
return {
|
||||
ok: false,
|
||||
@@ -81,7 +76,6 @@ export async function importSinglePet(params: {
|
||||
};
|
||||
|
||||
onProgress?.("converting");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||
const result = convertSwfToNitro(swfBuffer, lib);
|
||||
|
||||
@@ -104,9 +104,7 @@ export interface RepairEvent {
|
||||
|
||||
export async function repairMissingIcons(
|
||||
onEvent?: (evt: RepairEvent) => void,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<{ succeeded: number; failed: number; skipped: number }> {
|
||||
options?.signal?.throwIfAborted();
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
@@ -206,7 +204,6 @@ export async function repairMissingIcons(
|
||||
onEvent?.({ type: "started", total });
|
||||
|
||||
const processItem = async (classname: string) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
// Special items (badges, pets, effects, bots, sticky notes) don't have
|
||||
// .png icon files to repair. Skip them.
|
||||
if (dbSkipSet.has(classname)) {
|
||||
@@ -315,7 +312,7 @@ export async function repairMissingIcons(
|
||||
targets.iconDir,
|
||||
fileName,
|
||||
),
|
||||
{ maxRetries: 1, validate: "png", signal: options?.signal },
|
||||
{ maxRetries: 1, validate: "png" },
|
||||
);
|
||||
if (dl.ok) {
|
||||
const icon = await fs.readFile(
|
||||
@@ -353,7 +350,7 @@ export async function repairMissingIcons(
|
||||
const dl = await downloadFile(
|
||||
`${source.nitroBaseUrl}/${classname}.nitro`,
|
||||
nitroTmp,
|
||||
{ maxRetries: 1, signal: options?.signal },
|
||||
{ maxRetries: 1 },
|
||||
);
|
||||
if (dl.ok) {
|
||||
const icon = extractFurniIconPng(await fs.readFile(nitroTmp));
|
||||
@@ -393,7 +390,6 @@ export async function repairMissingIcons(
|
||||
};
|
||||
|
||||
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
const classname = item.item_name;
|
||||
const result = await processItem(classname);
|
||||
if (result.status === "skipped") {
|
||||
|
||||
@@ -106,9 +106,7 @@ export interface RepairNitroEvent {
|
||||
|
||||
export async function repairMissingNitros(
|
||||
onEvent?: (evt: RepairNitroEvent) => void,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<{ succeeded: number; failed: number; skipped: number }> {
|
||||
options?.signal?.throwIfAborted();
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
@@ -203,7 +201,6 @@ export async function repairMissingNitros(
|
||||
onEvent?.({ type: "started", total });
|
||||
|
||||
const processItem = async (classname: string) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
// Special items (badges, pets, effects, bots, sticky notes) don't have
|
||||
// furni .nitro bundles to repair. Skip them.
|
||||
if (skipSet.has(classname)) {
|
||||
@@ -279,7 +276,7 @@ export async function repairMissingNitros(
|
||||
const dl = await downloadFile(
|
||||
`${source.nitroBaseUrl}/${nitroName}`,
|
||||
tmpPath,
|
||||
{ maxRetries: 1, signal: options?.signal },
|
||||
{ maxRetries: 1 },
|
||||
);
|
||||
if (dl.ok) {
|
||||
// Validate it is a real Nitro bundle before writing it
|
||||
@@ -318,7 +315,6 @@ export async function repairMissingNitros(
|
||||
};
|
||||
|
||||
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
const classname = item.item_name;
|
||||
const result = await processItem(classname);
|
||||
if (result.status === "skipped") {
|
||||
@@ -388,14 +384,13 @@ export async function repairMissingNitros(
|
||||
*/
|
||||
export async function addSize32AllNitros(
|
||||
onEvent?: (evt: RepairNitroEvent) => void,
|
||||
opts?: { concurrency?: number; signal?: AbortSignal },
|
||||
opts?: { concurrency?: number },
|
||||
): Promise<{
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
existing: number;
|
||||
}> {
|
||||
opts?.signal?.throwIfAborted();
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
@@ -447,7 +442,6 @@ export async function addSize32AllNitros(
|
||||
[...fileNames],
|
||||
opts?.concurrency ?? 12,
|
||||
async (fileName, i) => {
|
||||
opts?.signal?.throwIfAborted();
|
||||
let buf: Buffer | null = null;
|
||||
for (const dir of nitroDirs) {
|
||||
try {
|
||||
|
||||
@@ -19,56 +19,6 @@ export interface TicketReplyDb {
|
||||
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
|
||||
}
|
||||
|
||||
export const MAX_UNSIGNED_TICKET_ID = 18_446_744_073_709_551_615n;
|
||||
|
||||
function boundedDecimalPattern(maximum: string): RegExp {
|
||||
const alternatives = [`[1-9]\\d{0,${maximum.length - 2}}`];
|
||||
for (let index = 0; index < maximum.length; index += 1) {
|
||||
const maximumDigit = Number(maximum[index]);
|
||||
const minimumDigit = index === 0 ? 1 : 0;
|
||||
const upperDigit = maximumDigit - 1;
|
||||
if (upperDigit < minimumDigit) continue;
|
||||
const digit =
|
||||
upperDigit === minimumDigit
|
||||
? String(minimumDigit)
|
||||
: `[${minimumDigit}-${upperDigit}]`;
|
||||
alternatives.push(
|
||||
`${maximum.slice(0, index)}${digit}\\d{${maximum.length - index - 1}}`,
|
||||
);
|
||||
}
|
||||
alternatives.push(maximum);
|
||||
return new RegExp(`^(?:${alternatives.join("|")})$`, "u");
|
||||
}
|
||||
|
||||
export const CANONICAL_TICKET_ID_PATTERN = boundedDecimalPattern(
|
||||
MAX_UNSIGNED_TICKET_ID.toString(),
|
||||
);
|
||||
|
||||
// Canonicalize a SQL BIGINT identifier without passing through Number.
|
||||
export function canonicalTicketId(value: string | number | bigint): bigint {
|
||||
let parsed: bigint;
|
||||
try {
|
||||
if (typeof value === "bigint") {
|
||||
parsed = value;
|
||||
} else if (typeof value === "string") {
|
||||
if (!CANONICAL_TICKET_ID_PATTERN.test(value)) {
|
||||
throw new Error("noncanonical identifier");
|
||||
}
|
||||
parsed = BigInt(value);
|
||||
} else if (Number.isSafeInteger(value) && value > 0) {
|
||||
parsed = BigInt(value);
|
||||
} else {
|
||||
throw new Error("unsafe identifier");
|
||||
}
|
||||
} catch {
|
||||
throw new Error("invalid ticket identifier");
|
||||
}
|
||||
if (parsed <= 0n || parsed > MAX_UNSIGNED_TICKET_ID) {
|
||||
throw new Error("invalid ticket identifier");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function createOwnedTicketReply(
|
||||
db: TicketReplyDb,
|
||||
input: { ticketId: bigint; userId: number; content: string },
|
||||
|
||||
@@ -234,7 +234,6 @@ export async function uploadSingleFurni(params: {
|
||||
nitroBuffer: Buffer;
|
||||
iconBuffer?: Buffer | null;
|
||||
generateSql?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<UploadResult> {
|
||||
const {
|
||||
classname,
|
||||
@@ -251,9 +250,7 @@ export async function uploadSingleFurni(params: {
|
||||
nitroBuffer,
|
||||
iconBuffer,
|
||||
generateSql = false,
|
||||
signal,
|
||||
} = params;
|
||||
signal?.throwIfAborted();
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!/^[\w\-.*]+$/.test(classname)) {
|
||||
@@ -283,7 +280,6 @@ export async function uploadSingleFurni(params: {
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
signal?.throwIfAborted();
|
||||
const assetTargets = await getFurniAssetWriteTargets();
|
||||
const { iconDir, nitroDir } = assetTargets;
|
||||
|
||||
@@ -301,7 +297,6 @@ export async function uploadSingleFurni(params: {
|
||||
};
|
||||
}
|
||||
await fs.writeFile(nitroPath, nitroBuffer);
|
||||
signal?.throwIfAborted();
|
||||
|
||||
let iconFileName: string | null = null;
|
||||
let iconPath: string | null = null;
|
||||
@@ -312,7 +307,6 @@ export async function uploadSingleFurni(params: {
|
||||
}
|
||||
|
||||
const mirroredPaths: string[] = [];
|
||||
signal?.throwIfAborted();
|
||||
await mirrorUploadedAsset(
|
||||
nitroPath,
|
||||
nitroFileName,
|
||||
@@ -352,7 +346,6 @@ export async function uploadSingleFurni(params: {
|
||||
const interactionModesCount = autoInteraction.interactionModesCount;
|
||||
|
||||
let newId: number;
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
newId = await allocateItemsBaseId(async (nextId) => {
|
||||
await db.execute(sql`
|
||||
@@ -379,7 +372,6 @@ export async function uploadSingleFurni(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const furniEntry = buildFurniEntry({
|
||||
id: newId,
|
||||
classname,
|
||||
@@ -398,7 +390,6 @@ export async function uploadSingleFurni(params: {
|
||||
|
||||
let catalogItemId: number | null = null;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
// Skip when the item already has a catalog row — re-uploads must never
|
||||
// create a second row for the same item_ids.
|
||||
const [existingCatalog] = (await db.execute(sql`
|
||||
|
||||
Reference in new issue
Block a user