import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore } from "../types.ts"; export interface SqliteStatementLike { run(...params: unknown[]): unknown; get(...params: unknown[]): Record | undefined; } export interface SqliteDatabaseLike { exec(sql: string): unknown; prepare(sql: string): SqliteStatementLike; } export interface SqliteCaptchaStoreOptions { challengeTable?: string; tokenTable?: string; initialize?: boolean; } function safeIdentifier(value: string): string { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw new TypeError(`Unsafe SQL identifier: ${value}`); return value; } function parseChallenge( row: Record | undefined, ): CaptchaChallengeRecord | undefined { if (!row) return undefined; return JSON.parse(String(row.payload)) as CaptchaChallengeRecord; } function parseToken( row: Record | undefined, ): CaptchaResponseTokenRecord | undefined { if (!row) return undefined; return JSON.parse(String(row.payload)) as CaptchaResponseTokenRecord; } export class SqliteCaptchaStore implements CaptchaStore { private readonly challengeTable: string; private readonly tokenTable: string; constructor( private readonly db: SqliteDatabaseLike, options: SqliteCaptchaStoreOptions = {}, ) { this.challengeTable = safeIdentifier(options.challengeTable ?? "wrn_captcha_challenges"); this.tokenTable = safeIdentifier(options.tokenTable ?? "wrn_captcha_tokens"); if (options.initialize ?? true) this.initialize(); } initialize(): void { this.db.exec(` CREATE TABLE IF NOT EXISTS ${this.challengeTable} ( id TEXT PRIMARY KEY, payload TEXT NOT NULL, expires_at INTEGER NOT NULL, consumed_at INTEGER, attempts INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS ${this.challengeTable}_expires_idx ON ${this.challengeTable}(expires_at); CREATE TABLE IF NOT EXISTS ${this.tokenTable} ( token_hash TEXT PRIMARY KEY, payload TEXT NOT NULL, expires_at INTEGER NOT NULL, consumed_at INTEGER ); CREATE INDEX IF NOT EXISTS ${this.tokenTable}_expires_idx ON ${this.tokenTable}(expires_at); `); } async createChallenge(record: CaptchaChallengeRecord): Promise { this.db .prepare( `INSERT OR REPLACE INTO ${this.challengeTable} (id, payload, expires_at, consumed_at, attempts) VALUES (?, ?, ?, ?, ?)`, ) .run( record.id, JSON.stringify(record), record.expiresAt, record.consumedAt ?? null, record.attempts, ); } async getChallenge(id: string): Promise { return parseChallenge( this.db.prepare(`SELECT payload FROM ${this.challengeTable} WHERE id = ?`).get(id), ); } async incrementAttempts(id: string, now: number): Promise { const current = await this.getChallenge(id); if (!current || current.expiresAt <= now || current.consumedAt) return undefined; current.attempts += 1; const result = this.db .prepare( `UPDATE ${this.challengeTable} SET payload = ?, attempts = ? WHERE id = ? AND expires_at > ? AND consumed_at IS NULL AND attempts = ?`, ) .run(JSON.stringify(current), current.attempts, id, now, current.attempts - 1) as { changes?: number; }; return result?.changes === 0 ? undefined : current; } async consumeChallenge(id: string, now: number): Promise { const current = await this.getChallenge(id); if (!current || current.expiresAt <= now || current.consumedAt) return undefined; current.consumedAt = now; const result = this.db .prepare( `UPDATE ${this.challengeTable} SET payload = ?, consumed_at = ? WHERE id = ? AND expires_at > ? AND consumed_at IS NULL`, ) .run(JSON.stringify(current), now, id, now) as { changes?: number }; return result?.changes === 0 ? undefined : current; } async deleteChallenge(id: string): Promise { this.db.prepare(`DELETE FROM ${this.challengeTable} WHERE id = ?`).run(id); } async createToken(record: CaptchaResponseTokenRecord): Promise { this.db .prepare( `INSERT OR REPLACE INTO ${this.tokenTable} (token_hash, payload, expires_at, consumed_at) VALUES (?, ?, ?, ?)`, ) .run(record.tokenHash, JSON.stringify(record), record.expiresAt, record.consumedAt ?? null); } async getToken(tokenHash: string): Promise { return parseToken( this.db.prepare(`SELECT payload FROM ${this.tokenTable} WHERE token_hash = ?`).get(tokenHash), ); } async consumeToken( tokenHash: string, now: number, ): Promise { const current = await this.getToken(tokenHash); if (!current || current.expiresAt <= now || current.consumedAt) return undefined; current.consumedAt = now; const result = this.db .prepare( `UPDATE ${this.tokenTable} SET payload = ?, consumed_at = ? WHERE token_hash = ? AND expires_at > ? AND consumed_at IS NULL`, ) .run(JSON.stringify(current), now, tokenHash, now) as { changes?: number }; return result?.changes === 0 ? undefined : current; } async deleteToken(tokenHash: string): Promise { this.db.prepare(`DELETE FROM ${this.tokenTable} WHERE token_hash = ?`).run(tokenHash); } async gc(now: number): Promise { this.db .prepare(`DELETE FROM ${this.challengeTable} WHERE expires_at <= ? OR consumed_at <= ?`) .run(now, now - 60_000); this.db .prepare(`DELETE FROM ${this.tokenTable} WHERE expires_at <= ? OR consumed_at <= ?`) .run(now, now - 60_000); } } export function createSqliteCaptchaStore( db: SqliteDatabaseLike, options?: SqliteCaptchaStoreOptions, ): SqliteCaptchaStore { return new SqliteCaptchaStore(db, options); }