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:
1 parent
5c638cd6bc
commit
c5db7f5156
17 files changed
+1175
-449
No files matched your search
@@ -0,0 +1,136 @@
|
||||
import { createConnection } from "node:net";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const MIGRATIONS_DIR = resolve(__dirname, "../prisma/migrations");
|
||||
const TRACKING_TABLE = "cms_migrations";
|
||||
|
||||
interface MigrationFile {
|
||||
id: string;
|
||||
name: string;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
function getDbConfig(): { url: string; database: string } {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("DATABASE_URL is required");
|
||||
const dbName = url.split("/").pop()?.split("?")[0] ?? "atomcms";
|
||||
return { url, database: dbName };
|
||||
}
|
||||
|
||||
async function ensureConnection(): Promise<void> {
|
||||
const { database } = getDbConfig();
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(process.env.DATABASE_URL!);
|
||||
try {
|
||||
await conn.execute(
|
||||
`CREATE TABLE IF NOT EXISTS \`${TRACKING_TABLE}\` (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
migration VARCHAR(255) NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
);
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function getApplied(): Promise<Set<string>> {
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(process.env.DATABASE_URL!);
|
||||
try {
|
||||
const [rows] = await conn.execute(
|
||||
`SELECT migration FROM \`${TRACKING_TABLE}\` ORDER BY id`,
|
||||
);
|
||||
return new Set((rows as { migration: string }[]).map((r) => r.migration));
|
||||
} catch {
|
||||
return new Set();
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
function loadMigrations(): MigrationFile[] {
|
||||
const entries = readdirSync(MIGRATIONS_DIR, { withFileTypes: true });
|
||||
const files = entries
|
||||
.filter((e) => e.isFile() && e.name.endsWith(".sql"))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return files.map((f) => {
|
||||
const id = f.name.replace(/\.sql$/, "");
|
||||
const sql = readFileSync(resolve(MIGRATIONS_DIR, f.name), "utf-8");
|
||||
return { id, name: f.name, sql };
|
||||
});
|
||||
}
|
||||
|
||||
async function apply(migration: MigrationFile): Promise<void> {
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(process.env.DATABASE_URL!);
|
||||
try {
|
||||
const statements = migration.sql
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0 && !s.startsWith("--"));
|
||||
|
||||
for (const stmt of statements) {
|
||||
await conn.execute(stmt);
|
||||
}
|
||||
|
||||
await conn.execute(
|
||||
`INSERT INTO \`${TRACKING_TABLE}\` (migration) VALUES (?)`,
|
||||
[migration.id],
|
||||
);
|
||||
console.log(`[migrate] Applied: ${migration.name}`);
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const flag = process.argv[2];
|
||||
|
||||
if (flag === "--status") {
|
||||
await ensureConnection();
|
||||
const applied = await getApplied();
|
||||
const all = loadMigrations();
|
||||
|
||||
console.log("\nMigration status:\n");
|
||||
for (const m of all) {
|
||||
const done = applied.has(m.id);
|
||||
console.log(` ${done ? "✓" : " "} ${m.name}${done ? "" : " [PENDING]"}`);
|
||||
}
|
||||
|
||||
const pending = all.filter((m) => !applied.has(m.id));
|
||||
const total = all.length;
|
||||
const done = total - pending.length;
|
||||
console.log(`\n${done}/${total} applied, ${pending.length} pending\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureConnection();
|
||||
const applied = await getApplied();
|
||||
const pending = loadMigrations().filter((m) => !applied.has(m.id));
|
||||
|
||||
if (pending.length === 0) {
|
||||
console.log("[migrate] All migrations already applied.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[migrate] Applying ${pending.length} migration(s)...\n`);
|
||||
for (const m of pending) {
|
||||
try {
|
||||
await apply(m);
|
||||
} catch (err) {
|
||||
console.error(`[migrate] FAILED: ${m.name}`, err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log("\n[migrate] Done.");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[migrate] Fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user