From c989a029abb900b8dfd256163f842d725a9337aa Mon Sep 17 00:00:00 2001 From: cita Date: Wed, 18 Mar 2026 21:45:07 +0800 Subject: [PATCH] fix: recover sqlite migration gaps and harden route regex --- src/server/db/migrate.ts | 64 ++++++++--- src/server/services/tokenRouter.test.ts | 7 +- src/server/services/tokenRouter.ts | 138 +++++++++++++++++++++--- 3 files changed, 179 insertions(+), 30 deletions(-) diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts index bfa3a91..a2758d4 100644 --- a/src/server/db/migrate.ts +++ b/src/server/db/migrate.ts @@ -274,6 +274,14 @@ function markMigrationRecordIfMissing(sqlite: Database.Database, record: Migrati return true; } +function hasMigrationRecord(sqlite: Database.Database, record: MigrationRecord): boolean { + if (!tableExists(sqlite, '__drizzle_migrations')) return false; + const row = sqlite + .prepare('SELECT 1 FROM "__drizzle_migrations" WHERE "hash" = ? LIMIT 1') + .get(record.hash); + return !!row; +} + function normalizeSchemaErrorMessage(error: unknown): string { if (!error || typeof error !== 'object') { return String(error || ''); @@ -332,25 +340,28 @@ function isSitesPlatformUrlUniqueConflictError(error: unknown): boolean { === normalizeSqlForMatch('CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`,`url`);'); } -function getLatestRecordedMigrationCreatedAt(sqlite: Database.Database): number | null { - if (!tableExists(sqlite, '__drizzle_migrations')) return null; - const row = sqlite - .prepare('SELECT created_at FROM "__drizzle_migrations" ORDER BY created_at DESC LIMIT 1') - .get() as { created_at?: number } | undefined; - if (!row || row.created_at === undefined || row.created_at === null) { - return null; - } - return Number(row.created_at); -} - function replayMigrationStatements(sqlite: Database.Database, statements: string[]): void { for (const statement of statements) { try { sqlite.exec(statement); } catch (error) { - if (!isRecoverableSchemaConflictError(error)) { - throw error; + if (isRecoverableSchemaConflictError(error)) { + continue; } + + if (isSitesPlatformUrlUniqueConflictError(error) && deduplicateLegacySitesForUniqueIndex(sqlite)) { + try { + sqlite.exec(statement); + continue; + } catch (retryError) { + if (isRecoverableSchemaConflictError(retryError)) { + continue; + } + throw retryError; + } + } + + throw error; } } } @@ -366,20 +377,40 @@ function recoverMigrationSequence( return false; } - let latestRecordedCreatedAt = getLatestRecordedMigrationCreatedAt(sqlite); for (const migration of migrations.slice(0, failedMigrationIndex + 1)) { - if (latestRecordedCreatedAt !== null && latestRecordedCreatedAt >= migration.createdAt) { + if (hasMigrationRecord(sqlite, migration)) { continue; } replayMigrationStatements(sqlite, migration.statements); markMigrationRecordIfMissing(sqlite, migration); - latestRecordedCreatedAt = migration.createdAt; } return true; } +function backfillMissingRecordedMigrations(sqlite: Database.Database, migrationsFolder: string): number { + if (!tableExists(sqlite, '__drizzle_migrations')) return 0; + + let recoveredCount = 0; + for (const migration of readRecoveryMigrations(migrationsFolder)) { + if (hasMigrationRecord(sqlite, migration)) { + continue; + } + + replayMigrationStatements(sqlite, migration.statements); + if (markMigrationRecordIfMissing(sqlite, migration)) { + recoveredCount += 1; + } + } + + if (recoveredCount > 0) { + console.warn(`[db] Backfilled ${recoveredCount} missing drizzle migration record(s).`); + } + + return recoveredCount; +} + function tryRecoverDuplicateColumnMigrationError( sqlite: Database.Database, migrationsFolder: string, @@ -557,6 +588,7 @@ export function runSqliteMigrations(): void { const sqlite = new Database(dbPath); bootstrapLegacyDrizzleMigrations(sqlite, migrationsFolder); + backfillMissingRecordedMigrations(sqlite, migrationsFolder); try { migrate(drizzle(sqlite), { migrationsFolder }); diff --git a/src/server/services/tokenRouter.test.ts b/src/server/services/tokenRouter.test.ts index 0c92742..1784d9a 100644 --- a/src/server/services/tokenRouter.test.ts +++ b/src/server/services/tokenRouter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { filterRecentlyFailedCandidates, isChannelRecentlyFailed } from './tokenRouter.js'; +import { filterRecentlyFailedCandidates, isChannelRecentlyFailed, matchesModelPattern, parseRegexModelPattern } from './tokenRouter.js'; type Candidate = { channel: { @@ -10,6 +10,11 @@ type Candidate = { }; describe('filterRecentlyFailedCandidates', () => { + it('rejects unsafe nested-quantifier regex route patterns', () => { + expect(parseRegexModelPattern('re:^(a+)+$')).toBeNull(); + expect(matchesModelPattern('aaaa', 're:^(a+)+$')).toBe(false); + }); + it('uses a short default recent-failure window', () => { const nowMs = Date.now(); expect(isChannelRecentlyFailed({ diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index 4d40af4..526b506 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -43,6 +43,7 @@ const FAILURE_BACKOFF_BASE_SEC = 15; const MIN_EFFECTIVE_UNIT_COST = 1e-6; const ROUND_ROBIN_FAILURE_THRESHOLD = 3; const ROUND_ROBIN_COOLDOWN_LEVELS_SEC = [0, 10 * 60, 60 * 60, 24 * 60 * 60] as const; +const MAX_ROUTE_REGEX_BODY_LENGTH = 256; function fibonacciNumber(index: number): number { if (index <= 2) return 1; @@ -285,19 +286,130 @@ type CostSignal = { source: 'observed' | 'configured' | 'catalog' | 'fallback'; }; -export function isRegexModelPattern(pattern: string): boolean { - return pattern.trim().toLowerCase().startsWith('re:'); -} - -export function parseRegexModelPattern(pattern: string): RegExp | null { - if (!isRegexModelPattern(pattern)) return null; - const body = pattern.trim().slice(3).trim(); - if (!body) return null; - try { - return new RegExp(body); - } catch { - return null; - } +export function isRegexModelPattern(pattern: string): boolean { + return pattern.trim().toLowerCase().startsWith('re:'); +} + +function readRegexQuantifierLength(pattern: string, startIndex: number): number { + const ch = pattern[startIndex]; + if (ch === '*' || ch === '+' || ch === '?') return 1; + if (ch !== '{') return 0; + + let index = startIndex + 1; + let sawDigit = false; + while (index < pattern.length && /\d/.test(pattern[index])) { + sawDigit = true; + index += 1; + } + if (!sawDigit) return 0; + + if (pattern[index] === ',') { + index += 1; + while (index < pattern.length && /\d/.test(pattern[index])) { + index += 1; + } + } + + if (pattern[index] !== '}') return 0; + return index - startIndex + 1; +} + +function isSafeRegexPatternBody(body: string): boolean { + if (!body || body.length > MAX_ROUTE_REGEX_BODY_LENGTH) return false; + if (!/^[a-z0-9\s.^$|()[\]{}+*?\\:_/-]+$/i.test(body)) return false; + if (body.includes('(?=') || body.includes('(?!') || body.includes('(?<=') || body.includes('(? = []; + let escaped = false; + let inCharClass = false; + + for (let index = 0; index < body.length; index += 1) { + const ch = body[index]; + + if (escaped) { + escaped = false; + continue; + } + + if (ch === '\\') { + escaped = true; + continue; + } + + if (inCharClass) { + if (ch === ']') inCharClass = false; + continue; + } + + if (ch === '[') { + inCharClass = true; + continue; + } + + if (ch === '(') { + if (body[index + 1] === '?') { + if (body[index + 2] !== ':') { + return false; + } + groupStack.push({ hasInnerQuantifier: false, hasAlternation: false }); + index += 2; + continue; + } + + groupStack.push({ hasInnerQuantifier: false, hasAlternation: false }); + continue; + } + + if (ch === '|') { + if (groupStack.length > 0) { + groupStack[groupStack.length - 1].hasAlternation = true; + } + continue; + } + + if (ch === ')') { + const group = groupStack.pop(); + if (!group) return false; + + const quantifierLength = readRegexQuantifierLength(body, index + 1); + if (quantifierLength > 0 && (group.hasInnerQuantifier || group.hasAlternation)) { + return false; + } + + const parent = groupStack[groupStack.length - 1]; + if (parent && (group.hasInnerQuantifier || quantifierLength > 0)) { + parent.hasInnerQuantifier = true; + } + continue; + } + + const quantifierLength = readRegexQuantifierLength(body, index); + if (quantifierLength > 0) { + if (groupStack.length > 0) { + groupStack[groupStack.length - 1].hasInnerQuantifier = true; + } + index += quantifierLength - 1; + } + } + + return !escaped && !inCharClass && groupStack.length === 0; +} + +export function parseRegexModelPattern(pattern: string): RegExp | null { + if (!isRegexModelPattern(pattern)) return null; + const body = pattern.trim().slice(3).trim(); + if (!body) return null; + if (!isSafeRegexPatternBody(body)) return null; + try { + return new RegExp(body); + } catch { + return null; + } } export function matchesModelPattern(model: string, pattern: string): boolean {