chore: remove dead code flagged by knip
Local Build and Deploy / deploy (push) Successful in 1m7s

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:
openhands committed 2026-07-18 19:08:17 +02:00
1 parent 60eb45be73
commit 3c9c1311ec
23 files changed
+586 -2158

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": [
"src/app/**/page.{ts,tsx}",
"src/app/**/layout.{ts,tsx}",
"src/app/**/route.{ts,tsx}",
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
"src/app/**/actions.{ts,tsx}",
"next.config.ts",
"src/middleware.ts"
],
"project": ["src/**/*.{ts,tsx}"],
"ignore": [
"src/**/*.test.{ts,tsx}",
"scripts/**"
],
"ignoreDependencies": ["@types/*", "knip"]
}
+1
View File
@@ -74,6 +74,7 @@
"@types/react-dom": "^19.2.0",
"@types/sanitize-html": "^2.16.1",
"dotenv": "^16.4.0",
"knip": "^6.27.0",
"pino-pretty": "^13.1.3",
"postcss": "^8.5.19",
"prisma": "^7.8.0",
+567
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
"use server";
File diff suppressed because it is too large. Load diff
-86
View File
@@ -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);
}
-40
View File
@@ -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>
);
}
-29
View File
@@ -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>
);
}
-100
View File
@@ -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
View File
@@ -1 +0,0 @@
export * from "./catalog-categories/index";
File diff suppressed because it is too large. Load diff
-63
View File
@@ -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];
}
-99
View File
@@ -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;
}
-9
View File
@@ -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) : "";
}
File diff suppressed because it is too large. Load diff
-79
View File
@@ -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";
File diff suppressed because it is too large. Load diff
-99
View File
@@ -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 } }));
}
File diff suppressed because it is too large. Load diff
-97
View File
@@ -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();
}
}
-74
View File
@@ -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();
}
}