Reapply "Add missing admin action files and navigation links"
This reverts commit 4d515bc400.
This commit is contained in:
1 parent
96ed768f14
commit
5b4228261a
338 files changed
+28143
-5948
No files matched your search
@@ -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
@@ -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
@@ -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,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,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 () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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"],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -66,11 +66,7 @@ function alertEmail(): string | undefined {
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user