Remove 19 unused source files (no importers anywhere in src/):
- src/actions/admin-permissions.ts, admin-radio.ts, admin-user-edit.ts,
admin-users.ts (functionality lives in @/actions/users and
@/actions/permissions)
- src/components/admin/confirm-action.tsx, page-header.tsx
- src/components/motion-elements.tsx
- src/lib/format-date.ts
- src/lib/catalog-categories/* (incl. re-export barrel)
- src/lib/foundation/{index,database,middleware,validation}.ts (errors/action
kept, still imported directly)
- src/lib/services/imager/{avatar-renderer,memory-cache}.ts
- src/lib/services/nitro-assets.ts
Update admin-operations-contract test to drop the two removed action-file
gates (their permission coverage already exists in users.ts/permissions.ts).
Add knip.json for repeatable dead-code audits.
Verified: tsc --noEmit clean, next build succeeds, full test suite green
(301/301).
This commit is contained in:
1 parent
60eb45be73
commit
3c9c1311ec
23 files changed
+586
-2158
No files matched your search
@@ -1,135 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// website_permissions (model WebsitePermissions) is the CMS-owned permission ->
|
||||
// minimum-rank mapping. Editable columns are exactly: permission (unique name),
|
||||
// min_rank (lowest rank that may use it), description. id is BigInt and
|
||||
// created_at/updated_at are managed here.
|
||||
|
||||
function parseMinRank(formData: FormData): number {
|
||||
const n = Number(
|
||||
String(formData.get("minRank") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim(),
|
||||
);
|
||||
return Number.isInteger(n) && n >= 0 ? n : 1;
|
||||
}
|
||||
|
||||
export async function createPermission(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PERMISSIONS_MANAGE);
|
||||
const permission = String(formData.get("permission") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const minRank = parseMinRank(formData);
|
||||
const description =
|
||||
String(formData.get("description") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() || null;
|
||||
if (!permission) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.websitePermissions.upsert({
|
||||
where: { permission },
|
||||
update: { minRank, description, updatedAt: now },
|
||||
create: {
|
||||
permission,
|
||||
minRank,
|
||||
description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "permission_create",
|
||||
description: `Saved permission "${permission}" (min rank ${minRank})`,
|
||||
targetType: "permission",
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.permission_create_failed", error, {
|
||||
staffId: staff.id,
|
||||
permission,
|
||||
});
|
||||
redirect("/admin/permissions?error=save");
|
||||
// ignore (e.g. constraint failure) — page re-renders current state
|
||||
}
|
||||
revalidatePath("/admin/permissions");
|
||||
redirect("/admin/permissions?saved=1");
|
||||
}
|
||||
|
||||
export async function updatePermission(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PERMISSIONS_MANAGE);
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!raw) return;
|
||||
const id = BigInt(raw);
|
||||
const permission = String(formData.get("permission") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const minRank = parseMinRank(formData);
|
||||
const description =
|
||||
String(formData.get("description") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() || null;
|
||||
if (!permission) return;
|
||||
|
||||
try {
|
||||
await prisma.websitePermissions.update({
|
||||
where: { id },
|
||||
data: { permission, minRank, description, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "permission_update",
|
||||
description: `Updated permission #${id} ("${permission}" min rank ${minRank})`,
|
||||
targetType: "permission",
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.permission_update_failed", error, {
|
||||
staffId: staff.id,
|
||||
permissionId: String(id),
|
||||
});
|
||||
redirect("/admin/permissions?error=save");
|
||||
// ignore (e.g. duplicate) — page re-renders current state
|
||||
}
|
||||
revalidatePath("/admin/permissions");
|
||||
redirect("/admin/permissions?saved=1");
|
||||
}
|
||||
|
||||
export async function deletePermission(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PERMISSIONS_MANAGE);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const deleted = await prisma.websitePermissions.delete({
|
||||
where: { id },
|
||||
select: { permission: true },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "permission_delete",
|
||||
description: `Deleted permission #${id} ("${deleted.permission}")`,
|
||||
targetType: "permission",
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.permission_delete_failed", error, {
|
||||
staffId: staff.id,
|
||||
permissionId: String(id),
|
||||
});
|
||||
redirect("/admin/permissions?error=delete");
|
||||
// ignore (e.g. already removed)
|
||||
}
|
||||
revalidatePath("/admin/permissions");
|
||||
redirect("/admin/permissions?saved=1");
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
"use server";
|
||||
@@ -1,101 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// users_currency.type values for the non-credits currencies (mirror send-currency.ts).
|
||||
// Credits live on users.credits; pixels/points live on the users row too;
|
||||
// duckets/diamonds live in users_currency keyed by (user_id, type).
|
||||
const DUCKETS_TYPE = 0;
|
||||
const DIAMONDS_TYPE = 5;
|
||||
|
||||
function toInt(value: FormDataEntryValue | null, min = 0): number | null {
|
||||
if (value == null) return null;
|
||||
const raw = String(value).trim();
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
const i = Math.trunc(n);
|
||||
return i < min ? min : i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the SAFE website-managed fields of a users row (and the duckets/diamonds
|
||||
* balances in users_currency). Never touches the password. Re-reads the staff
|
||||
* user from the session and logs the action. emulator-owned users.id is Int.
|
||||
*/
|
||||
export async function updateUser(formData: FormData): Promise<void> {
|
||||
// Never trust the client: re-check USERS_EDIT inside the action.
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
|
||||
const userId = Number(formData.get("id"));
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) return;
|
||||
|
||||
// users row — only existing, safe columns.
|
||||
const mailRaw = String(formData.get("mail") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 127);
|
||||
const look = String(formData.get("look") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 256);
|
||||
const rank = toInt(formData.get("rank"), 1);
|
||||
const credits = toInt(formData.get("credits"), 0);
|
||||
const pixels = toInt(formData.get("pixels"), 0);
|
||||
const points = toInt(formData.get("points"), 0);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
mail: mailRaw === "" ? null : mailRaw.slice(0, 500),
|
||||
motto,
|
||||
look,
|
||||
...(rank != null ? { rank } : {}),
|
||||
...(credits != null ? { credits } : {}),
|
||||
...(pixels != null ? { pixels } : {}),
|
||||
...(points != null ? { points } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// users_currency — set exact balances for duckets / diamonds.
|
||||
const duckets = toInt(formData.get("duckets"), 0);
|
||||
const diamonds = toInt(formData.get("diamonds"), 0);
|
||||
if (duckets != null) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: DUCKETS_TYPE } },
|
||||
update: { amount: duckets },
|
||||
create: { userId, type: DUCKETS_TYPE, amount: duckets },
|
||||
});
|
||||
}
|
||||
if (diamonds != null) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: DIAMONDS_TYPE } },
|
||||
update: { amount: diamonds },
|
||||
create: { userId, type: DIAMONDS_TYPE, amount: diamonds },
|
||||
});
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "user_edit",
|
||||
description: `Edited account fields of user #${userId}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
revalidatePath(`/admin/users/edit/${userId}`);
|
||||
redirect(`/admin/users/show/${userId}`);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission, requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
const CURRENCIES: ReadonlySet<string> = new Set([
|
||||
"credits",
|
||||
"duckets",
|
||||
"diamonds",
|
||||
"points",
|
||||
]);
|
||||
|
||||
export async function giveCurrency(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
|
||||
const userId = Number(formData.get("userId"));
|
||||
const type = String(formData.get("type"));
|
||||
const amount = Number(formData.get("amount"));
|
||||
if (userId > 0 && amount > 0 && CURRENCIES.has(type)) {
|
||||
await sendCurrency(
|
||||
{ rcon, db: prisma },
|
||||
userId,
|
||||
type as CurrencyName,
|
||||
amount,
|
||||
);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "give_currency",
|
||||
description: `Gave ${amount} ${type} to user #${userId}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
}
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function setMotto(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.USERS_EDIT);
|
||||
const userId = Number(formData.get("userId"));
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 127);
|
||||
if (userId > 0) {
|
||||
await prisma.user.update({ where: { id: userId }, data: { motto } });
|
||||
await rcon.setMotto(userId, motto);
|
||||
}
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function setRank(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
|
||||
const userId = Number(formData.get("userId"));
|
||||
const rank = Number(formData.get("rank"));
|
||||
if (userId > 0 && rank > 0) {
|
||||
await prisma.user.update({ where: { id: userId }, data: { rank } });
|
||||
await rcon.setRank(userId, rank);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "rank_change",
|
||||
description: `Set rank of user #${userId} to ${rank}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
}
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function alertUser(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.USERS_EDIT);
|
||||
const userId = Number(formData.get("userId"));
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (userId > 0 && message) await rcon.alertUser(userId, message);
|
||||
}
|
||||
|
||||
export async function disconnectUser(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.USERS_EDIT);
|
||||
const userId = Number(formData.get("userId"));
|
||||
const username = String(formData.get("username") ?? "").normalize("NFC");
|
||||
if (userId > 0) await rcon.disconnectUser(userId, username);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function ConfirmAction({
|
||||
message,
|
||||
action,
|
||||
userId,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
message: string;
|
||||
action: string;
|
||||
userId: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
action="/api/admin/users/actions"
|
||||
method="post"
|
||||
style={{ display: "inline-block" }}
|
||||
>
|
||||
<input type="hidden" name="userId" value={userId} />
|
||||
<input type="hidden" name="action" value={action} />
|
||||
<button
|
||||
type="submit"
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={(e) => {
|
||||
if (!confirm(message)) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface AdminPageHeaderProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export function AdminPageHeader({
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
}: AdminPageHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Icon size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { HTMLMotionProps } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
fadeIn,
|
||||
fadeInUp,
|
||||
hoverLift,
|
||||
hoverScale,
|
||||
scaleIn,
|
||||
staggerContainer,
|
||||
staggerItem,
|
||||
} from "@/lib/motion";
|
||||
|
||||
type DivProps = HTMLMotionProps<"div">;
|
||||
|
||||
export function AnimatedSection({ children, className, ...props }: DivProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimatedChild({ children, className, ...props }: DivProps) {
|
||||
return (
|
||||
<motion.div className={className} variants={staggerItem} {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FadeIn({ children, className, ...props }: DivProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FadeInUp({ children, className, ...props }: DivProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
variants={fadeInUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScaleIn({ children, className, ...props }: DivProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
variants={scaleIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HoverCard({ children, className, ...props }: DivProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
{...hoverLift}
|
||||
whileHover={{ y: -4, transition: { duration: 0.2 } }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HoverButton({ children, className, ...props }: DivProps) {
|
||||
return (
|
||||
<motion.div className={className} {...hoverScale} {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -48,12 +48,10 @@ const ACTION_GATES: Array<[string, string]> = [
|
||||
["admin-radio-autodj.ts", "PERMS.RADIO_EDIT"],
|
||||
["catalog.ts", "PERMS.CATALOG_EDIT"],
|
||||
["rooms.ts", "PERMS.ROOMS_EDIT"],
|
||||
["admin-users.ts", "PERMS.USERS_EDIT"],
|
||||
["admin-vpn.ts", "PERMS.SETTINGS_EDIT"],
|
||||
["admin-ads.ts", "PERMS.PAGES_EDIT"],
|
||||
["admin-vouchers.ts", "PERMS.SHOP_EDIT"],
|
||||
["admin-alerts.ts", "PERMS.NOTIFICATIONS_EDIT"],
|
||||
["admin-permissions.ts", "PERMS.PERMISSIONS_MANAGE"],
|
||||
["commandocentrum.ts", "PERMS.RCON_EXECUTE"],
|
||||
["translations.ts", "PERMS.SETTINGS_EDIT"],
|
||||
];
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./catalog-categories/index";
|
||||
@@ -1,695 +0,0 @@
|
||||
/**
|
||||
* Supreme catalog categories for organizing Habbo furniture.
|
||||
*
|
||||
* Each category belongs to a root: furni, costruire, animali, vestiti, or staff.
|
||||
*/
|
||||
|
||||
// ── Root Categories ──────────────────────────────────────────────
|
||||
|
||||
export type RootCategory =
|
||||
| "furni"
|
||||
| "costruire"
|
||||
| "animali"
|
||||
| "vestiti"
|
||||
| "staff";
|
||||
|
||||
export const ROOT_PAGES: Record<
|
||||
RootCategory,
|
||||
{ label: string; labelEn: string; icon: number }
|
||||
> = {
|
||||
furni: { label: "Mobili", labelEn: "Furniture", icon: 263 },
|
||||
costruire: { label: "Costruire", labelEn: "Building", icon: 69991 },
|
||||
animali: { label: "Animali", labelEn: "Pets", icon: 8 },
|
||||
vestiti: { label: "Vestiti", labelEn: "Clothing", icon: 369 },
|
||||
staff: { label: "Staff", labelEn: "Staff", icon: 65 },
|
||||
};
|
||||
|
||||
// ── Supreme Categories ─────────────────────────────────────────────
|
||||
|
||||
export interface SupremeCategory {
|
||||
key: string;
|
||||
label: string;
|
||||
labelEn: string;
|
||||
icon: number;
|
||||
root: RootCategory;
|
||||
keywords: string[];
|
||||
patterns: RegExp[];
|
||||
interactionTypes?: string[];
|
||||
layout: string;
|
||||
}
|
||||
|
||||
export const SUPREME_CATEGORIES: SupremeCategory[] = [
|
||||
// ─── Staff ───
|
||||
{
|
||||
key: "staff_items",
|
||||
label: "Staff",
|
||||
labelEn: "Staff",
|
||||
icon: 65,
|
||||
root: "staff",
|
||||
keywords: ["staff_", "admin_", "mod_", "moderator_"],
|
||||
patterns: [/^staff_/, /^admin_/, /^mod_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
// ─── Vestiti ───
|
||||
{
|
||||
key: "fashion",
|
||||
label: "Moda",
|
||||
labelEn: "Fashion",
|
||||
icon: 74,
|
||||
root: "vestiti",
|
||||
keywords: ["clothing_", "shirt_", "hat_", "dress_", "skirt_", "shoes_"],
|
||||
patterns: [/^clothing_/, /^shirt_/, /^hat_/],
|
||||
interactionTypes: ["clothing"],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
// ─── Animali ───
|
||||
{
|
||||
key: "pets",
|
||||
label: "Animali",
|
||||
labelEn: "Pets",
|
||||
icon: 8,
|
||||
root: "animali",
|
||||
keywords: [
|
||||
"pet_",
|
||||
"petfood",
|
||||
"pet_toy",
|
||||
"pet_nest",
|
||||
"pet_breeding",
|
||||
"horse_",
|
||||
"monsterplant",
|
||||
"waterbowl",
|
||||
"petfood_",
|
||||
],
|
||||
patterns: [
|
||||
/^pet_/,
|
||||
/^petfood/,
|
||||
/^pet_toy/,
|
||||
/^pet_nest/,
|
||||
/^pet_breeding/,
|
||||
/^horse_/,
|
||||
/^monsterplant_/,
|
||||
/^waterbowl/,
|
||||
],
|
||||
interactionTypes: [
|
||||
"pet_food",
|
||||
"pet_toy",
|
||||
"pet_breeding_box",
|
||||
"pet_nest",
|
||||
"pet_drink",
|
||||
],
|
||||
layout: "pets",
|
||||
},
|
||||
// ─── Furni ───
|
||||
{
|
||||
key: "rare",
|
||||
label: "Rari",
|
||||
labelEn: "Rares",
|
||||
icon: 145,
|
||||
root: "furni",
|
||||
keywords: ["rare_", "ultra_rare", "super_rare", "ltd_", "bonusrare"],
|
||||
patterns: [/^rare_/, /^ultra_rare/, /^super_rare/, /^ltd_/, /^bonusrare/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "wired",
|
||||
label: "Wired",
|
||||
labelEn: "Wired",
|
||||
icon: 5,
|
||||
root: "furni",
|
||||
keywords: ["wf_", "wired_"],
|
||||
patterns: [
|
||||
/^wf_/,
|
||||
/^wired_/,
|
||||
/^wf_act_/,
|
||||
/^wf_cnd_/,
|
||||
/^wf_trg_/,
|
||||
/^wf_xtra/,
|
||||
],
|
||||
interactionTypes: [
|
||||
"wired_condition",
|
||||
"wired_trigger",
|
||||
"wired_effect",
|
||||
"wired_extra",
|
||||
"wired_addon",
|
||||
],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "teleports",
|
||||
label: "Teletrasporti",
|
||||
labelEn: "Teleports",
|
||||
icon: 120,
|
||||
root: "furni",
|
||||
keywords: ["teleport"],
|
||||
patterns: [/^teleport_/, /_teleport$/],
|
||||
interactionTypes: ["teleport", "teleport_tile"],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "rollers",
|
||||
label: "Rulli",
|
||||
labelEn: "Rollers",
|
||||
icon: 222,
|
||||
root: "furni",
|
||||
keywords: ["roller_", "queue_tile"],
|
||||
patterns: [/^roller_/, /^queue_tile/],
|
||||
interactionTypes: ["roller"],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "games",
|
||||
label: "Giochi",
|
||||
labelEn: "Games",
|
||||
icon: 78,
|
||||
root: "furni",
|
||||
keywords: [
|
||||
"freeze_",
|
||||
"banzai",
|
||||
"football_",
|
||||
"snowb_",
|
||||
"bb_gate",
|
||||
"bb_patch",
|
||||
"sporttrack",
|
||||
],
|
||||
patterns: [
|
||||
/^bb_/,
|
||||
/^freeze_/,
|
||||
/^football_/,
|
||||
/^snowb_/,
|
||||
/^banzai_/,
|
||||
/^battle_banzai/,
|
||||
/^game_/,
|
||||
/^sporttrack/,
|
||||
],
|
||||
interactionTypes: [
|
||||
"battle_banzai_tile",
|
||||
"battle_banzai_scoreboard",
|
||||
"battle_banzai_puck",
|
||||
"battle_banzai_sphere",
|
||||
"battle_banzai_teleport",
|
||||
"freeze_tile",
|
||||
"freeze_exit",
|
||||
"football_gate",
|
||||
"football_goal",
|
||||
"football",
|
||||
],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "music",
|
||||
label: "Musica",
|
||||
labelEn: "Music",
|
||||
icon: 11,
|
||||
root: "furni",
|
||||
keywords: ["sound_", "jukebox", "trax_", "song_disk"],
|
||||
patterns: [/^sound_/, /^jukebox/, /^trax_/, /^song_disk/],
|
||||
interactionTypes: ["jukebox", "soundmachine", "music_disc"],
|
||||
layout: "soundmachine",
|
||||
},
|
||||
{
|
||||
key: "trophies",
|
||||
label: "Trofei",
|
||||
labelEn: "Trophies",
|
||||
icon: 221,
|
||||
root: "furni",
|
||||
keywords: ["trophy", "prizetrophy"],
|
||||
patterns: [
|
||||
/^trophy_/,
|
||||
/^prizetrophy/,
|
||||
/^gold_trophy/,
|
||||
/^silver_trophy/,
|
||||
/^bronze_trophy/,
|
||||
],
|
||||
interactionTypes: ["trophy"],
|
||||
layout: "trophies",
|
||||
},
|
||||
{
|
||||
key: "effects",
|
||||
label: "Effetti",
|
||||
labelEn: "Effects",
|
||||
icon: 61,
|
||||
root: "furni",
|
||||
keywords: ["effect_", "fx_", "avatar_effect"],
|
||||
patterns: [/^effect_/, /^fx_/, /^avatar_effect/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "credits",
|
||||
label: "Crediti",
|
||||
labelEn: "Credits",
|
||||
icon: 27,
|
||||
root: "furni",
|
||||
keywords: ["cf_", "credit_furni", "deal_"],
|
||||
patterns: [/^cf_/, /^credit_furni/, /^deal_/],
|
||||
interactionTypes: ["exchange"],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "hc",
|
||||
label: "Club Habbo",
|
||||
labelEn: "Habbo Club",
|
||||
icon: 172,
|
||||
root: "furni",
|
||||
keywords: [
|
||||
"hc_",
|
||||
"club_",
|
||||
"vip_",
|
||||
"hcsohva",
|
||||
"hcamme",
|
||||
"edicehc",
|
||||
"mocchamaster",
|
||||
],
|
||||
patterns: [/^hc_/, /^club_/, /^vip_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "bots",
|
||||
label: "Bot",
|
||||
labelEn: "Bots",
|
||||
icon: 36,
|
||||
root: "furni",
|
||||
keywords: ["bot_", "rentable_bot"],
|
||||
patterns: [/^bot_/, /^rentable_bot/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "groups",
|
||||
label: "Gruppi",
|
||||
labelEn: "Groups",
|
||||
icon: 203,
|
||||
root: "furni",
|
||||
keywords: ["guild_", "group_"],
|
||||
patterns: [/^guild_/, /^group_/],
|
||||
interactionTypes: ["guild_gate", "guild_forum"],
|
||||
layout: "guilds",
|
||||
},
|
||||
// ─── Furniture Lines (alphabetical) ───
|
||||
{
|
||||
key: "area",
|
||||
label: "Area",
|
||||
labelEn: "Area",
|
||||
icon: 14,
|
||||
root: "furni",
|
||||
keywords: ["area_"],
|
||||
patterns: [/^area_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "asian",
|
||||
label: "Asiatico",
|
||||
labelEn: "Asian",
|
||||
icon: 15,
|
||||
root: "furni",
|
||||
keywords: ["asian_", "china_", "jade_", "jp_", "wooden_screen"],
|
||||
patterns: [/^jp_/, /^asian_/, /^china_/, /^jade_/, /^wooden_screen/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "bathroom",
|
||||
label: "Bagno",
|
||||
labelEn: "Bathroom",
|
||||
icon: 17,
|
||||
root: "furni",
|
||||
keywords: ["bath_", "shower_", "toilet_"],
|
||||
patterns: [/^bath_/, /^shower_/, /^toilet_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "candy",
|
||||
label: "Caramelle",
|
||||
labelEn: "Candy",
|
||||
icon: 19,
|
||||
root: "furni",
|
||||
keywords: ["candy_", "sweet_"],
|
||||
patterns: [/^candy_/, /^sweet_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "country",
|
||||
label: "Country",
|
||||
labelEn: "Country",
|
||||
icon: 21,
|
||||
root: "furni",
|
||||
keywords: ["country_", "barn_", "hay_"],
|
||||
patterns: [/^country_/, /^barn_/, /^hay_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "cubie",
|
||||
label: "Cubie",
|
||||
labelEn: "Cubie",
|
||||
icon: 100,
|
||||
root: "furni",
|
||||
keywords: ["cubie_"],
|
||||
patterns: [/^cubie_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "diner",
|
||||
label: "Diner",
|
||||
labelEn: "Diner",
|
||||
icon: 204,
|
||||
root: "furni",
|
||||
keywords: ["diner_", "dsk_"],
|
||||
patterns: [/^diner_/, /^dsk_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "executive",
|
||||
label: "Executive",
|
||||
labelEn: "Executive",
|
||||
icon: 27,
|
||||
root: "furni",
|
||||
keywords: ["executive_", "office_"],
|
||||
patterns: [/^exe_/, /^executive_/, /^office_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "gothic",
|
||||
label: "Gotico",
|
||||
labelEn: "Gothic",
|
||||
icon: 30,
|
||||
root: "furni",
|
||||
keywords: ["gothic_", "goth_"],
|
||||
patterns: [/^gothic_/, /^goth_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "iced",
|
||||
label: "Ghiacciato",
|
||||
labelEn: "Iced",
|
||||
icon: 72,
|
||||
root: "furni",
|
||||
keywords: ["iced_", "norja"],
|
||||
patterns: [/^iced_/, /^norja/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "kitchen",
|
||||
label: "Cucina",
|
||||
labelEn: "Kitchen",
|
||||
icon: 217,
|
||||
root: "furni",
|
||||
keywords: ["kitchen_", "fridge_", "stove_", "oven_", "sink_"],
|
||||
patterns: [/^kitchen_/, /^fridge_/, /^stove_/, /^oven_/, /^sink_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "lodge",
|
||||
label: "Baita",
|
||||
labelEn: "Lodge",
|
||||
icon: 37,
|
||||
root: "furni",
|
||||
keywords: ["lodge_"],
|
||||
patterns: [/^lodge_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "maya",
|
||||
label: "Maya",
|
||||
labelEn: "Maya",
|
||||
icon: 171,
|
||||
root: "furni",
|
||||
keywords: ["maya_"],
|
||||
patterns: [/^maya_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "medieval",
|
||||
label: "Medievale",
|
||||
labelEn: "Medieval",
|
||||
icon: 39,
|
||||
root: "furni",
|
||||
keywords: ["medieval", "castle_", "throne_", "knight_", "dragon_"],
|
||||
patterns: [/^medieval_/, /^castle_/, /^throne_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "mode",
|
||||
label: "Mode",
|
||||
labelEn: "Mode",
|
||||
icon: 39,
|
||||
root: "furni",
|
||||
keywords: ["polyfon", "bardesk"],
|
||||
patterns: [/^bardesk/, /polyfon/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "mystics",
|
||||
label: "Mistici",
|
||||
labelEn: "Mystics",
|
||||
icon: 185,
|
||||
root: "furni",
|
||||
keywords: ["mystic"],
|
||||
patterns: [/^mystic/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "plastic",
|
||||
label: "Plastica",
|
||||
labelEn: "Plastic",
|
||||
icon: 46,
|
||||
root: "furni",
|
||||
keywords: ["plasto", "plasty", "plastic_"],
|
||||
patterns: [/plasto/, /plasty/, /^plastic_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "pura",
|
||||
label: "Pura",
|
||||
labelEn: "Pura",
|
||||
icon: 48,
|
||||
root: "furni",
|
||||
keywords: ["pura_"],
|
||||
patterns: [/^pura_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "relax",
|
||||
label: "Relax",
|
||||
labelEn: "Relax",
|
||||
icon: 49,
|
||||
root: "furni",
|
||||
keywords: ["relax_"],
|
||||
patterns: [/^relax_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "romantique",
|
||||
label: "Romantique",
|
||||
labelEn: "Romantique",
|
||||
icon: 50,
|
||||
root: "furni",
|
||||
keywords: ["romantique", "romant_"],
|
||||
patterns: [/^romantique/, /^romant_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "scifi",
|
||||
label: "Sci-Fi",
|
||||
labelEn: "Sci-Fi",
|
||||
icon: 53,
|
||||
root: "furni",
|
||||
keywords: ["scifi"],
|
||||
patterns: [/^scifi/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "steampunk",
|
||||
label: "Steampunk",
|
||||
labelEn: "Steampunk",
|
||||
icon: 180,
|
||||
root: "furni",
|
||||
keywords: ["steampunk"],
|
||||
patterns: [/^steampunk/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "tropical",
|
||||
label: "Tropicale",
|
||||
labelEn: "Tropical",
|
||||
icon: 38,
|
||||
root: "furni",
|
||||
keywords: ["tropic", "tropical", "palm_", "tiki_", "coconut"],
|
||||
patterns: [/^tropic_/, /^tropical_/, /^palm_/, /^tiki_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "usva",
|
||||
label: "USVA",
|
||||
labelEn: "USVA",
|
||||
icon: 129,
|
||||
root: "furni",
|
||||
keywords: ["usva_"],
|
||||
patterns: [/^usva_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "waasa",
|
||||
label: "Waasa",
|
||||
labelEn: "Waasa",
|
||||
icon: 103,
|
||||
root: "furni",
|
||||
keywords: ["waasa_"],
|
||||
patterns: [/^waasa_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
// ─── Seasonal ───
|
||||
{
|
||||
key: "christmas",
|
||||
label: "Natale",
|
||||
labelEn: "Christmas",
|
||||
icon: 168,
|
||||
root: "furni",
|
||||
keywords: [
|
||||
"xmas",
|
||||
"christmas",
|
||||
"santa",
|
||||
"snowman",
|
||||
"advent",
|
||||
"jouluank",
|
||||
"joulutahti",
|
||||
"hyasintti",
|
||||
"wcandle",
|
||||
],
|
||||
patterns: [/^xmas_/, /^christmas_/, /^xmas\d/, /^santai/, /^snow_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "halloween",
|
||||
label: "Halloween",
|
||||
labelEn: "Halloween",
|
||||
icon: 34,
|
||||
root: "furni",
|
||||
keywords: [
|
||||
"halloween",
|
||||
"hween",
|
||||
"ghost_",
|
||||
"skull_",
|
||||
"pumpkin",
|
||||
"zombie",
|
||||
"skeleton",
|
||||
"spider_",
|
||||
"witch_",
|
||||
"deadduck",
|
||||
"kallo",
|
||||
],
|
||||
patterns: [/^hween_/, /^halloween_/, /^ghost_/, /^skull_/, /^pumpkin/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "valentine",
|
||||
label: "San Valentino",
|
||||
labelEn: "Valentine",
|
||||
icon: 62,
|
||||
root: "furni",
|
||||
keywords: ["valentine", "heart_", "love_", "cupid", "cupido"],
|
||||
patterns: [/^val_/, /^valentine_/, /^heart_/, /^love_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "easter",
|
||||
label: "Pasqua",
|
||||
labelEn: "Easter",
|
||||
icon: 181,
|
||||
root: "furni",
|
||||
keywords: ["easter", "bunny_", "easterduck"],
|
||||
patterns: [/^easter_/, /^egg_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
// ─── Lighting ───
|
||||
{
|
||||
key: "lighting",
|
||||
label: "Illuminazione",
|
||||
labelEn: "Lighting",
|
||||
icon: 115,
|
||||
root: "furni",
|
||||
keywords: ["lamp_", "light_", "candle_", "torch_", "neon_"],
|
||||
patterns: [/^lamp_/, /^light_/, /^candle_/, /^torch_/, /^neon_/],
|
||||
interactionTypes: ["dimmer"],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
// ─── Costruire ───
|
||||
{
|
||||
key: "builders_club",
|
||||
label: "Builders Club",
|
||||
labelEn: "Builders Club",
|
||||
icon: 28,
|
||||
root: "costruire",
|
||||
keywords: ["bc_"],
|
||||
patterns: [/^bc_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "walls",
|
||||
label: "Pareti",
|
||||
labelEn: "Walls",
|
||||
icon: 122,
|
||||
root: "costruire",
|
||||
keywords: ["wallpaper", "poster_"],
|
||||
patterns: [/^wall_/, /^wallpaper/, /^poster_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "floors",
|
||||
label: "Pavimenti",
|
||||
labelEn: "Floors",
|
||||
icon: 41,
|
||||
root: "costruire",
|
||||
keywords: ["carpet_", "rug_", "doormat", "floortile"],
|
||||
patterns: [/^floor_/, /^carpet_/, /^rug_/, /^doormat/, /^floortile/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "landscapes",
|
||||
label: "Paesaggi",
|
||||
labelEn: "Landscapes",
|
||||
icon: 206,
|
||||
root: "costruire",
|
||||
keywords: ["landscape_"],
|
||||
patterns: [/^landscape_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "windows",
|
||||
label: "Finestre",
|
||||
labelEn: "Windows",
|
||||
icon: 218,
|
||||
root: "costruire",
|
||||
keywords: ["window_", "curtain_"],
|
||||
patterns: [/^window_/, /^curtain_/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "doors_gates",
|
||||
label: "Porte e Cancelli",
|
||||
labelEn: "Doors & Gates",
|
||||
icon: 55,
|
||||
root: "costruire",
|
||||
keywords: ["door_", "gate_", "marquee"],
|
||||
patterns: [/^door_/, /^gate_/, /^marquee/],
|
||||
interactionTypes: ["gate"],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
{
|
||||
key: "plants",
|
||||
label: "Piante",
|
||||
labelEn: "Plants",
|
||||
icon: 220,
|
||||
root: "costruire",
|
||||
keywords: ["plant_", "tree_", "flower_", "bush_", "cactus"],
|
||||
patterns: [/^plant_/, /^tree_/, /^flower_/, /^bush_/, /^cactus/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
// Fallback
|
||||
{
|
||||
key: "uncategorized",
|
||||
label: "Non Categorizzati",
|
||||
labelEn: "Uncategorized",
|
||||
icon: 1,
|
||||
root: "furni",
|
||||
keywords: [],
|
||||
patterns: [/.*/],
|
||||
layout: "default_3x3",
|
||||
},
|
||||
];
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* Release eras for organizing Habbo furniture by revision number.
|
||||
*/
|
||||
|
||||
// ── Release Eras ───────────────────────────────────────────────────
|
||||
|
||||
export interface ReleaseEra {
|
||||
key: string;
|
||||
label: string;
|
||||
labelEn: string;
|
||||
revisionMin: number;
|
||||
revisionMax: number;
|
||||
}
|
||||
|
||||
export const RELEASE_ERAS: ReleaseEra[] = [
|
||||
{
|
||||
key: "classic",
|
||||
label: "Classici 2000-2003",
|
||||
labelEn: "Classic 2000-2003",
|
||||
revisionMin: 0,
|
||||
revisionMax: 19999,
|
||||
},
|
||||
{
|
||||
key: "mid",
|
||||
label: "Era HC 2004-2006",
|
||||
labelEn: "HC Era 2004-2006",
|
||||
revisionMin: 20000,
|
||||
revisionMax: 39999,
|
||||
},
|
||||
{
|
||||
key: "modern",
|
||||
label: "Moderni 2007-2009",
|
||||
labelEn: "Modern 2007-2009",
|
||||
revisionMin: 40000,
|
||||
revisionMax: 59999,
|
||||
},
|
||||
{
|
||||
key: "recent",
|
||||
label: "Recenti 2010-2012",
|
||||
labelEn: "Recent 2010-2012",
|
||||
revisionMin: 60000,
|
||||
revisionMax: 79999,
|
||||
},
|
||||
{
|
||||
key: "latest",
|
||||
label: "Ultimi 2013+",
|
||||
labelEn: "Latest 2013+",
|
||||
revisionMin: 80000,
|
||||
revisionMax: Infinity,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Match a revision number to a release era.
|
||||
*/
|
||||
export function matchEra(revision: number): ReleaseEra {
|
||||
for (const era of RELEASE_ERAS) {
|
||||
if (revision >= era.revisionMin && revision <= era.revisionMax) {
|
||||
return era;
|
||||
}
|
||||
}
|
||||
return RELEASE_ERAS[RELEASE_ERAS.length - 1];
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Supreme catalog categories + release eras for organizing Habbo furniture.
|
||||
*
|
||||
* Matching priority (first match wins within each level):
|
||||
* 1. Interaction type (most specific)
|
||||
* 2. Keywords anywhere in item name (normalized, *N stripped)
|
||||
* 3. Regex prefix patterns (on normalized name)
|
||||
* 4. Auto-prefix grouping (handled in route.ts)
|
||||
* 5. Uncategorized fallback
|
||||
*
|
||||
* Each category belongs to a root: furni, costruire, animali, vestiti, or staff.
|
||||
*/
|
||||
|
||||
export * from "./categories";
|
||||
export * from "./eras";
|
||||
|
||||
import { SUPREME_CATEGORIES, type SupremeCategory } from "./categories";
|
||||
|
||||
// ── Name Normalization ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize a Habbo item classname for matching.
|
||||
* Strips the *N color suffix (e.g. "chair_plasto*5" -> "chair_plasto")
|
||||
* and lowercases the result.
|
||||
*/
|
||||
export function normalizeName(itemName: string): string {
|
||||
return itemName.replace(/\*\d+$/, "").toLowerCase();
|
||||
}
|
||||
|
||||
// ── Category Detection ──────────────────────────────────────────
|
||||
|
||||
export const UNCATEGORIZED_KEY = "uncategorized";
|
||||
export const AUTO_PREFIX_MIN_ITEMS = 5;
|
||||
|
||||
/**
|
||||
* Find the supreme category for an item by classname and interaction type.
|
||||
*
|
||||
* Priority: interaction type -> keywords (anywhere in normalized name) -> regex patterns.
|
||||
* First match wins within each priority level.
|
||||
* Item names are normalized (strip *N suffix, lowercase) before matching.
|
||||
*/
|
||||
export function matchCategory(
|
||||
itemName: string,
|
||||
interactionType?: string,
|
||||
): SupremeCategory {
|
||||
const lower = normalizeName(itemName);
|
||||
|
||||
// Pass 1: Interaction type (most specific)
|
||||
for (const cat of SUPREME_CATEGORIES) {
|
||||
if (interactionType && cat.interactionTypes?.includes(interactionType)) {
|
||||
return cat;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Keywords anywhere in name (user priority)
|
||||
for (const cat of SUPREME_CATEGORIES) {
|
||||
if (cat.key === UNCATEGORIZED_KEY) continue;
|
||||
for (const kw of cat.keywords) {
|
||||
if (lower.includes(kw)) return cat;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Regex prefix patterns (fallback)
|
||||
for (const cat of SUPREME_CATEGORIES) {
|
||||
if (cat.key === UNCATEGORIZED_KEY) continue;
|
||||
for (const pattern of cat.patterns) {
|
||||
if (pattern.test(lower)) return cat;
|
||||
}
|
||||
}
|
||||
|
||||
return SUPREME_CATEGORIES[SUPREME_CATEGORIES.length - 1];
|
||||
}
|
||||
|
||||
// ── Auto-Prefix Grouping ────────────────────────────────────────────
|
||||
|
||||
export interface AutoDetectedCategory {
|
||||
key: string;
|
||||
prefix: string;
|
||||
label: string;
|
||||
labelEn: string;
|
||||
icon: number;
|
||||
layout: string;
|
||||
autoDetected: true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prefix from an item name for auto-grouping.
|
||||
* - Strips *N color suffix first ("chair_plasto*5" -> "chair_plasto")
|
||||
* - If name contains '_', take everything before the first '_'
|
||||
* - If no underscore, take the alphabetic prefix (letters before first digit)
|
||||
* - Lowercase the result; return null if prefix is empty or 1 character
|
||||
*/
|
||||
export function extractPrefix(itemName: string): string | null {
|
||||
const normalized = normalizeName(itemName);
|
||||
const idx = normalized.indexOf("_");
|
||||
if (idx > 1) return normalized.substring(0, idx);
|
||||
const match = normalized.match(/^([a-z]{2,})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/** Format a Date as 'YYYY-MM-DD HH:MM:SS' — used across admin and radio pages. */
|
||||
export function formatDateTime(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "";
|
||||
}
|
||||
|
||||
/** Format a Date as 'YYYY-MM-DD' — used across radio list pages. */
|
||||
export function formatDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 10) : "";
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { env } from "@/env";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { DatabaseError } from "./errors";
|
||||
import { getRequestId } from "./request-context";
|
||||
|
||||
const globalForDb = globalThis as unknown as { _db?: DbService };
|
||||
|
||||
interface HealthStatus {
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
poolSize: number;
|
||||
activeQueries: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class DbService {
|
||||
private readonly client: PrismaClient;
|
||||
private lastHealthCheck = 0;
|
||||
private healthCache: HealthStatus | null = null;
|
||||
private readonly healthTtlMs = 10_000;
|
||||
|
||||
constructor() {
|
||||
const url = new URL(env.DATABASE_URL);
|
||||
const adapter = new PrismaMariaDb({
|
||||
host: url.hostname,
|
||||
port: Number(url.port) || 3306,
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ""),
|
||||
connectionLimit: env.DATABASE_POOL_SIZE,
|
||||
connectTimeout: env.DATABASE_CONNECT_TIMEOUT_MS,
|
||||
acquireTimeout: env.DATABASE_CONNECT_TIMEOUT_MS,
|
||||
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
this.client = new PrismaClient({
|
||||
adapter,
|
||||
log:
|
||||
env.NODE_ENV === "development"
|
||||
? [
|
||||
{ emit: "event", level: "query" },
|
||||
{ emit: "event", level: "error" },
|
||||
]
|
||||
: [{ emit: "event", level: "error" }],
|
||||
});
|
||||
|
||||
if (env.NODE_ENV === "development") {
|
||||
this.client.$on("query" as never, (e: unknown) => {
|
||||
const ev = e as { query: string; duration: number };
|
||||
logger.debug("DB query", {
|
||||
query: ev.query.slice(0, 200),
|
||||
durationMs: ev.duration,
|
||||
requestId: getRequestId(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.client.$on("error" as never, (e: unknown) => {
|
||||
const ev = e as { message: string };
|
||||
logger.error("DB error", {
|
||||
message: ev.message,
|
||||
requestId: getRequestId(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get prisma(): PrismaClient {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async health(): Promise<HealthStatus> {
|
||||
const now = Date.now();
|
||||
if (this.healthCache && now - this.lastHealthCheck < this.healthTtlMs) {
|
||||
return this.healthCache;
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await this.client.$queryRaw`SELECT 1`;
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
this.healthCache = {
|
||||
ok: true,
|
||||
latencyMs,
|
||||
poolSize: env.DATABASE_POOL_SIZE,
|
||||
activeQueries: 0,
|
||||
};
|
||||
this.lastHealthCheck = now;
|
||||
return this.healthCache;
|
||||
} catch (cause) {
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
const message =
|
||||
cause instanceof Error ? cause.message : "Unknown database error";
|
||||
this.healthCache = {
|
||||
ok: false,
|
||||
latencyMs,
|
||||
poolSize: env.DATABASE_POOL_SIZE,
|
||||
activeQueries: 0,
|
||||
error: message,
|
||||
};
|
||||
this.lastHealthCheck = now;
|
||||
return this.healthCache;
|
||||
}
|
||||
}
|
||||
|
||||
async execute<T>(fn: (client: PrismaClient) => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn(this.client);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Query failed", cause);
|
||||
}
|
||||
}
|
||||
|
||||
async transaction<T>(
|
||||
fn: (
|
||||
tx: Omit<
|
||||
PrismaClient,
|
||||
"$connect" | "$disconnect" | "$on" | "$use" | "$extends"
|
||||
>,
|
||||
) => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await this.client.$transaction(fn);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Transaction failed", cause);
|
||||
}
|
||||
}
|
||||
|
||||
async rawQuery<T>(
|
||||
strings: TemplateStringsArray,
|
||||
...values: unknown[]
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await this.client.$queryRaw<T>(strings, ...values);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Raw query failed", cause);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a raw SQL string with parameterized ? placeholders.
|
||||
* Named "Unsafe" because the caller is responsible for using ? placeholders
|
||||
* and never interpolating user input directly into the query string.
|
||||
*/
|
||||
async executeRawUnsafe(query: string, ...values: unknown[]): Promise<number> {
|
||||
try {
|
||||
return await this.client.$executeRawUnsafe(query, ...values);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Execute raw failed", cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const db = globalForDb._db ?? new DbService();
|
||||
if (env.NODE_ENV !== "production") globalForDb._db = db;
|
||||
@@ -1,79 +0,0 @@
|
||||
export {
|
||||
actionError,
|
||||
actionOk,
|
||||
adminAction,
|
||||
authAction,
|
||||
handleActionError,
|
||||
} from "./action";
|
||||
|
||||
export { DbService, db } from "./database";
|
||||
export {
|
||||
ConflictError,
|
||||
DatabaseError,
|
||||
DomainError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
RateLimitError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "./errors";
|
||||
export {
|
||||
addSecurityHeaders,
|
||||
chain,
|
||||
protectAdminRoutes,
|
||||
withRequestContext,
|
||||
} from "./middleware";
|
||||
|
||||
export {
|
||||
createStore,
|
||||
elapsed,
|
||||
getClientIp,
|
||||
getRequestId,
|
||||
getRequestStore,
|
||||
runWithStore,
|
||||
setContextUserId,
|
||||
} from "./request-context";
|
||||
export {
|
||||
canonicalize,
|
||||
canonicalizeFormData,
|
||||
canonicalizeFormValue,
|
||||
extractClientIpAsync,
|
||||
redirectSafe,
|
||||
safeRedirect,
|
||||
sanitizeFilename,
|
||||
setCsrfCookie,
|
||||
validateCsrfToken,
|
||||
} from "./security";
|
||||
export type {
|
||||
ActionContext,
|
||||
ActionFailure,
|
||||
ActionResult,
|
||||
ActionSuccess,
|
||||
AdminActionContext,
|
||||
AppSession,
|
||||
IpAddress,
|
||||
PaginatedQuery,
|
||||
PaginatedResult,
|
||||
PermissionSet,
|
||||
RankId,
|
||||
RequestContext,
|
||||
RequestId,
|
||||
SessionUser,
|
||||
UserId,
|
||||
} from "./types";
|
||||
export {
|
||||
bigIntString,
|
||||
boolString,
|
||||
buildSearchQuery,
|
||||
email,
|
||||
hexColor,
|
||||
idParam,
|
||||
look,
|
||||
nonNegativeInt,
|
||||
pagination,
|
||||
password,
|
||||
positiveInt,
|
||||
slug,
|
||||
url,
|
||||
username,
|
||||
} from "./validation";
|
||||
@@ -1,102 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { createStore, getRequestId, runWithStore } from "./request-context";
|
||||
import { extractClientIpAsync, safeRedirect } from "./security";
|
||||
|
||||
type MiddlewareHandler = (req: NextRequest) => Promise<NextResponse | null>;
|
||||
|
||||
export function chain(...handlers: MiddlewareHandler[]): MiddlewareHandler {
|
||||
return async (req: NextRequest) => {
|
||||
for (const handler of handlers) {
|
||||
const result = await handler(req);
|
||||
if (result) return result;
|
||||
}
|
||||
return NextResponse.next();
|
||||
};
|
||||
}
|
||||
|
||||
export function withRequestContext(
|
||||
handler: MiddlewareHandler,
|
||||
): MiddlewareHandler {
|
||||
return async (req: NextRequest) => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
return runWithStore(store, async () => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await handler(req);
|
||||
const duration = Date.now() - start;
|
||||
logger.info("Request completed", {
|
||||
method: req.method,
|
||||
path: req.nextUrl.pathname,
|
||||
status: result?.status ?? 200,
|
||||
durationMs: duration,
|
||||
requestId: getRequestId(),
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - start;
|
||||
logger.error("Request failed", {
|
||||
method: req.method,
|
||||
path: req.nextUrl.pathname,
|
||||
durationMs: duration,
|
||||
requestId: getRequestId(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
const safeUrl = new URL(
|
||||
safeRedirect(req.nextUrl.pathname, "/"),
|
||||
req.url,
|
||||
);
|
||||
return NextResponse.redirect(safeUrl);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function protectAdminRoutes(req: NextRequest): NextResponse | null {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
if (!pathname.startsWith("/admin")) return null;
|
||||
|
||||
const authToken =
|
||||
req.cookies.get("next-auth.session-token")?.value ??
|
||||
req.cookies.get("__Secure-next-auth.session-token")?.value;
|
||||
|
||||
if (!authToken) {
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function addSecurityHeaders(req: NextRequest): NextResponse | null {
|
||||
if (req.method === "OPTIONS") return null;
|
||||
|
||||
const response = NextResponse.next();
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: https: http:",
|
||||
"font-src 'self' https:",
|
||||
"connect-src 'self' https: wss:",
|
||||
"frame-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join("; ");
|
||||
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-Frame-Options", "DENY");
|
||||
response.headers.set("X-XSS-Protection", "0");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
response.headers.set(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=()",
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const USERNAME_RE = /^[a-zA-Z0-9\-_.]+$/;
|
||||
|
||||
export const username = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Username is required")
|
||||
.max(32, "Username must be at most 32 characters")
|
||||
.regex(
|
||||
USERNAME_RE,
|
||||
"Username may only contain letters, numbers, hyphens, underscores, and dots",
|
||||
)
|
||||
.transform((v) => v.normalize("NFC"));
|
||||
|
||||
export const password = z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128, "Password must be at most 128 characters");
|
||||
|
||||
export const email = z
|
||||
.string()
|
||||
.trim()
|
||||
.email("Invalid email address")
|
||||
.max(255, "Email must be at most 255 characters")
|
||||
.transform((v) => v.normalize("NFC").toLowerCase());
|
||||
|
||||
const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i;
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
export const hexColor = z
|
||||
.string()
|
||||
.length(7, "Must be exactly 7 characters (e.g. #ff0000)")
|
||||
.regex(HEX_COLOR_RE, "Must be a valid hex color (e.g. #ff0000)");
|
||||
|
||||
export const slug = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Slug is required")
|
||||
.max(64, "Slug must be at most 64 characters")
|
||||
.regex(
|
||||
SLUG_RE,
|
||||
"Slug must be lowercase alphanumeric with hyphens only between characters",
|
||||
)
|
||||
.refine(
|
||||
(v) => !v.startsWith("-") && !v.endsWith("-"),
|
||||
"Slug must not start or end with a hyphen",
|
||||
)
|
||||
.transform((v) => v.normalize("NFC"));
|
||||
|
||||
export const url = z
|
||||
.string()
|
||||
.url("Invalid URL")
|
||||
.max(2048, "URL must be at most 2048 characters");
|
||||
|
||||
export const look = z
|
||||
.string()
|
||||
.max(512, "Look string must be at most 512 characters")
|
||||
.regex(/^[a-zA-Z0-9.\-_\s]+$/, "Invalid look format")
|
||||
.optional();
|
||||
|
||||
export const positiveInt = z
|
||||
.number()
|
||||
.int("Must be a whole number")
|
||||
.positive("Must be positive");
|
||||
|
||||
export const nonNegativeInt = z
|
||||
.number()
|
||||
.int("Must be a whole number")
|
||||
.nonnegative("Must not be negative");
|
||||
|
||||
export const bigIntString = z
|
||||
.string()
|
||||
.regex(/^\d+$/, "Must be a numeric string")
|
||||
.transform(BigInt);
|
||||
|
||||
export const idParam = z
|
||||
.string()
|
||||
.regex(/^\d+$/, "ID must be numeric")
|
||||
.transform(Number);
|
||||
|
||||
export const pagination = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
perPage: z.coerce.number().int().min(1).max(250).default(50),
|
||||
sort: z.string().optional(),
|
||||
order: z.enum(["asc", "desc"]).optional(),
|
||||
search: z.string().max(256).optional(),
|
||||
});
|
||||
|
||||
export const boolString = z
|
||||
.string()
|
||||
.transform((v) => v === "true" || v === "1")
|
||||
.or(z.boolean());
|
||||
|
||||
export function buildSearchQuery(fields: string[], search: string | undefined) {
|
||||
if (!search?.trim()) return undefined;
|
||||
const sanitized = search.normalize("NFC").trim().slice(0, 256);
|
||||
return fields.map((f) => ({ [f]: { contains: sanitized } }));
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
/**
|
||||
* Self-hosted avatar imager — Phase 1.
|
||||
*
|
||||
* Pipeline (cascade):
|
||||
* 1. In-memory LRU cache → instant
|
||||
* 2. Disk cache → fast
|
||||
* 3. In-flight dedup → coalesce thundering herd
|
||||
* 4. Upstream Habbo proxy → save to disk + memory
|
||||
* 5. Transparent fallback → never break the UI
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { InFlight, LruCache, NegativeCache } from "./memory-cache";
|
||||
|
||||
const CACHE_DIR = join(process.cwd(), "public", "cache", "avatars");
|
||||
const DEFAULT_UPSTREAM = "https://www.habbo.com/habbo-imaging/avatarimage";
|
||||
|
||||
const MEM_CACHE_CAPACITY = 500;
|
||||
const NEGATIVE_CACHE_TTL_MS = 60_000;
|
||||
const UPSTREAM_TIMEOUT_MS = 10_000;
|
||||
|
||||
// 1×1 transparent PNG (67 bytes) — served when both local and upstream fail.
|
||||
const TRANSPARENT_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
export interface AvatarRenderOptions {
|
||||
figure: string;
|
||||
size?: "s" | "m" | "l";
|
||||
direction?: number;
|
||||
headDirection?: number;
|
||||
headOnly?: boolean;
|
||||
gesture?: string;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export interface AvatarRenderResult {
|
||||
buffer: Buffer;
|
||||
etag: string;
|
||||
source: "memory" | "disk" | "upstream" | "fallback";
|
||||
}
|
||||
|
||||
const memCache = new LruCache<Buffer>(MEM_CACHE_CAPACITY);
|
||||
const inFlight = new InFlight<AvatarRenderResult>();
|
||||
const negativeCache = new NegativeCache(NEGATIVE_CACHE_TTL_MS);
|
||||
|
||||
function getCacheKey(opts: AvatarRenderOptions): string {
|
||||
const raw = [
|
||||
opts.figure,
|
||||
opts.size ?? "m",
|
||||
opts.direction ?? 2,
|
||||
opts.headDirection ?? 3,
|
||||
opts.headOnly ? "h" : "f",
|
||||
opts.gesture ?? "",
|
||||
opts.action ?? "",
|
||||
].join("_");
|
||||
return createHash("md5").update(raw).digest("hex");
|
||||
}
|
||||
|
||||
function getCachePath(key: string): string {
|
||||
return join(CACHE_DIR, key.slice(0, 2), `${key}.png`);
|
||||
}
|
||||
|
||||
function makeEtag(key: string): string {
|
||||
return `W/"${key}"`;
|
||||
}
|
||||
|
||||
function saveToCache(cachePath: string, data: Buffer): void {
|
||||
try {
|
||||
const dir = dirname(cachePath);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(cachePath, data);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function isPng(buf: Buffer): boolean {
|
||||
return (
|
||||
buf.length > 8 &&
|
||||
buf[0] === 0x89 &&
|
||||
buf[1] === 0x50 &&
|
||||
buf[2] === 0x4e &&
|
||||
buf[3] === 0x47
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveUpstreamBase(): Promise<string> {
|
||||
const fromEnv = process.env.IMAGING_UPSTREAM_URL?.trim();
|
||||
if (fromEnv) return fromEnv.replace(/\/+$/, "");
|
||||
const fromSettings =
|
||||
(await siteSettings.get("habbo_imaging_url", DEFAULT_UPSTREAM)) ??
|
||||
DEFAULT_UPSTREAM;
|
||||
return fromSettings.replace(/\/+$/, "") || DEFAULT_UPSTREAM;
|
||||
}
|
||||
|
||||
async function fetchUpstream(opts: AvatarRenderOptions): Promise<Buffer | null> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
figure: opts.figure,
|
||||
direction: String(opts.direction ?? 2),
|
||||
head_direction: String(opts.headDirection ?? 3),
|
||||
size: opts.size ?? "m",
|
||||
});
|
||||
if (opts.headOnly) params.set("headonly", "1");
|
||||
if (opts.gesture) params.set("gesture", opts.gesture);
|
||||
if (opts.action) params.set("action", opts.action);
|
||||
|
||||
const base = await resolveUpstreamBase();
|
||||
const url = `${base}?${params.toString()}`;
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
return isPng(buffer) ? buffer : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an avatar image. Always returns a result — never throws, never returns
|
||||
* null. Worst case is a 1×1 transparent PNG so the UI never shows broken
|
||||
* images.
|
||||
*/
|
||||
export async function renderAvatar(
|
||||
opts: AvatarRenderOptions,
|
||||
): Promise<AvatarRenderResult> {
|
||||
const key = getCacheKey(opts);
|
||||
const etag = makeEtag(key);
|
||||
|
||||
const fromMem = memCache.get(key);
|
||||
if (fromMem) return { buffer: fromMem, etag, source: "memory" };
|
||||
|
||||
const cachePath = getCachePath(key);
|
||||
if (existsSync(cachePath)) {
|
||||
try {
|
||||
const buf = readFileSync(cachePath);
|
||||
memCache.set(key, buf);
|
||||
return { buffer: buf, etag, source: "disk" };
|
||||
} catch {
|
||||
/* fall through to fetch */
|
||||
}
|
||||
}
|
||||
|
||||
if (negativeCache.isMarked(key)) {
|
||||
return { buffer: TRANSPARENT_PNG, etag, source: "fallback" };
|
||||
}
|
||||
|
||||
return inFlight.dedup(key, async () => {
|
||||
if (existsSync(cachePath)) {
|
||||
try {
|
||||
const buf = readFileSync(cachePath);
|
||||
memCache.set(key, buf);
|
||||
return { buffer: buf, etag, source: "disk" };
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
|
||||
const useUpstream =
|
||||
(await siteSettings.get("imaging_use_habbo_fallback", "1")) === "1";
|
||||
if (useUpstream) {
|
||||
const upstream = await fetchUpstream(opts);
|
||||
if (upstream) {
|
||||
saveToCache(cachePath, upstream);
|
||||
memCache.set(key, upstream);
|
||||
return { buffer: upstream, etag, source: "upstream" };
|
||||
}
|
||||
}
|
||||
|
||||
negativeCache.mark(key);
|
||||
return { buffer: TRANSPARENT_PNG, etag, source: "fallback" };
|
||||
});
|
||||
}
|
||||
|
||||
export function getImagerStats() {
|
||||
return {
|
||||
memCacheSize: memCache.size,
|
||||
memCacheCapacity: MEM_CACHE_CAPACITY,
|
||||
cacheDir: CACHE_DIR,
|
||||
};
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Tiny LRU + in-flight dedup utilities for the avatar imager.
|
||||
* Pure in-house, zero deps. Map preserves insertion order, so we
|
||||
* implement LRU by deleting + re-inserting on access.
|
||||
*/
|
||||
|
||||
export class LruCache<V> {
|
||||
private map = new Map<string, V>();
|
||||
|
||||
constructor(private readonly capacity: number) {}
|
||||
|
||||
get(key: string): V | undefined {
|
||||
const v = this.map.get(key);
|
||||
if (v === undefined) return undefined;
|
||||
this.map.delete(key);
|
||||
this.map.set(key, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
set(key: string, value: V): void {
|
||||
if (this.map.has(key)) this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
if (this.map.size > this.capacity) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
if (oldest !== undefined) this.map.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.map.has(key);
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.map.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-flight request deduplication. If N callers ask for the same key
|
||||
* concurrently, only the first one runs `loader` and the rest await
|
||||
* the same Promise.
|
||||
*/
|
||||
export class InFlight<V> {
|
||||
private pending = new Map<string, Promise<V>>();
|
||||
|
||||
async dedup(key: string, loader: () => Promise<V>): Promise<V> {
|
||||
const existing = this.pending.get(key);
|
||||
if (existing) return existing;
|
||||
const p = loader().finally(() => {
|
||||
this.pending.delete(key);
|
||||
});
|
||||
this.pending.set(key, p);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Negative cache: marks a key as "known to fail" for `ttlMs` so we don't
|
||||
* hammer the upstream / re-attempt expensive renders for broken figures.
|
||||
*/
|
||||
export class NegativeCache {
|
||||
private map = new Map<string, number>();
|
||||
|
||||
constructor(private readonly ttlMs: number) {}
|
||||
|
||||
isMarked(key: string): boolean {
|
||||
const exp = this.map.get(key);
|
||||
if (exp === undefined) return false;
|
||||
if (Date.now() > exp) {
|
||||
this.map.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
mark(key: string): void {
|
||||
this.map.set(key, Date.now() + this.ttlMs);
|
||||
if (this.map.size > 1000) {
|
||||
const now = Date.now();
|
||||
for (const [k, exp] of this.map) {
|
||||
if (now > exp) this.map.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { matchCategory, normalizeName } from "@/lib/catalog-categories";
|
||||
import { getAncestors } from "@/lib/services/catalog-tree";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
|
||||
/**
|
||||
* Categorize a file using matchCategory logic.
|
||||
* Returns the category key.
|
||||
*/
|
||||
export function categorizeFile(filename: string): string {
|
||||
const name = filename.replace(/\.nitro$/, "");
|
||||
const cat = matchCategory(normalizeName(name));
|
||||
return cat.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a nested folder path from catalog page ancestors.
|
||||
* Example: page "HC Rares" under "Rares" under "Furni" → "furni/rares/hc-rares"
|
||||
* Includes the root page in the path.
|
||||
*/
|
||||
export async function buildCatalogPath(pageId: number): Promise<string> {
|
||||
const ancestors = await getAncestors(pageId);
|
||||
if (ancestors.length === 0) return "uncategorized";
|
||||
|
||||
return ancestors.map((a) => slugifyPath(a.caption)).join("/");
|
||||
}
|
||||
|
||||
/** Sanitize a catalog page name into a valid folder segment */
|
||||
export function slugifyPath(name: string): string {
|
||||
return (
|
||||
name
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "uncategorized"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the flat furniture folder for .nitro files.
|
||||
*/
|
||||
export async function scanFlatFolder(): Promise<string[]> {
|
||||
try {
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const entries = await fs.readdir(nitroDir);
|
||||
return entries.filter((e) => e.endsWith(".nitro"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan ALL .nitro files recursively (flat + subfolders).
|
||||
* Returns a Set of classnames (filename without .nitro extension).
|
||||
*/
|
||||
export async function scanAllNitroFiles(): Promise<Set<string>> {
|
||||
try {
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const entries = await fs.readdir(nitroDir, { recursive: true });
|
||||
const classnames = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const name = typeof entry === "string" ? entry : String(entry);
|
||||
if (name.endsWith(".nitro")) {
|
||||
const basename = path.basename(name, ".nitro");
|
||||
classnames.add(basename);
|
||||
}
|
||||
}
|
||||
return classnames;
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user