fix: production hardening — migration script, security fixes, structured logging, API docs, component splitting

- Create apply-migrations.ts and jobs-worker.ts scripts (package.json references)
- Convert badge leaderboard from $queryRawUnsafe to $queryRaw with Prisma.sql templates
- Fix OAuth email binding: add oauth_require_link site setting, skip 2FA-protected accounts
- Add per-user 2FA rate limiting (5/30s) to prevent TOTP brute-force
- Add structured JSON logger with levels (debug/info/warn/error)
- Split 341-line HomePage into GuestView + UserView components
- Add OpenAPI v3.1 spec at /api/openapi.json
- Add LOG_LEVEL env var, regenerate Prisma client
- Add mysql2 dependency for migration scripts
- All 58 tests pass, typecheck clean
This commit is contained in:
openhands committed 2026-07-08 13:06:02 +02:00
1 parent 5c638cd6bc
commit c5db7f5156
17 files changed
+1140 -414

No files matched your search

+4
View File
@@ -69,3 +69,7 @@ PAYPAL_API=https://api-m.sandbox.paypal.com
# allowing horizontal scaling across multiple instances. Falls back to in-process # allowing horizontal scaling across multiple instances. Falls back to in-process
# Maps when unset. # Maps when unset.
REDIS_URL=redis://127.0.0.1:6379 REDIS_URL=redis://127.0.0.1:6379
# Logging level (debug | info | warn | error). Defaults to 'info' in production,
# 'debug' in development.
LOG_LEVEL=info
+1
View File
@@ -26,6 +26,7 @@
"ioredis": "^5.11.1", "ioredis": "^5.11.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"mysql2": "^3.22.6",
"next": "^16.2.10", "next": "^16.2.10",
"next-auth": "5.0.0-beta.31", "next-auth": "5.0.0-beta.31",
"next-intl": "^4.13.1", "next-intl": "^4.13.1",
+27
View File
@@ -32,6 +32,9 @@ importers:
lucide-react: lucide-react:
specifier: ^1.23.0 specifier: ^1.23.0
version: 1.23.0([email protected]) version: 1.23.0([email protected])
mysql2:
specifier: ^3.22.6
version: 3.22.6(@types/[email protected])
next: next:
specifier: ^16.2.10 specifier: ^16.2.10
version: 16.2.10([email protected]([email protected]))([email protected]) version: 16.2.10([email protected]([email protected]))([email protected])
@@ -2201,6 +2204,12 @@ packages:
peerDependencies: peerDependencies:
'@types/node': '>= 8' '@types/node': '>= 8'
[email protected]:
resolution: {integrity: sha512-fPKmeDGUzvFP7bMD5SASlJ5zIgvCC4hbanTmhbUlEmhyrY1hR4Hi3xLOWgTd3luYjLVifx6uGvMNJ2m/LlqEpg==}
engines: {node: '>= 8.0'}
peerDependencies:
'@types/node': '>= 8'
[email protected]: [email protected]:
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@@ -2488,6 +2497,10 @@ packages:
resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==}
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
[email protected]:
resolution: {integrity: sha512-Ti/Zx9J3aITMYUMFhCKbkplQjyi7Kk2SyYXp+rzrkyKZetIy19XbQtVZ+0ZR5aVm/178tIJ6+aVvBqXkH+Xs7w==}
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
[email protected]: [email protected]:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -4195,6 +4208,18 @@ snapshots:
named-placeholders: 1.1.6 named-placeholders: 1.1.6
sql-escaper: 1.3.3 sql-escaper: 1.3.3
[email protected](@types/[email protected]):
dependencies:
'@types/node': 22.20.0
aws-ssl-profiles: 1.1.2
denque: 2.1.0
generate-function: 2.3.1
iconv-lite: 0.7.2
long: 5.3.2
lru.min: 1.1.4
named-placeholders: 1.1.6
sql-escaper: 1.4.0
[email protected]: [email protected]:
dependencies: dependencies:
lru.min: 1.1.4 lru.min: 1.1.4
@@ -4507,6 +4532,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+84
View File
@@ -0,0 +1,84 @@
import { Cron } from "croner";
import { env } from "../src/env";
import { prisma } from "../src/lib/prisma";
async function backupEmulatorJar(): Promise<void> {
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } = await import("node:fs");
const { resolve } = await import("node:path");
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
const backupFile = resolve(env.EMULATOR_BACKUP_DIR, `emulator-${timestamp}.jar`);
if (!existsSync(env.EMULATOR_BACKUP_DIR)) {
mkdirSync(env.EMULATOR_BACKUP_DIR, { recursive: true });
}
try {
copyFileSync(env.EMULATOR_JAR_PATH, backupFile);
console.log(`[jobs] Backed up emulator JAR to ${backupFile}`);
// Rotate: keep only the N newest
const keep = env.EMULATOR_BACKUP_KEEP ?? 7;
const files = readdirSync(env.EMULATOR_BACKUP_DIR)
.filter((f) => f.startsWith("emulator-") && f.endsWith(".jar"))
.sort()
.reverse();
for (let i = keep; i < files.length; i++) {
unlinkSync(resolve(env.EMULATOR_BACKUP_DIR, files[i]));
console.log(`[jobs] Rotated out old backup: ${files[i]}`);
}
} catch (err) {
console.error("[jobs] JAR backup failed:", err);
}
}
async function cleanupOldLogs(): Promise<void> {
try {
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
await prisma.websiteLoginLogs.deleteMany({ where: { createdAt: { lt: cutoff } } });
console.log("[jobs] Cleaned up login logs older than 30 days");
} catch (err) {
console.error("[jobs] Log cleanup failed:", err);
}
}
async function cleanupOldSessions(): Promise<void> {
try {
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
await prisma.passwordReset.deleteMany({ where: { createdAt: { lt: cutoff } } });
console.log("[jobs] Cleaned up expired password reset tokens");
} catch (err) {
console.error("[jobs] Session cleanup failed:", err);
}
}
async function main() {
console.log("[jobs] Worker started");
// JAR backup — daily at 03:00
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
new Cron("0 3 * * *", () => {
backupEmulatorJar().catch((e) => console.error("[jobs] Backup error:", e));
});
console.log("[jobs] Scheduled: emulator JAR backup (daily 03:00)");
}
// Log cleanup — daily at 04:00
new Cron("0 4 * * *", () => {
Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) =>
console.error("[jobs] Cleanup error:", e),
);
});
console.log("[jobs] Scheduled: old data cleanup (daily 04:00)");
// Run once on startup
await Promise.all([backupEmulatorJar(), cleanupOldLogs(), cleanupOldSessions()]);
}
main().catch((err) => {
console.error("[jobs] Fatal:", err);
process.exit(1);
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"noEmit": true
},
"include": ["./**/*.ts"]
}
File diff suppressed because it is too large. Load diff
+9 -323
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2
View File
@@ -63,6 +63,8 @@ const schema = z.object({
PAYPAL_API: z.string().url().optional(), PAYPAL_API: z.string().url().optional(),
// Optional Redis — enables shared caching for rate limiting and site settings. // Optional Redis — enables shared caching for rate limiting and site settings.
REDIS_URL: z.string().optional(), REDIS_URL: z.string().optional(),
// Logging level.
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
}); });
type Env = z.infer<typeof schema>; type Env = z.infer<typeof schema>;
+49 -30
View File
File diff suppressed because it is too large. Load diff
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { generateRequestId } from "./logger";
describe("generateRequestId", () => {
it("produces a non-empty string", () => {
const id = generateRequestId();
expect(id).toBeTruthy();
expect(typeof id).toBe("string");
});
it("produces unique values on successive calls", () => {
const a = generateRequestId();
const b = generateRequestId();
expect(a).not.toBe(b);
});
});
+71
View File
@@ -0,0 +1,71 @@
type LogLevel = "debug" | "info" | "warn" | "error";
interface LogEntry {
level: LogLevel;
message: string;
timestamp: string;
requestId?: string;
module?: string;
[key: string]: unknown;
}
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
const currentLevel: LogLevel =
(process.env.LOG_LEVEL as LogLevel) ?? (process.env.NODE_ENV === "production" ? "info" : "debug");
let requestIdCounter = 0;
export function generateRequestId(): string {
requestIdCounter += 1;
return `${Date.now().toString(36)}-${requestIdCounter.toString(36)}`;
}
function shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
}
function formatLog(entry: LogEntry): string {
return JSON.stringify(entry);
}
function writeLog(entry: LogEntry): void {
if (!shouldLog(entry.level)) return;
const formatted = formatLog(entry);
switch (entry.level) {
case "error":
console.error(formatted);
break;
case "warn":
console.warn(formatted);
break;
default:
console.log(formatted);
break;
}
}
export const logger = {
debug(message: string, meta: Record<string, unknown> = {}): void {
writeLog({ level: "debug", message, timestamp: new Date().toISOString(), ...meta });
},
info(message: string, meta: Record<string, unknown> = {}): void {
writeLog({ level: "info", message, timestamp: new Date().toISOString(), ...meta });
},
warn(message: string, meta: Record<string, unknown> = {}): void {
writeLog({ level: "warn", message, timestamp: new Date().toISOString(), ...meta });
},
error(message: string, meta: Record<string, unknown> = {}): void {
writeLog({ level: "error", message, timestamp: new Date().toISOString(), ...meta });
},
};
@@ -0,0 +1,13 @@
<!-- To: [email protected] | From: | Subject: Verify your email · Epicnabbo -->
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
<h2 style="margin:0 0 0.5rem">Verify your email</h2>
<p>Welcome to Epicnabbo! Confirm this email address to finish setting up your account.</p>
<p style="margin:1.25rem 0">
<a href="https://epicnabbo.nl/verify?token=5e21ef6e09952e9bb7257d54c1fe896713c0f05e6d92a1f8669869d875ab2693&email=juanedavid%40hotmail.com"
style="display:inline-block;padding:0.6rem 1.2rem;border-radius:8px;background:#eeb425;color:#1a1a2e;font-weight:700;text-decoration:none">
Verify email
</a>
</p>
<p style="color:#64748b;font-size:0.875rem">If the button doesn't work, paste this link into your browser:</p>
<p style="color:#64748b;font-size:0.875rem;word-break:break-all">https://epicnabbo.nl/verify?token=5e21ef6e09952e9bb7257d54c1fe896713c0f05e6d92a1f8669869d875ab2693&email=juanedavid%40hotmail.com</p>
</div>
File diff suppressed because it is too large. Load diff