Fix TypeScript errors and implement furniture import ID integrity with 18+ age verification
- Add termsAccepted and ageVerified columns to User table - Update register schema with new boolean fields - Fix register form age verification checkbox (th -> t) - Fix furni-import spriteId declaration order - Fix batch route variable naming (id -> spriteId) - Fix catalog-audit import path and ensure correct types - Hardened import with per-item id conflict checks - Added audit option for FurnitureData.json spriteId conflicts - Updated tagline to include Leeftijdsvereiste: 18+
This commit is contained in:
1 parent
e5ec3c1f06
commit
e76c530e4f
13 files changed
+267
-104
No files matched your search
@@ -7,7 +7,6 @@ import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteAds } from "@/lib/db";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
@@ -117,28 +116,3 @@ export const deleteAd = adminAction(
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
/** Legacy form POST delete — kept for compatibility; prefer client deleteAd action. */
|
||||
export async function deleteAdForm(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await db.delete(WebsiteAds).where(eq(WebsiteAds.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_delete",
|
||||
description: `Deleted advertisement #${id}`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Action failed: deleteAdForm", {
|
||||
action: "deleteAdForm",
|
||||
id: Number(id),
|
||||
error: err instanceof Error ? err.message : "DB error",
|
||||
});
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
}
|
||||
@@ -155,6 +155,7 @@ export function AuditClient() {
|
||||
const [applySql, setApplySql] = useState(false);
|
||||
const [repairFurniData, setRepairFurniData] = useState(false);
|
||||
const [repairStructure, setRepairStructure] = useState(false);
|
||||
const [checkFurniDataIds, setCheckFurniDataIds] = useState(false);
|
||||
|
||||
const [issues, setIssues] = useState<AuditIssue[]>([]);
|
||||
const [missingFromSources, setMissingFromSources] = useState<
|
||||
@@ -421,6 +422,13 @@ export function AuditClient() {
|
||||
>
|
||||
Repair FurnitureData.json (add missing + dedupe)
|
||||
</AuditCheckbox>
|
||||
<AuditCheckbox
|
||||
checked={checkFurniDataIds}
|
||||
disabled={loading}
|
||||
onChange={setCheckFurniDataIds}
|
||||
>
|
||||
Check FurnitureData.json for spriteId conflicts
|
||||
</AuditCheckbox>
|
||||
<AuditCheckbox
|
||||
checked={repairStructure}
|
||||
disabled={loading}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const POST = withAdmin(
|
||||
let applySql = false;
|
||||
let repairFurniData = false;
|
||||
let repairStructure = false;
|
||||
let checkFurniDataIds = false;
|
||||
try {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
repair?: unknown;
|
||||
@@ -26,6 +27,7 @@ export const POST = withAdmin(
|
||||
applySql?: unknown;
|
||||
repairFurniData?: unknown;
|
||||
repairStructure?: unknown;
|
||||
checkFurniDataIds?: unknown;
|
||||
};
|
||||
repair = body.repair === true;
|
||||
repairNitros = body.repairNitros === true;
|
||||
@@ -33,6 +35,7 @@ export const POST = withAdmin(
|
||||
applySql = body.applySql === true;
|
||||
repairFurniData = body.repairFurniData === true;
|
||||
repairStructure = body.repairStructure === true;
|
||||
checkFurniDataIds = body.checkFurniDataIds === true;
|
||||
} catch (err) {
|
||||
logger.warn("Failed to parse audit request body", { err });
|
||||
}
|
||||
@@ -55,6 +58,7 @@ export const POST = withAdmin(
|
||||
applySql,
|
||||
repairFurniData,
|
||||
repairStructure,
|
||||
checkFurniDataIds,
|
||||
});
|
||||
} catch (err) {
|
||||
send({
|
||||
|
||||
@@ -42,6 +42,7 @@ export function RegisterForm({
|
||||
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
|
||||
const [serverError, formAction, isPending] = useActionState(register, null);
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [ageVerified, setAgeVerified] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
@@ -280,7 +281,7 @@ export function RegisterForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terms */}
|
||||
{/* Terms & Age Verification */}
|
||||
<div
|
||||
className="rounded-xl p-4 flex flex-col gap-3"
|
||||
style={{
|
||||
@@ -314,6 +315,23 @@ export function RegisterForm({
|
||||
{t("termsAccept", { hotel: hotelName })}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ageVerified"
|
||||
name="ageVerified"
|
||||
checked={ageVerified}
|
||||
onChange={(e) => setAgeVerified(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-2 accent-(--color-primary) shrink-0 cursor-pointer"
|
||||
/>
|
||||
<label
|
||||
htmlFor="ageVerified"
|
||||
className="font-semibold cursor-pointer select-none"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{t("ageVerified", { hotel: hotelName })}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Captcha */}
|
||||
|
||||
@@ -76,6 +76,8 @@ export const User = mysqlTable("users", {
|
||||
homeRoom: int("home_room").notNull().default(0),
|
||||
secretKey: varchar("secret_key", { length: 40 }),
|
||||
pincode: varchar("pincode", { length: 11 }),
|
||||
termsAccepted: boolean("terms_accepted").notNull().default(false),
|
||||
ageVerified: boolean("age_verified").notNull().default(false),
|
||||
extraRank: int("extra_rank"),
|
||||
twoFactorSecret: text("two_factor_secret"),
|
||||
twoFactorRecoveryCodes: text("two_factor_recovery_codes"),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
repairFurniData,
|
||||
repairOrphanedCatalog,
|
||||
} from "./catalog-repair";
|
||||
import { readFurniData } from "./furni-data";
|
||||
import { ensureDirectories } from "./furni-import";
|
||||
import {
|
||||
assetNameCandidates,
|
||||
@@ -118,6 +119,7 @@ export interface AuditSummary {
|
||||
duplicatesMerged: number;
|
||||
duplicateRowsRemoved: number;
|
||||
remappedReferences: number;
|
||||
furniDataIdConflicts: number;
|
||||
}
|
||||
|
||||
export interface CatalogAuditOptions {
|
||||
@@ -128,6 +130,7 @@ export interface CatalogAuditOptions {
|
||||
organizeSql?: boolean;
|
||||
repairFurniData?: boolean;
|
||||
repairStructure?: boolean;
|
||||
checkFurniDataIds?: boolean;
|
||||
}
|
||||
|
||||
export async function runCatalogAudit(
|
||||
@@ -722,6 +725,60 @@ export async function runCatalogAudit(
|
||||
}
|
||||
}
|
||||
|
||||
// ── FurnitureData.json id conflict check ──────────────
|
||||
let furniDataIdConflicts: {
|
||||
classname: string;
|
||||
itemId: number;
|
||||
conflictingId: number;
|
||||
existingClassname: string;
|
||||
}[] = [];
|
||||
if (options?.checkFurniDataIds) {
|
||||
onEvent?.({
|
||||
type: "progress",
|
||||
message: "Checking FurnitureData.json for spriteId conflicts…",
|
||||
});
|
||||
try {
|
||||
const furniData = (await readFurniData()) as Record<
|
||||
string,
|
||||
{ furnitype: Array<Record<string, unknown>> }
|
||||
>;
|
||||
// Build map of id → classname from both sections
|
||||
const idToClassname = new Map<number, string>();
|
||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||
for (const e of furniData[section]?.furnitype ?? []) {
|
||||
const id = Number(e?.id);
|
||||
const classname = typeof e?.classname === "string" ? e.classname : "";
|
||||
if (!Number.isFinite(id) || id <= 0 || !classname) continue;
|
||||
const existing = idToClassname.get(id);
|
||||
if (existing && existing !== classname) {
|
||||
// Conflict: same id used by different classname
|
||||
furniDataIdConflicts.push({
|
||||
classname,
|
||||
itemId: id,
|
||||
conflictingId: id,
|
||||
existingClassname: existing,
|
||||
});
|
||||
} else if (!existing) {
|
||||
idToClassname.set(id, classname);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove duplicates (same classname + id pair counted once)
|
||||
const seen = new Set<string>();
|
||||
furniDataIdConflicts = furniDataIdConflicts.filter((c) => {
|
||||
const key = `${c.classname}:${c.conflictingId}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
} catch (err) {
|
||||
onEvent?.({
|
||||
type: "error",
|
||||
message: `FurnitureData id check failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onEvent?.({ type: "progress", message: "Comparing with clone sources…" });
|
||||
|
||||
const sources = await listSources();
|
||||
@@ -811,6 +868,7 @@ export async function runCatalogAudit(
|
||||
duplicatesMerged,
|
||||
duplicateRowsRemoved,
|
||||
remappedReferences,
|
||||
furniDataIdConflicts: furniDataIdConflicts.length,
|
||||
};
|
||||
|
||||
onEvent?.({
|
||||
|
||||
@@ -106,6 +106,77 @@ async function acquireDiskLock(lockPath: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a map of sprite id → classname from both sections of the furnidata.
|
||||
* If multiple entries share the same id, the first encountered classname wins. */
|
||||
function furniDataIdOwners(
|
||||
data: Record<string, { furnitype: Array<Record<string, unknown>> }>,
|
||||
): Map<number, string> {
|
||||
const owners = new Map<number, string>();
|
||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||
for (const e of data[section]?.furnitype ?? []) {
|
||||
const id = Number(e?.id);
|
||||
if (!Number.isFinite(id) || id <= 0) continue;
|
||||
const classname = typeof e?.classname === "string" ? e.classname : "";
|
||||
if (!classname) continue;
|
||||
if (!owners.has(id)) owners.set(id, classname);
|
||||
}
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
/** Assert that no entry in `entries` has a spriteId already used by a different classname
|
||||
* in the existing furnidata or within this batch. Throws if a conflict is found. */
|
||||
function assertNoFurniDataIdConflicts(
|
||||
existingOwners: Map<number, string>,
|
||||
entries: Array<{ entry: Record<string, unknown>; itemType: string }>,
|
||||
): void {
|
||||
const conflicts: string[] = [];
|
||||
for (const { entry } of entries) {
|
||||
const id = Number(entry?.id);
|
||||
if (!Number.isFinite(id) || id <= 0) continue;
|
||||
const classname =
|
||||
typeof entry?.classname === "string" ? entry.classname : "";
|
||||
const existing = existingOwners.get(id);
|
||||
if (existing !== undefined && existing !== classname) {
|
||||
conflicts.push(
|
||||
`spriteId ${id} already used by "${existing}" in FurnitureData.json ` +
|
||||
`(attempted "${classname}")`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// reserve the id within this batch so a second entry with the same id + different classname
|
||||
// is also caught (even though the file hasn't been written yet).
|
||||
existingOwners.set(id, classname);
|
||||
}
|
||||
if (conflicts.length > 0) {
|
||||
throw new Error(
|
||||
`FurnitureData spriteId conflicts: ${conflicts.join("; ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a spriteId is already used by a different classname in the local
|
||||
* FurnitureData.json. Returns the existing classname if there's a conflict,
|
||||
* or null if the spriteId is free or the file couldn't be read. */
|
||||
export async function findFurniDataIdConflict(
|
||||
spriteId: number,
|
||||
classname: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const furniData = await readFurniData();
|
||||
const owners = furniDataIdOwners(
|
||||
furniData as Record<
|
||||
string,
|
||||
{ furnitype: Array<Record<string, unknown>> }
|
||||
>,
|
||||
);
|
||||
const existing = owners.get(spriteId);
|
||||
return existing !== undefined && existing !== classname ? existing : null;
|
||||
} catch {
|
||||
return null; // if file missing/unreadable, treat as no conflict
|
||||
}
|
||||
}
|
||||
|
||||
export async function withFurniDataLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
let release!: () => void;
|
||||
const acquired = new Promise<void>((r) => {
|
||||
@@ -228,6 +299,11 @@ export async function appendFurniEntry(
|
||||
string,
|
||||
{ furnitype: Array<Record<string, unknown>> }
|
||||
>;
|
||||
|
||||
// Guard: ensure the spriteId is not already used by a different classname in the file.
|
||||
const existingOwners = furniDataIdOwners(furniData);
|
||||
assertNoFurniDataIdConflicts(existingOwners, [{ entry, itemType }]);
|
||||
|
||||
if (itemType === "i") {
|
||||
if (!furniData.wallitemtypes) furniData.wallitemtypes = { furnitype: [] };
|
||||
upsertEntryInSection(furniData.wallitemtypes, entry);
|
||||
@@ -331,6 +407,17 @@ export async function appendFurniEntriesBatch(
|
||||
string,
|
||||
{ furnitype: unknown[] }
|
||||
>;
|
||||
|
||||
// Guard: ensure no spriteId conflicts with existing file entries
|
||||
// or within this batch (same id + different classname).
|
||||
const existingOwners = furniDataIdOwners(
|
||||
furniData as Record<
|
||||
string,
|
||||
{ furnitype: Array<Record<string, unknown>> }
|
||||
>,
|
||||
);
|
||||
assertNoFurniDataIdConflicts(existingOwners, entries);
|
||||
|
||||
for (const { entry, itemType } of entries) {
|
||||
if (itemType === "i") {
|
||||
if (!furniData.wallitemtypes)
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock("@/lib/services/furni-data", () => ({
|
||||
appendFurniEntry: vi.fn(),
|
||||
buildFurniEntry: vi.fn(),
|
||||
removeFurniEntry: vi.fn(),
|
||||
findFurniDataIdConflict: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import {
|
||||
appendFurniEntry,
|
||||
buildFurniEntry,
|
||||
findFurniDataIdConflict,
|
||||
removeFurniEntry,
|
||||
} from "@/lib/services/furni-data";
|
||||
import {
|
||||
@@ -472,6 +473,16 @@ export async function importSingleFurni(params: {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Check if spriteId already used in FurnitureData.json ──
|
||||
const takenBy = await findFurniDataIdConflict(originalId, classname);
|
||||
if (takenBy) {
|
||||
return {
|
||||
ok: false,
|
||||
warnings,
|
||||
error: `spriteId ${originalId} already used by "${takenBy}" in FurnitureData.json`,
|
||||
};
|
||||
}
|
||||
|
||||
const itemType = type === "wallitem" ? "i" : "s";
|
||||
const spriteId = originalId;
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"header": {
|
||||
"online": "{count} {hotel} online",
|
||||
"nitroClient": "Nitro client",
|
||||
"tagline": "Een online virtuele wereld waar je je eigen avatar kunt maken, vrienden kunt maken, kunt chatten, kamers kunt maken en nog veel meer!",
|
||||
"tagline": "Een online virtuele wereld waar je je eigen avatar kunt maken, vrienden kunt maken, kunt chatten, kamers kunt maken en nog veel meer! Leeftijdsvereiste: 18+",
|
||||
"login": "Inloggen",
|
||||
"or": "Of",
|
||||
"createAccount": "Account aanmaken"
|
||||
@@ -919,6 +919,7 @@
|
||||
"passwordError": "Wachtwoord moet minimaal 6 tekens bevatten.",
|
||||
"confirmPasswordError": "Wachtwoorden komen niet overeen.",
|
||||
"termsRequired": "Je moet de voorwaarden & regels accepteren.",
|
||||
"ageVerified": "Ik ben 18+ en ga akkoord met de leeftijdsvereiste.",
|
||||
"usernameRequired": "Gebruikersnaam is verplicht.",
|
||||
"emailRequired": "E-mail is verplicht.",
|
||||
"passwordRequired": "Wachtwoord is verplicht.",
|
||||
|
||||
Reference in new issue
Block a user