/ src / captchajs.ts
captchajs.ts
  1  import { createHash } from 'node:crypto';
  2  
  3  import randomstring from "randomstring";
  4  import { MemoryRandomStore } from "./memory_randomstore";
  5  import { CaptchaJsOptions, GetAudioUrlOptions, GetImageUrlOptions, RandomStore } from "./index";
  6  import * as util from "node:util";
  7  
  8  const logger = util.debuglog('captchajs:captcha');
  9  
 10  const standardAlphabet = "abcdefghijklmnopqrstuvwxyz";
 11  
 12  const defaultOptions: CaptchaJsOptions = {
 13    // These are defined in default but not expected ever to be used.
 14    client: "demo",
 15    secret: "secret",
 16    numberOfLetters: 6,
 17    width: 240,
 18    height: 80,
 19    alphabet: standardAlphabet
 20  };
 21  
 22  export class CaptchaJs implements CaptchaJs {
 23    private opts: CaptchaJsOptions;
 24    private randomStore: RandomStore;
 25  
 26    constructor(options: CaptchaJsOptions) {
 27      logger("Constructing with options", options);
 28      if (!options.client) {
 29        throw new Error("No client ID provided");
 30      }
 31      if (!options.secret) {
 32        throw new Error("No secret provided");
 33      }
 34      if (options.alphabet === "") {
 35        throw new Error("Can't use an empty alphabet");
 36      }
 37      if (options.numberOfLetters === 0) {
 38        throw new Error("Need at least one letter");
 39      }
 40  
 41      this.opts = Object.assign({}, defaultOptions, options);
 42      logger("this.opts", this.opts);
 43      this.randomStore = options.randomStore ?? new MemoryRandomStore();
 44      logger("this.randomStore", this.randomStore);
 45    }
 46  
 47    getRandomString(): string {
 48      let rs;
 49      // console.log("getRandomString in");
 50      do {
 51        rs = randomstring.generate({ length: 40 });
 52        // console.log("Trying random string", rs);
 53      } while (!this.randomStore.addRandom(rs));
 54  
 55      // console.log("Returning", rs)
 56      return rs;
 57    }
 58  
 59    makePassword(random: string): string {
 60      if (!random) {
 61        throw new Error("No random string supplied");
 62      }
 63      const concatString = this.opts.secret + random;
 64      const hash = createHash('md5');
 65      hash.write(concatString);
 66      const substring = hash.digest().slice(0, this.opts.numberOfLetters);
 67      // TODO rewrite with map() or similar
 68      let password = "";
 69      for (const c of substring) {
 70        // 'alphabet' will exist by now, we'll have used the default if needed.
 71        password += this.opts.alphabet![c % this.opts.alphabet!.length]
 72      }
 73  
 74      return password;
 75    }
 76  
 77    getImageUrl(opts: GetImageUrlOptions): string {
 78      const base = opts?.baseURL || "https://image.captchas.net/";
 79      let url = `${base}?client=${this.opts.client}&random=${opts.randomString}`
 80      if (this.opts.alphabet !== standardAlphabet) {
 81        url += `&alphabet=${this.opts.alphabet}`;
 82      }
 83      if (this.opts.numberOfLetters !== 6) {
 84        url += `&letters=${this.opts.numberOfLetters}`;
 85      }
 86      if (this.opts.width !== 240) {
 87        url += `&width=${this.opts.width}`;
 88      }
 89      if (this.opts.height !== 80) {
 90        url += `&height=${this.opts.height}`;
 91      }
 92      return url;
 93    }
 94  
 95    getAudioUrl(opts: GetAudioUrlOptions): string {
 96      const base = opts?.baseURL || "https://audio.captchas.net/";
 97      let url = `${base}?client=${this.opts.client}&random=${opts.randomString}`;
 98      if (this.opts.alphabet !== standardAlphabet) {
 99        url += `&alphabet=${this.opts.alphabet}`;
100      }
101      if (this.opts.numberOfLetters !== 6) {
102        url += `&letters=${this.opts.numberOfLetters}`;
103      }
104      return url;
105    }
106  
107    validateRandomString(randomString: string, invalidate = true): boolean {
108      if (!randomString) {
109        logger("No random string");
110        return false;
111      }
112  
113      logger("Validating random string", randomString);
114      return this.randomStore.validateRandom(randomString, invalidate);
115    }
116  
117    verifyPassword(randomString: string, password: string): boolean {
118      if (!password) {
119        logger("No password");
120        return false;
121      }
122      if (!randomString) {
123        logger("No random string");
124        return false;
125      }
126  
127      logger("Verifying password", password, "using random string", randomString);
128      if (password.length != this.opts.numberOfLetters) {
129        logger("Password length mismatch, reject.")
130        return false;
131      }
132      const ourPassword = this.makePassword(randomString);
133      logger("Calculated password", ourPassword);
134      return ourPassword === password;
135    }
136  }