108 lines
3.8 KiB
TypeScript
108 lines
3.8 KiB
TypeScript
import type {
|
|
CaptchaChallengeRecord,
|
|
CaptchaResponseTokenRecord,
|
|
CaptchaStore,
|
|
} from "../types.ts";
|
|
|
|
function cloneChallenge(record: CaptchaChallengeRecord): CaptchaChallengeRecord {
|
|
return structuredClone(record);
|
|
}
|
|
|
|
function cloneToken(record: CaptchaResponseTokenRecord): CaptchaResponseTokenRecord {
|
|
return structuredClone(record);
|
|
}
|
|
|
|
export interface MemoryCaptchaStoreOptions {
|
|
maxChallenges?: number;
|
|
maxTokens?: number;
|
|
}
|
|
|
|
export class MemoryCaptchaStore implements CaptchaStore {
|
|
private readonly challenges = new Map<string, CaptchaChallengeRecord>();
|
|
private readonly tokens = new Map<string, CaptchaResponseTokenRecord>();
|
|
private readonly maxChallenges: number;
|
|
private readonly maxTokens: number;
|
|
|
|
constructor(options: MemoryCaptchaStoreOptions = {}) {
|
|
this.maxChallenges = options.maxChallenges ?? 25_000;
|
|
this.maxTokens = options.maxTokens ?? 50_000;
|
|
if (!Number.isInteger(this.maxChallenges) || this.maxChallenges < 1) {
|
|
throw new RangeError("maxChallenges must be a positive integer");
|
|
}
|
|
if (!Number.isInteger(this.maxTokens) || this.maxTokens < 1) {
|
|
throw new RangeError("maxTokens must be a positive integer");
|
|
}
|
|
}
|
|
|
|
async createChallenge(record: CaptchaChallengeRecord): Promise<void> {
|
|
this.evict(this.challenges, this.maxChallenges, record.createdAt);
|
|
this.challenges.set(record.id, cloneChallenge(record));
|
|
}
|
|
|
|
async getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined> {
|
|
const record = this.challenges.get(id);
|
|
return record ? cloneChallenge(record) : undefined;
|
|
}
|
|
|
|
async incrementAttempts(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
|
|
const record = this.challenges.get(id);
|
|
if (!record || record.expiresAt <= now || record.consumedAt) return undefined;
|
|
record.attempts += 1;
|
|
return cloneChallenge(record);
|
|
}
|
|
|
|
async consumeChallenge(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
|
|
const record = this.challenges.get(id);
|
|
if (!record || record.expiresAt <= now || record.consumedAt) return undefined;
|
|
record.consumedAt = now;
|
|
return cloneChallenge(record);
|
|
}
|
|
|
|
async deleteChallenge(id: string): Promise<void> {
|
|
this.challenges.delete(id);
|
|
}
|
|
|
|
async createToken(record: CaptchaResponseTokenRecord): Promise<void> {
|
|
this.evict(this.tokens, this.maxTokens, record.createdAt);
|
|
this.tokens.set(record.tokenHash, cloneToken(record));
|
|
}
|
|
|
|
async getToken(tokenHash: string): Promise<CaptchaResponseTokenRecord | undefined> {
|
|
const record = this.tokens.get(tokenHash);
|
|
return record ? cloneToken(record) : undefined;
|
|
}
|
|
|
|
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
|
|
const record = this.tokens.get(tokenHash);
|
|
if (!record || record.expiresAt <= now || record.consumedAt) return undefined;
|
|
record.consumedAt = now;
|
|
return cloneToken(record);
|
|
}
|
|
|
|
async deleteToken(tokenHash: string): Promise<void> {
|
|
this.tokens.delete(tokenHash);
|
|
}
|
|
|
|
async gc(now: number): Promise<void> {
|
|
for (const [id, record] of this.challenges) {
|
|
if (record.expiresAt <= now || (record.consumedAt && record.consumedAt + 60_000 <= now)) {
|
|
this.challenges.delete(id);
|
|
}
|
|
}
|
|
for (const [hash, record] of this.tokens) {
|
|
if (record.expiresAt <= now || (record.consumedAt && record.consumedAt + 60_000 <= now)) {
|
|
this.tokens.delete(hash);
|
|
}
|
|
}
|
|
}
|
|
|
|
private evict<T extends { expiresAt: number }>(map: Map<string, T>, max: number, now: number): void {
|
|
for (const [key, record] of map) if (record.expiresAt <= now) map.delete(key);
|
|
while (map.size >= max) map.delete(map.keys().next().value!);
|
|
}
|
|
}
|
|
|
|
export function createMemoryCaptchaStore(options?: MemoryCaptchaStoreOptions): MemoryCaptchaStore {
|
|
return new MemoryCaptchaStore(options);
|
|
}
|