style: format code biome
This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
+97
-89
@@ -1,133 +1,141 @@
|
||||
import "dotenv/config";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { splitSqlStatements } from "./sql-statements";
|
||||
import { mysqlConnectionUrl } from "./db-url";
|
||||
import { splitSqlStatements } from "./sql-statements";
|
||||
|
||||
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;
|
||||
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 parsed = new URL(url);
|
||||
const dbName = decodeURIComponent(parsed.pathname.replace(/^\//, "")) || "atomcms";
|
||||
return { url: mysqlConnectionUrl(url), database: dbName };
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("DATABASE_URL is required");
|
||||
const parsed = new URL(url);
|
||||
const dbName =
|
||||
decodeURIComponent(parsed.pathname.replace(/^\//, "")) || "atomcms";
|
||||
return { url: mysqlConnectionUrl(url), database: dbName };
|
||||
}
|
||||
|
||||
async function ensureConnection(): Promise<void> {
|
||||
const { url } = getDbConfig();
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(url);
|
||||
try {
|
||||
await conn.execute(
|
||||
`CREATE TABLE IF NOT EXISTS \`${TRACKING_TABLE}\` (
|
||||
const { url } = getDbConfig();
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(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();
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function getApplied(): Promise<Set<string>> {
|
||||
const { url } = getDbConfig();
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(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();
|
||||
}
|
||||
const { url } = getDbConfig();
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(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));
|
||||
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 };
|
||||
});
|
||||
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 { url } = getDbConfig();
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(url);
|
||||
try {
|
||||
const statements = splitSqlStatements(migration.sql);
|
||||
const { url } = getDbConfig();
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(url);
|
||||
try {
|
||||
const statements = splitSqlStatements(migration.sql);
|
||||
|
||||
for (const stmt of statements) {
|
||||
await conn.execute(stmt);
|
||||
}
|
||||
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();
|
||||
}
|
||||
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];
|
||||
const flag = process.argv[2];
|
||||
|
||||
if (flag === "--status") {
|
||||
await ensureConnection();
|
||||
const applied = await getApplied();
|
||||
const all = loadMigrations();
|
||||
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]"}`);
|
||||
}
|
||||
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;
|
||||
}
|
||||
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));
|
||||
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;
|
||||
}
|
||||
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.");
|
||||
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);
|
||||
console.error("[migrate] Fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -3,10 +3,11 @@ import { findMissingLocalImports } from "../src/lib/local-imports";
|
||||
const missing = await findMissingLocalImports(process.cwd());
|
||||
|
||||
if (missing.length === 0) {
|
||||
console.log("No unresolved local imports.");
|
||||
process.exit(0);
|
||||
console.log("No unresolved local imports.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const item of missing) console.error(`${item.importer}: ${item.specifier}`);
|
||||
for (const item of missing)
|
||||
console.error(`${item.importer}: ${item.specifier}`);
|
||||
console.error(`${missing.length} unresolved local import(s).`);
|
||||
process.exitCode = 1;
|
||||
@@ -2,12 +2,12 @@ import { describe, expect, it } from "vitest";
|
||||
import { mysqlConnectionUrl } from "./db-url";
|
||||
|
||||
describe("mysqlConnectionUrl", () => {
|
||||
it("removes Prisma-only pool options before passing the URL to MySQL2", () => {
|
||||
const result = mysqlConnectionUrl(
|
||||
"mysql://user:pass@localhost:3306/cms?connection_limit=20&pool_timeout=30&charset=utf8mb4",
|
||||
);
|
||||
expect(result).not.toContain("connection_limit");
|
||||
expect(result).not.toContain("pool_timeout");
|
||||
expect(result).toContain("charset=utf8mb4");
|
||||
});
|
||||
it("removes Prisma-only pool options before passing the URL to MySQL2", () => {
|
||||
const result = mysqlConnectionUrl(
|
||||
"mysql://user:pass@localhost:3306/cms?connection_limit=20&pool_timeout=30&charset=utf8mb4",
|
||||
);
|
||||
expect(result).not.toContain("connection_limit");
|
||||
expect(result).not.toContain("pool_timeout");
|
||||
expect(result).toContain("charset=utf8mb4");
|
||||
});
|
||||
});
|
||||
+8
-4
@@ -1,7 +1,11 @@
|
||||
const MYSQL2_UNSUPPORTED_OPTIONS = ["connection_limit", "pool_timeout"] as const;
|
||||
const MYSQL2_UNSUPPORTED_OPTIONS = [
|
||||
"connection_limit",
|
||||
"pool_timeout",
|
||||
] as const;
|
||||
|
||||
export function mysqlConnectionUrl(value: string): string {
|
||||
const url = new URL(value);
|
||||
for (const option of MYSQL2_UNSUPPORTED_OPTIONS) url.searchParams.delete(option);
|
||||
return url.toString();
|
||||
const url = new URL(value);
|
||||
for (const option of MYSQL2_UNSUPPORTED_OPTIONS)
|
||||
url.searchParams.delete(option);
|
||||
return url.toString();
|
||||
}
|
||||
+71
-57
@@ -3,82 +3,96 @@ 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;
|
||||
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 { 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`);
|
||||
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 });
|
||||
}
|
||||
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}`);
|
||||
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();
|
||||
// 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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");
|
||||
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)");
|
||||
}
|
||||
// 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)");
|
||||
// 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()]);
|
||||
// Run once on startup
|
||||
await Promise.all([
|
||||
backupEmulatorJar(),
|
||||
cleanupOldLogs(),
|
||||
cleanupOldSessions(),
|
||||
]);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[jobs] Fatal:", err);
|
||||
process.exit(1);
|
||||
console.error("[jobs] Fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -9,132 +9,142 @@
|
||||
* npx tsx scripts/migrate-aes-cbc-to-gcm.ts
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHmac,
|
||||
randomBytes,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
import { prisma } from "../src/lib/prisma";
|
||||
|
||||
function getKey(appKey: string): Buffer {
|
||||
const raw = appKey.startsWith("base64:")
|
||||
? Buffer.from(appKey.slice("base64:".length), "base64")
|
||||
: Buffer.from(appKey, "utf8");
|
||||
if (raw.length !== 32) {
|
||||
throw new Error(`APP_KEY must decode to 32 bytes (got ${raw.length})`);
|
||||
}
|
||||
return raw;
|
||||
const raw = appKey.startsWith("base64:")
|
||||
? Buffer.from(appKey.slice("base64:".length), "base64")
|
||||
: Buffer.from(appKey, "utf8");
|
||||
if (raw.length !== 32) {
|
||||
throw new Error(`APP_KEY must decode to 32 bytes (got ${raw.length})`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** OLD: AES-256-CBC decrypt with HMAC-SHA256 verification. */
|
||||
function decryptCbc(payload: string, key: Buffer, serialize = true): string {
|
||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as {
|
||||
iv: string;
|
||||
value: string;
|
||||
mac: string;
|
||||
};
|
||||
const expected = createHmac("sha256", key)
|
||||
.update(json.iv + json.value)
|
||||
.digest("hex");
|
||||
const a = Buffer.from(expected, "hex");
|
||||
const b = Buffer.from(json.mac, "hex");
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error("The MAC is invalid (CBC payload).");
|
||||
}
|
||||
const iv = Buffer.from(json.iv, "base64");
|
||||
const decipher = createDecipheriv("aes-256-cbc", key, iv);
|
||||
const plain = decipher.update(json.value, "base64", "utf8") + decipher.final("utf8");
|
||||
return serialize ? phpUnserializeString(plain) : plain;
|
||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as {
|
||||
iv: string;
|
||||
value: string;
|
||||
mac: string;
|
||||
};
|
||||
const expected = createHmac("sha256", key)
|
||||
.update(json.iv + json.value)
|
||||
.digest("hex");
|
||||
const a = Buffer.from(expected, "hex");
|
||||
const b = Buffer.from(json.mac, "hex");
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error("The MAC is invalid (CBC payload).");
|
||||
}
|
||||
const iv = Buffer.from(json.iv, "base64");
|
||||
const decipher = createDecipheriv("aes-256-cbc", key, iv);
|
||||
const plain =
|
||||
decipher.update(json.value, "base64", "utf8") + decipher.final("utf8");
|
||||
return serialize ? phpUnserializeString(plain) : plain;
|
||||
}
|
||||
|
||||
/** NEW: AES-256-GCM encrypt (mirrors current LaravelEncrypter). */
|
||||
function encryptGcm(plaintext: string, key: Buffer, serialize = true): string {
|
||||
const iv = randomBytes(12);
|
||||
const data = serialize ? phpSerializeString(plaintext) : plaintext;
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const valueB64 = cipher.update(data, "utf8", "base64") + cipher.final("base64");
|
||||
const tag = cipher.getAuthTag();
|
||||
const ivB64 = iv.toString("base64");
|
||||
const tagB64 = tag.toString("base64");
|
||||
const payload = JSON.stringify({ iv: ivB64, value: valueB64, tag: tagB64 });
|
||||
return Buffer.from(payload, "utf8").toString("base64");
|
||||
const iv = randomBytes(12);
|
||||
const data = serialize ? phpSerializeString(plaintext) : plaintext;
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const valueB64 =
|
||||
cipher.update(data, "utf8", "base64") + cipher.final("base64");
|
||||
const tag = cipher.getAuthTag();
|
||||
const ivB64 = iv.toString("base64");
|
||||
const tagB64 = tag.toString("base64");
|
||||
const payload = JSON.stringify({ iv: ivB64, value: valueB64, tag: tagB64 });
|
||||
return Buffer.from(payload, "utf8").toString("base64");
|
||||
}
|
||||
|
||||
/** Tries to decrypt a payload with the NEW GCM logic; if it works, skip. */
|
||||
function isAlreadyGcm(payload: string, key: Buffer): boolean {
|
||||
try {
|
||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
||||
if (!json.tag && !json.mac) return false; // can't determine format
|
||||
if (json.tag) return true; // has authTag => GCM
|
||||
return false; // has mac => CBC
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
function isAlreadyGcm(payload: string, _key: Buffer): boolean {
|
||||
try {
|
||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
||||
if (!json.tag && !json.mac) return false; // can't determine format
|
||||
if (json.tag) return true; // has authTag => GCM
|
||||
return false; // has mac => CBC
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const appKey = process.env.APP_KEY;
|
||||
if (!appKey) {
|
||||
console.error("APP_KEY environment variable is required.");
|
||||
process.exit(1);
|
||||
}
|
||||
const key = getKey(appKey);
|
||||
const appKey = process.env.APP_KEY;
|
||||
if (!appKey) {
|
||||
console.error("APP_KEY environment variable is required.");
|
||||
process.exit(1);
|
||||
}
|
||||
const key = getKey(appKey);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: { twoFactorSecret: { not: null } },
|
||||
select: { id: true, twoFactorSecret: true },
|
||||
});
|
||||
const users = await prisma.user.findMany({
|
||||
where: { twoFactorSecret: { not: null } },
|
||||
select: { id: true, twoFactorSecret: true },
|
||||
});
|
||||
|
||||
console.log(`Found ${users.length} user(s) with a twoFactorSecret.`);
|
||||
console.log(`Found ${users.length} user(s) with a twoFactorSecret.`);
|
||||
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.twoFactorSecret) continue;
|
||||
for (const user of users) {
|
||||
if (!user.twoFactorSecret) continue;
|
||||
|
||||
if (isAlreadyGcm(user.twoFactorSecret, key)) {
|
||||
console.log(` [SKIP] User ${user.id} — already GCM`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (isAlreadyGcm(user.twoFactorSecret, key)) {
|
||||
console.log(` [SKIP] User ${user.id} — already GCM`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const plaintext = decryptCbc(user.twoFactorSecret, key);
|
||||
const reEncrypted = encryptGcm(plaintext, key);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorSecret: reEncrypted },
|
||||
});
|
||||
console.log(` [OK] User ${user.id} — migrated`);
|
||||
migrated++;
|
||||
} catch (err) {
|
||||
console.error(` [FAIL] User ${user.id} — ${err}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const plaintext = decryptCbc(user.twoFactorSecret, key);
|
||||
const reEncrypted = encryptGcm(plaintext, key);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorSecret: reEncrypted },
|
||||
});
|
||||
console.log(` [OK] User ${user.id} — migrated`);
|
||||
migrated++;
|
||||
} catch (err) {
|
||||
console.error(` [FAIL] User ${user.id} — ${err}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone: ${migrated} migrated, ${skipped} skipped, ${errors} errors.`);
|
||||
if (errors > 0) process.exit(1);
|
||||
console.log(
|
||||
`\nDone: ${migrated} migrated, ${skipped} skipped, ${errors} errors.`,
|
||||
);
|
||||
if (errors > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
|
||||
/* ---- helpers (mirrored from laravel-encrypter.ts) ---- */
|
||||
|
||||
function phpSerializeString(value: string): string {
|
||||
return `s:${Buffer.byteLength(value, "utf8")}:"${value}";`;
|
||||
return `s:${Buffer.byteLength(value, "utf8")}:"${value}";`;
|
||||
}
|
||||
|
||||
function phpUnserializeString(serialized: string): string {
|
||||
const m = /^s:(\d+):"/.exec(serialized);
|
||||
if (!m) throw new Error("Not a serialized PHP string");
|
||||
const byteLen = Number(m[1]);
|
||||
const start = m[0].length;
|
||||
const bytes = Buffer.from(serialized, "utf8").subarray(
|
||||
Buffer.byteLength(serialized.slice(0, start), "utf8"),
|
||||
);
|
||||
return bytes.subarray(0, byteLen).toString("utf8");
|
||||
const m = /^s:(\d+):"/.exec(serialized);
|
||||
if (!m) throw new Error("Not a serialized PHP string");
|
||||
const byteLen = Number(m[1]);
|
||||
const start = m[0].length;
|
||||
const bytes = Buffer.from(serialized, "utf8").subarray(
|
||||
Buffer.byteLength(serialized.slice(0, start), "utf8"),
|
||||
);
|
||||
return bytes.subarray(0, byteLen).toString("utf8");
|
||||
}
|
||||
@@ -3,10 +3,13 @@ import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("radio columns migration", () => {
|
||||
it("adds every column idempotently for partially migrated databases", () => {
|
||||
const sql = readFileSync(resolve("prisma/migrations/0009_radio_contests_giveaways_columns.sql"), "utf8");
|
||||
const additions = sql.match(/ADD COLUMN(?! IF NOT EXISTS)/gi) ?? [];
|
||||
expect(additions).toEqual([]);
|
||||
expect(sql).toContain("ADD COLUMN IF NOT EXISTS `title`");
|
||||
});
|
||||
it("adds every column idempotently for partially migrated databases", () => {
|
||||
const sql = readFileSync(
|
||||
resolve("prisma/migrations/0009_radio_contests_giveaways_columns.sql"),
|
||||
"utf8",
|
||||
);
|
||||
const additions = sql.match(/ADD COLUMN(?! IF NOT EXISTS)/gi) ?? [];
|
||||
expect(additions).toEqual([]);
|
||||
expect(sql).toContain("ADD COLUMN IF NOT EXISTS `title`");
|
||||
});
|
||||
});
|
||||
@@ -2,17 +2,17 @@ import { describe, expect, it } from "vitest";
|
||||
import { splitSqlStatements } from "./sql-statements";
|
||||
|
||||
describe("splitSqlStatements", () => {
|
||||
it("ignores semicolons inside line comments", () => {
|
||||
const sql = [
|
||||
"-- Existing installs have this; new installs need it.",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS example TEXT NULL;",
|
||||
"-- next statement",
|
||||
"CREATE TABLE IF NOT EXISTS example_table (id INT PRIMARY KEY);",
|
||||
].join("\n");
|
||||
it("ignores semicolons inside line comments", () => {
|
||||
const sql = [
|
||||
"-- Existing installs have this; new installs need it.",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS example TEXT NULL;",
|
||||
"-- next statement",
|
||||
"CREATE TABLE IF NOT EXISTS example_table (id INT PRIMARY KEY);",
|
||||
].join("\n");
|
||||
|
||||
expect(splitSqlStatements(sql)).toEqual([
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS example TEXT NULL",
|
||||
"CREATE TABLE IF NOT EXISTS example_table (id INT PRIMARY KEY)",
|
||||
]);
|
||||
});
|
||||
expect(splitSqlStatements(sql)).toEqual([
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS example TEXT NULL",
|
||||
"CREATE TABLE IF NOT EXISTS example_table (id INT PRIMARY KEY)",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
export function splitSqlStatements(sql: string): string[] {
|
||||
const withoutComments = sql.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*--.*$/gm, "");
|
||||
const withoutComments = sql
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*--.*$/gm, "");
|
||||
|
||||
return withoutComments
|
||||
.split(";")
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean);
|
||||
return withoutComments
|
||||
.split(";")
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user