183 lines
6.0 KiB
TypeScript
183 lines
6.0 KiB
TypeScript
import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore } from "../types.ts";
|
|
|
|
export interface SqliteStatementLike {
|
|
run(...params: unknown[]): unknown;
|
|
get(...params: unknown[]): Record<string, unknown> | 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<string, unknown> | undefined,
|
|
): CaptchaChallengeRecord | undefined {
|
|
if (!row) return undefined;
|
|
return JSON.parse(String(row.payload)) as CaptchaChallengeRecord;
|
|
}
|
|
|
|
function parseToken(
|
|
row: Record<string, unknown> | 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<void> {
|
|
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<CaptchaChallengeRecord | undefined> {
|
|
return parseChallenge(
|
|
this.db.prepare(`SELECT payload FROM ${this.challengeTable} WHERE id = ?`).get(id),
|
|
);
|
|
}
|
|
|
|
async incrementAttempts(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
|
|
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<CaptchaChallengeRecord | undefined> {
|
|
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<void> {
|
|
this.db.prepare(`DELETE FROM ${this.challengeTable} WHERE id = ?`).run(id);
|
|
}
|
|
|
|
async createToken(record: CaptchaResponseTokenRecord): Promise<void> {
|
|
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<CaptchaResponseTokenRecord | undefined> {
|
|
return parseToken(
|
|
this.db.prepare(`SELECT payload FROM ${this.tokenTable} WHERE token_hash = ?`).get(tokenHash),
|
|
);
|
|
}
|
|
|
|
async consumeToken(
|
|
tokenHash: string,
|
|
now: number,
|
|
): Promise<CaptchaResponseTokenRecord | undefined> {
|
|
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<void> {
|
|
this.db.prepare(`DELETE FROM ${this.tokenTable} WHERE token_hash = ?`).run(tokenHash);
|
|
}
|
|
|
|
async gc(now: number): Promise<void> {
|
|
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);
|
|
}
|