New Captcha Package added

This commit is contained in:
2026-07-25 13:38:18 +05:30
parent d0aded0392
commit 8b728a3e5d
157 changed files with 12092 additions and 1913 deletions
+170
View File
@@ -0,0 +1,170 @@
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);
}