Reapply "Add missing admin action files and navigation links"

This reverts commit 4d515bc400.
This commit is contained in:
Simo committed 2026-07-11 20:52:56 +02:00
1 parent 96ed768f14
commit 5b4228261a
338 files changed
+28143 -5948

No files matched your search

+1 -2
View File
@@ -22,8 +22,7 @@ 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.
+11 -7
View File
@@ -23,12 +23,18 @@ 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);
@@ -67,11 +73,9 @@ 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;
}
+2 -13
View File
@@ -1,15 +1,4 @@
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";
+1 -5
View File
@@ -1,10 +1,6 @@
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")}`;
+1 -7
View File
@@ -1,12 +1,6 @@
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 () => {
+1 -5
View File
@@ -39,11 +39,7 @@ 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) {
+1 -3
View File
@@ -22,9 +22,7 @@ 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>,
+33 -4
View File
@@ -8,10 +8,39 @@ 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"],
+1 -2
View File
@@ -54,8 +54,7 @@ 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) {
+3 -12
View File
@@ -66,11 +66,7 @@ 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;");
}
/**
@@ -144,9 +140,7 @@ 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>`;
@@ -166,10 +160,7 @@ 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 {
+1 -2
View File
@@ -14,8 +14,7 @@ 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 };
+1 -3
View File
@@ -39,9 +39,7 @@ 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
+3 -7
View File
@@ -11,8 +11,7 @@
// 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();
@@ -28,9 +27,7 @@ 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 };
}
@@ -111,8 +108,7 @@ 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 };
}
+1 -4
View File
@@ -11,10 +11,7 @@ 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: {