Security fixes: - migrate.ts: SQL/Command Injection 방지 (spawnSync 사용) - migrate.ts: Path Traversal 검증 추가 - api-tester.ts: API 키 마스킹 (4자만 노출) - api-tester.ts: 최소 16자 키 길이 검증 - cache.ts: ReDoS 방지 (패턴 길이/와일드카드 제한) Performance improvements: - cache.ts: 순차 삭제 → 병렬 배치 처리 (50개씩) - cache.ts: KV 등록 fire-and-forget (non-blocking) - cache.ts: 메모리 제한 (5000키) - cache.ts: 25초 실행 시간 가드 - cache.ts: 패턴 매칭 prefix 최적화 New features: - 마이그레이션 자동화 시스템 (scripts/migrate.ts) - KV 기반 캐시 인덱스 (invalidatePattern, clearAll) - 글로벌 CacheService 싱글톤 Other: - .env.example 추가, API 키 환경변수 처리 - CACHE_TTL.RECOMMENDATIONS (10분) 분리 - e2e-tester.ts JSON 파싱 에러 핸들링 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
405 lines
12 KiB
TypeScript
405 lines
12 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* Database Migration Runner
|
|
* Automatically detects and executes unapplied SQL migrations
|
|
*
|
|
* Usage:
|
|
* npm run db:migrate # Run on local database
|
|
* npm run db:migrate:remote # Run on remote database
|
|
* npm run db:migrate:status # Show migration status
|
|
*/
|
|
|
|
import { spawnSync } from 'child_process';
|
|
import { readdirSync, readFileSync, existsSync } from 'fs';
|
|
import { join, basename, resolve } from 'path';
|
|
|
|
// ============================================================
|
|
// Configuration
|
|
// ============================================================
|
|
|
|
const DB_NAME = 'cloud-instances-db';
|
|
const MIGRATIONS_DIR = join(process.cwd(), 'migrations');
|
|
const MIGRATION_HISTORY_TABLE = 'migration_history';
|
|
|
|
type Environment = 'local' | 'remote';
|
|
type Command = 'migrate' | 'status';
|
|
|
|
// ============================================================
|
|
// Utility Functions
|
|
// ============================================================
|
|
|
|
/**
|
|
* Sanitize migration name (prevent SQL injection)
|
|
* Only allows alphanumeric characters, underscores, and hyphens
|
|
*/
|
|
function sanitizeMigrationName(name: string): string {
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
throw new Error(`Invalid migration name: ${name} (only alphanumeric, underscore, and hyphen allowed)`);
|
|
}
|
|
return name;
|
|
}
|
|
|
|
/**
|
|
* Validate file path (prevent path traversal)
|
|
* Ensures the file is within the migrations directory
|
|
*/
|
|
function validateFilePath(filename: string): string {
|
|
// Check filename format
|
|
if (!/^[a-zA-Z0-9_-]+\.sql$/.test(filename)) {
|
|
throw new Error(`Invalid filename format: ${filename}`);
|
|
}
|
|
|
|
const filePath = join(MIGRATIONS_DIR, filename);
|
|
const resolvedPath = resolve(filePath);
|
|
const resolvedMigrationsDir = resolve(MIGRATIONS_DIR);
|
|
|
|
// Ensure resolved path is within migrations directory
|
|
if (!resolvedPath.startsWith(resolvedMigrationsDir + '/') && resolvedPath !== resolvedMigrationsDir) {
|
|
throw new Error(`Path traversal detected: ${filename}`);
|
|
}
|
|
|
|
return filePath;
|
|
}
|
|
|
|
/**
|
|
* Execute wrangler d1 command and return output
|
|
* Uses spawnSync to prevent command injection
|
|
*/
|
|
function executeD1Command(sql: string, env: Environment): string {
|
|
const envFlag = env === 'remote' ? '--remote' : '--local';
|
|
const args = ['d1', 'execute', DB_NAME, envFlag, '--command', sql];
|
|
|
|
try {
|
|
const result = spawnSync('wrangler', args, {
|
|
encoding: 'utf-8',
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
if (result.error) {
|
|
throw new Error(`Failed to execute wrangler: ${result.error.message}`);
|
|
}
|
|
|
|
if (result.status !== 0) {
|
|
throw new Error(`D1 command failed with exit code ${result.status}: ${result.stderr}`);
|
|
}
|
|
|
|
return result.stdout;
|
|
} catch (error: any) {
|
|
throw new Error(`D1 command failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute SQL file using wrangler d1
|
|
* Uses spawnSync to prevent command injection
|
|
*/
|
|
function executeD1File(filePath: string, env: Environment): void {
|
|
const envFlag = env === 'remote' ? '--remote' : '--local';
|
|
const args = ['d1', 'execute', DB_NAME, envFlag, '--file', filePath];
|
|
|
|
try {
|
|
const result = spawnSync('wrangler', args, {
|
|
encoding: 'utf-8',
|
|
stdio: 'inherit'
|
|
});
|
|
|
|
if (result.error) {
|
|
throw new Error(`Failed to execute wrangler: ${result.error.message}`);
|
|
}
|
|
|
|
if (result.status !== 0) {
|
|
throw new Error(`Migration file execution failed with exit code ${result.status}`);
|
|
}
|
|
} catch (error: any) {
|
|
throw new Error(`Migration file execution failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get list of applied migrations from database
|
|
*/
|
|
function getAppliedMigrations(env: Environment): Set<string> {
|
|
try {
|
|
const sql = `SELECT migration_name FROM ${MIGRATION_HISTORY_TABLE} WHERE success = 1`;
|
|
const output = executeD1Command(sql, env);
|
|
|
|
const migrations = new Set<string>();
|
|
const lines = output.split('\n');
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (trimmed && !trimmed.includes('migration_name') && !trimmed.includes('─')) {
|
|
migrations.add(trimmed);
|
|
}
|
|
}
|
|
|
|
return migrations;
|
|
} catch (error: any) {
|
|
// If table doesn't exist, return empty set
|
|
if (error.message.includes('no such table')) {
|
|
return new Set<string>();
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get list of migration files from migrations directory
|
|
*/
|
|
function getMigrationFiles(): string[] {
|
|
if (!existsSync(MIGRATIONS_DIR)) {
|
|
throw new Error(`Migrations directory not found: ${MIGRATIONS_DIR}`);
|
|
}
|
|
|
|
const files = readdirSync(MIGRATIONS_DIR)
|
|
.filter(f => f.endsWith('.sql'))
|
|
.filter(f => f !== 'README.md')
|
|
.sort(); // Alphabetical sort ensures numeric order (000, 002, 003, 004)
|
|
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* Extract migration name from filename (without .sql extension)
|
|
*/
|
|
function getMigrationName(filename: string): string {
|
|
return basename(filename, '.sql');
|
|
}
|
|
|
|
/**
|
|
* Record migration execution in history table
|
|
*/
|
|
function recordMigration(
|
|
migrationName: string,
|
|
executionTimeMs: number,
|
|
success: boolean,
|
|
errorMessage: string | null,
|
|
env: Environment
|
|
): void {
|
|
// Sanitize migration name to prevent SQL injection
|
|
const safeMigrationName = sanitizeMigrationName(migrationName);
|
|
|
|
// Escape error message (if any) to prevent SQL injection
|
|
const safeErrorMessage = errorMessage
|
|
? `'${errorMessage.replace(/'/g, "''")}'`
|
|
: 'NULL';
|
|
|
|
const sql = `
|
|
INSERT INTO ${MIGRATION_HISTORY_TABLE}
|
|
(migration_name, execution_time_ms, success, error_message)
|
|
VALUES
|
|
('${safeMigrationName}', ${executionTimeMs}, ${success ? 1 : 0}, ${safeErrorMessage})
|
|
`;
|
|
|
|
try {
|
|
executeD1Command(sql, env);
|
|
} catch (error: any) {
|
|
console.error(`[ERROR] Failed to record migration: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a single migration file
|
|
*/
|
|
function executeMigration(filename: string, env: Environment): boolean {
|
|
const migrationName = getMigrationName(filename);
|
|
|
|
// Validate file path to prevent path traversal attacks
|
|
const filePath = validateFilePath(filename);
|
|
|
|
console.log(`\n[Migrate] Executing: ${migrationName}`);
|
|
console.log(`[Migrate] File: ${filename}`);
|
|
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
executeD1File(filePath, env);
|
|
const executionTime = Date.now() - startTime;
|
|
|
|
console.log(`[Migrate] ✅ Success (${executionTime}ms)`);
|
|
|
|
// Record success (only if not 000_migration_history itself)
|
|
if (migrationName !== '000_migration_history') {
|
|
recordMigration(migrationName, executionTime, true, null, env);
|
|
}
|
|
|
|
return true;
|
|
} catch (error: any) {
|
|
const executionTime = Date.now() - startTime;
|
|
const errorMessage = error.message || 'Unknown error';
|
|
|
|
console.error(`[Migrate] ❌ Failed (${executionTime}ms)`);
|
|
console.error(`[Migrate] Error: ${errorMessage}`);
|
|
|
|
// Record failure (only if not 000_migration_history itself)
|
|
if (migrationName !== '000_migration_history') {
|
|
recordMigration(migrationName, executionTime, false, errorMessage, env);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Main Migration Logic
|
|
// ============================================================
|
|
|
|
/**
|
|
* Run all pending migrations
|
|
*/
|
|
function runMigrations(env: Environment): void {
|
|
console.log(`\n${'='.repeat(60)}`);
|
|
console.log(`[Migrate] Database Migration Runner`);
|
|
console.log(`[Migrate] Environment: ${env}`);
|
|
console.log(`[Migrate] Database: ${DB_NAME}`);
|
|
console.log(`${'='.repeat(60)}\n`);
|
|
|
|
const allMigrations = getMigrationFiles();
|
|
console.log(`[Migrate] Found ${allMigrations.length} migration files`);
|
|
|
|
// Ensure migration history table exists first
|
|
const historyMigration = allMigrations.find(f => f.startsWith('000_migration_history'));
|
|
if (!historyMigration) {
|
|
console.error('[Migrate] ❌ Migration history table file (000_migration_history.sql) not found!');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Execute history table migration first
|
|
console.log(`[Migrate] Ensuring migration tracking table exists...`);
|
|
if (!executeMigration(historyMigration, env)) {
|
|
console.error('[Migrate] ❌ Failed to create migration history table');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Get applied migrations
|
|
const appliedMigrations = getAppliedMigrations(env);
|
|
console.log(`[Migrate] ${appliedMigrations.size} migrations already applied`);
|
|
|
|
// Filter pending migrations (excluding 000_migration_history)
|
|
const pendingMigrations = allMigrations
|
|
.filter(f => !f.startsWith('000_migration_history'))
|
|
.filter(f => !appliedMigrations.has(getMigrationName(f)));
|
|
|
|
if (pendingMigrations.length === 0) {
|
|
console.log('\n[Migrate] ✅ All migrations are up to date. Nothing to do.');
|
|
return;
|
|
}
|
|
|
|
console.log(`\n[Migrate] ${pendingMigrations.length} pending migrations to execute:`);
|
|
pendingMigrations.forEach(f => console.log(` - ${getMigrationName(f)}`));
|
|
|
|
// Execute each pending migration
|
|
let successCount = 0;
|
|
let failureCount = 0;
|
|
|
|
for (const filename of pendingMigrations) {
|
|
const success = executeMigration(filename, env);
|
|
|
|
if (success) {
|
|
successCount++;
|
|
} else {
|
|
failureCount++;
|
|
console.error(`\n[Migrate] ❌ Migration failed: ${filename}`);
|
|
console.error(`[Migrate] Stopping migration process.`);
|
|
break; // Stop on first failure
|
|
}
|
|
}
|
|
|
|
// Summary
|
|
console.log(`\n${'='.repeat(60)}`);
|
|
console.log(`[Migrate] Migration Summary:`);
|
|
console.log(`[Migrate] ✅ Successful: ${successCount}`);
|
|
if (failureCount > 0) {
|
|
console.log(`[Migrate] ❌ Failed: ${failureCount}`);
|
|
}
|
|
console.log(`${'='.repeat(60)}\n`);
|
|
|
|
if (failureCount > 0) {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show migration status
|
|
*/
|
|
function showStatus(env: Environment): void {
|
|
console.log(`\n${'='.repeat(60)}`);
|
|
console.log(`[Migrate] Migration Status`);
|
|
console.log(`[Migrate] Environment: ${env}`);
|
|
console.log(`[Migrate] Database: ${DB_NAME}`);
|
|
console.log(`${'='.repeat(60)}\n`);
|
|
|
|
const allMigrations = getMigrationFiles();
|
|
console.log(`[Migrate] Total migration files: ${allMigrations.length}`);
|
|
|
|
try {
|
|
const appliedMigrations = getAppliedMigrations(env);
|
|
console.log(`[Migrate] Applied migrations: ${appliedMigrations.size}`);
|
|
|
|
// Show detailed status
|
|
console.log(`\n[Migrate] Detailed Status:\n`);
|
|
|
|
for (const filename of allMigrations) {
|
|
const migrationName = getMigrationName(filename);
|
|
const isApplied = appliedMigrations.has(migrationName);
|
|
const status = isApplied ? '✅' : '⏳';
|
|
const label = isApplied ? 'Applied' : 'Pending';
|
|
|
|
console.log(` ${status} ${migrationName.padEnd(40)} [${label}]`);
|
|
}
|
|
|
|
const pendingCount = allMigrations.length - appliedMigrations.size;
|
|
if (pendingCount > 0) {
|
|
console.log(`\n[Migrate] ⚠️ ${pendingCount} migrations pending execution`);
|
|
console.log(`[Migrate] Run 'npm run db:migrate' to apply pending migrations`);
|
|
} else {
|
|
console.log(`\n[Migrate] ✅ All migrations are up to date`);
|
|
}
|
|
|
|
} catch (error: any) {
|
|
console.error(`[Migrate] ❌ Error reading migration status: ${error.message}`);
|
|
console.error(`[Migrate] The migration_history table may not exist yet.`);
|
|
console.error(`[Migrate] Run 'npm run db:migrate' to initialize the system.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`\n${'='.repeat(60)}\n`);
|
|
}
|
|
|
|
// ============================================================
|
|
// CLI Entry Point
|
|
// ============================================================
|
|
|
|
function main(): void {
|
|
const args = process.argv.slice(2);
|
|
|
|
// Parse command
|
|
let command: Command = 'migrate';
|
|
if (args.includes('--status')) {
|
|
command = 'status';
|
|
}
|
|
|
|
// Parse environment
|
|
let env: Environment = 'local';
|
|
if (args.includes('--remote')) {
|
|
env = 'remote';
|
|
}
|
|
|
|
// Execute command
|
|
try {
|
|
if (command === 'status') {
|
|
showStatus(env);
|
|
} else {
|
|
runMigrations(env);
|
|
}
|
|
} catch (error: any) {
|
|
console.error(`\n[Migrate] ❌ Fatal error: ${error.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Run if executed directly
|
|
if (require.main === module) {
|
|
main();
|
|
}
|