Revert "Add missing admin action files and navigation links"

This reverts commit 41be6835bf.
This commit is contained in:
Simo committed 2026-07-11 20:37:56 +02:00
1 parent 4a1e1115b3
commit 4d515bc400
338 files changed
+5949 -28144

No files matched your search

+2 -1
View File
@@ -22,7 +22,8 @@ function isExempt(path: string): boolean {
export async function enforceSiteAccess(): Promise<void> {
const h = await headers();
const path = h.get("x-pathname") ?? "/";
const ip = h.get("x-real-client-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "0.0.0.0";
const ip =
h.get("x-real-client-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "0.0.0.0";
// Abuse/DDoS guard: count this request and block flooding IPs (no-op unless
// enabled in settings). Best-effort — never let it throw past the guard.
+7 -11
View File
@@ -23,18 +23,12 @@ async function verify2faCode(userId: number, code: string): Promise<boolean> {
if (!appKey) throw new Error("APP_KEY not configured");
const secret = new LaravelEncrypter(appKey).decrypt(user.twoFactorSecret);
if (verifyTotp(code, secret)) return true;
} catch {
/* fall through to recovery */
}
} catch { /* fall through to recovery */ }
// Try recovery codes
if (user.twoFactorRecoveryCodes) {
let codes: string[];
try {
codes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
} catch {
return false;
}
try { codes = JSON.parse(user.twoFactorRecoveryCodes) as string[]; } catch { return false; }
const idx = codes.indexOf(code);
if (idx !== -1) {
codes.splice(idx, 1);
@@ -73,9 +67,11 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
// Prevent timing-based enumeration: always run a dummy hash check.
await checkLogin(password, "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd", {
convertPasswords: false,
});
await checkLogin(
password,
"$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd",
{ convertPasswords: false },
);
return null;
}
+13 -2
View File
@@ -1,4 +1,15 @@
export { checkLogin, hashPassword, isMd5Of, md5Hex, verifyPassword, type LoginCheck } from "./password";
export {
checkLogin,
hashPassword,
isMd5Of,
md5Hex,
verifyPassword,
type LoginCheck,
} from "./password";
export { generateSsoTicket, issueSsoTicket, type SsoUserUpdater } from "./sso-ticket";
export { LaravelEncrypter, phpSerializeString, phpUnserializeString } from "./laravel-encrypter";
export {
LaravelEncrypter,
phpSerializeString,
phpUnserializeString,
} from "./laravel-encrypter";
export { generateTotp, totpKeyUri, verifyTotp } from "./totp";
+5 -1
View File
@@ -1,6 +1,10 @@
import { randomBytes } from "node:crypto";
import { describe, expect, it } from "vitest";
import { LaravelEncrypter, phpSerializeString, phpUnserializeString } from "./laravel-encrypter";
import {
LaravelEncrypter,
phpSerializeString,
phpUnserializeString,
} from "./laravel-encrypter";
// Dynamically generated 32-byte key so no secret is hardcoded in source.
const APP_KEY = `base64:${randomBytes(32).toString("base64")}`;
+7 -1
View File
@@ -1,6 +1,12 @@
import { hash as bcryptHash } from "bcryptjs";
import { describe, expect, it } from "vitest";
import { checkLogin, hashPassword, isMd5Of, md5Hex, verifyPassword } from "./password";
import {
checkLogin,
hashPassword,
isMd5Of,
md5Hex,
verifyPassword,
} from "./password";
describe("md5Hex", () => {
it("matches PHP md5() on canonical vectors", async () => {
+5 -1
View File
@@ -39,7 +39,11 @@ function cleanup(): void {
}
}
export async function rateLimit(key: string, limit: number, windowMs: number): Promise<RateLimitResult> {
export async function rateLimit(
key: string,
limit: number,
windowMs: number,
): Promise<RateLimitResult> {
const now = Date.now();
if (redis) {
+3 -1
View File
@@ -22,7 +22,9 @@ function createRedis(): Redis | null {
}
export const redis: Redis | null =
globalForRedis.redis !== undefined ? globalForRedis.redis : (globalForRedis.redis = createRedis());
globalForRedis.redis !== undefined
? globalForRedis.redis
: (globalForRedis.redis = createRedis());
export async function withRedis<T>(
fallback: () => Promise<T>,
+4 -33
View File
@@ -8,39 +8,10 @@ import sanitizeHtml from "sanitize-html";
*/
const OPTIONS: sanitizeHtml.IOptions = {
allowedTags: [
"a",
"b",
"i",
"em",
"strong",
"u",
"s",
"p",
"br",
"hr",
"span",
"div",
"ul",
"ol",
"li",
"blockquote",
"code",
"pre",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"img",
"figure",
"figcaption",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"a", "b", "i", "em", "strong", "u", "s", "p", "br", "hr", "span", "div",
"ul", "ol", "li", "blockquote", "code", "pre",
"h1", "h2", "h3", "h4", "h5", "h6",
"img", "figure", "figcaption", "table", "thead", "tbody", "tr", "th", "td",
],
allowedAttributes: {
a: ["href", "title", "target", "rel"],
+2 -1
View File
@@ -54,7 +54,8 @@ export async function recordRequest(ip: string): Promise<void> {
if (!(await siteSettings.getBool("abuse_guard_enabled", false))) return;
const limit = Number(await siteSettings.get("abuse_guard_threshold", "200")) || 200;
const windowMs = (Number(await siteSettings.get("abuse_guard_window_seconds", "10")) || 10) * 1000;
const windowMs =
(Number(await siteSettings.get("abuse_guard_window_seconds", "10")) || 10) * 1000;
const now = Date.now();
if (buckets.size > 10_000) {
+12 -3
View File
@@ -66,7 +66,11 @@ function alertEmail(): string | undefined {
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
@@ -140,7 +144,9 @@ async function emailStaff(input: SendAlertInput): Promise<boolean> {
const html =
`<h2 style="margin:0 0 8px">${escapeHtml(input.type)} — ${escapeHtml(String(input.severity))}</h2>` +
`<p style="margin:0 0 12px">${escapeHtml(input.message)}</p>` +
(contextRows ? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>` : "") +
(contextRows
? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>`
: "") +
`<p style="margin-top:16px;color:#888;font-size:12px">` +
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/admin/alerts">view alerts</a></p>`;
@@ -160,7 +166,10 @@ async function emailStaff(input: SendAlertInput): Promise<boolean> {
*/
export async function sendAlert(input: SendAlertInput): Promise<SendAlertResult> {
// Fan out Discord + email first so we can record their outcome on the row.
const [sentViaDiscord, sentViaEmail] = await Promise.all([postDiscord(input), emailStaff(input)]);
const [sentViaDiscord, sentViaEmail] = await Promise.all([
postDiscord(input),
emailStaff(input),
]);
let logged = false;
try {
+2 -1
View File
@@ -14,7 +14,8 @@ export interface IpVerdict {
reason?: string;
}
const PRIVATE_RE = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1|fc|fd|localhost$|0\.0\.0\.0$)/i;
const PRIVATE_RE =
/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1|fc|fd|localhost$|0\.0\.0\.0$)/i;
export async function checkVpn(ip: string): Promise<IpVerdict> {
if (!ip || PRIVATE_RE.test(ip)) return { blocked: false };
+3 -1
View File
@@ -39,7 +39,9 @@ async function loadWordFilter(): Promise<string[]> {
const rows = await prisma.websiteWordfilter.findMany({
select: { word: true },
});
wordFilterCache = rows.map((r) => r.word.trim().toLowerCase()).filter((w) => w.length > 0);
wordFilterCache = rows
.map((r) => r.word.trim().toLowerCase())
.filter((w) => w.length > 0);
wordFilterLoadedAt = now;
} catch {
// DB unavailable — return an empty filter WITHOUT caching, so the next
+7 -3
View File
@@ -11,7 +11,8 @@
// carries `credits`, the in-game wallet), so a top-up credits the buyer's
// `credits` wallet via sendCurrency(), exactly like the voucher flow.
export const PAYPAL_API = process.env.PAYPAL_API?.replace(/\/+$/, "") ?? "https://api-m.sandbox.paypal.com";
export const PAYPAL_API =
process.env.PAYPAL_API?.replace(/\/+$/, "") ?? "https://api-m.sandbox.paypal.com";
export const PAYPAL_CURRENCY = (process.env.PAYPAL_CURRENCY ?? "USD").toUpperCase();
@@ -27,7 +28,9 @@ function credentials(): { clientId: string; secret: string } {
const clientId = process.env.PAYPAL_CLIENT_ID;
const secret = process.env.PAYPAL_SECRET;
if (!clientId || !secret) {
throw new PayPalConfigError("PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET.");
throw new PayPalConfigError(
"PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET.",
);
}
return { clientId, secret };
}
@@ -108,7 +111,8 @@ export async function createOrder(
id: string;
links?: { rel: string; href: string }[];
};
const approveUrl = json.links?.find((l) => l.rel === "approve" || l.rel === "payer-action")?.href ?? null;
const approveUrl =
json.links?.find((l) => l.rel === "approve" || l.rel === "payer-action")?.href ?? null;
return { id: json.id, approveUrl };
}
+4 -1
View File
@@ -11,7 +11,10 @@ const TYPE_VALUE: Record<Exclude<CurrencyName, "credits">, number> = {
export interface CurrencyDb {
user: {
update(args: { where: { id: number }; data: { credits: { increment: number } } }): Promise<unknown>;
update(args: {
where: { id: number };
data: { credits: { increment: number } };
}): Promise<unknown>;
};
usersCurrency: {
upsert(args: {