JiPaix/xdccJS

View on GitHub
src/ctcp_parser.ts

Summary

Maintainability
A
25 mins
Test Coverage
/* eslint-disable max-len */
/* eslint-disable no-bitwise */
/* eslint-disable no-param-reassign */
import * as fs from 'fs';
import * as path from 'path';
import AddJob from './addjob';
import type { FileInfo } from './interfaces/fileinfo';
import type { Job } from './interfaces/job';
import { ParamsTimeout } from './timeouthandler';

interface ResumeQueue extends FileInfo {
  nick: string
}

export type ParamsCTCP = ParamsTimeout & {
  /**
   * Download path
   * @default `false`
   * @remark `undefined` or `false` enables piping, see {@link XDCC.download} for example on how to use pipes.
   * @example
   * ```js
   * // absolute path
   * params.path = '/home/user/downloads
   * ```
   * @example
   * ```js
   * // relative path
   * params.path = 'downloads/xdcc' //=> /your/project/folder/downloads/xdcc
   * ```
   * @example
   * ```js
   * // explicitly enable piping
   * params.path = false
   * ```
   * */
  path: string | false
  /**
   * Block downloads if the bot's name does not match the request
   * @example
   * ```js
   * // with botNameMatch = true
   * xdccJS.download('BOT-A', 1)
   * //=> Only accept files comming from 'BOT-A'
   * ```
   */
   botNameMatch: boolean
}
export class CtcpParser extends AddJob {
  protected path: string | false;

  protected botNameMatch: boolean;

  private resumequeue: ResumeQueue[] = [];

  constructor(params: ParamsCTCP) {
    super(params);
    this.botNameMatch = params.botNameMatch;
    this.path = params.path;
    this.on('ctcp request', (resp: { [prop: string]: string }): void => {
      const isDownloadRequest = this.checkBeforeDL(resp, this.candidates[0]);
      if (isDownloadRequest) {
        this.emit('debug', 'xdccJS:: BEFORE_TCP_OK');
        this.emit('prepareDL', isDownloadRequest);
      }
    });
  }

  static pathCheck(fpath?: ParamsCTCP['path']): string | false {
    if (typeof fpath === 'string') {
      const tmp = path.normalize(fpath);
      if (path.isAbsolute(tmp)) {
        CtcpParser.mkdir(tmp);
        return tmp;
      }
      CtcpParser.mkdir(path.join(path.resolve('./'), fpath));
      return path.join(path.resolve('./'), fpath);
    }
    return false;
  }

  private static mkdir(fpath: string): void {
    if (!fs.existsSync(fpath)) {
      fs.mkdirSync(fpath, {
        recursive: true,
      });
    }
  }

  private SecurityCheck(nick: string, candidate?: Job): boolean {
    if (candidate) {
      nick = nick.toLowerCase();
      candidate.nick = candidate.nick.toLowerCase();
      candidate.cancelNick = nick;
      if (this.botNameMatch) {
        if (nick === candidate.nick) {
          return true;
        }
        return false;
      }
      return true;
    }
    return false;
  }

  private checkBeforeDL(
    resp: { [prop: string]: string },
    candidate: Job,
  ): { fileInfo: FileInfo; candidate: Job } | undefined {
    const fileInfo = this.parseCtcp(resp.message, resp.nick);
    if (fileInfo && this.SecurityCheck(resp.nick, candidate)) {
      this.SetupTimeout({
        candidate,
        eventType: 'error',
        message: `couldn't connect to %yellow%${fileInfo.ip}:${fileInfo.port}`,
        padding: 6,
        delay: this.timeout,
        fileInfo,
      });
      if (fileInfo.type === 'DCC SEND') {
        const isResume = this.checkExistingFiles(fileInfo, candidate, resp);
        if (!isResume) return { fileInfo, candidate };
      }
    }
    return undefined;
  }

  private addToResumeQueue(fileInfo: FileInfo, nick: string): void {
    this.resumequeue.push({
      type: fileInfo.type,
      nick,
      ip: fileInfo.ip,
      length: fileInfo.length,
      token: fileInfo.token,
      position: fileInfo.position,
      port: fileInfo.port,
      filePath: fileInfo.filePath,
      file: fileInfo.file,
    });
  }

  private checkExistingFiles(fileInfo: FileInfo, candidate: Job, resp: { [prop: string]: string })
  : boolean {
    if (fs.existsSync(fileInfo.filePath) && this.path) {
      fileInfo.position = fs.statSync(fileInfo.filePath).size - 8192;
      if (fileInfo.position < 0) {
        fileInfo.position = 0;
      }
      fileInfo.length -= fileInfo.position;
      const quotedFilename = CtcpParser.fileNameWithQuotes(fileInfo.file);
      this.ctcpRequest(resp.nick, 'DCC RESUME', quotedFilename, fileInfo.port, fileInfo.position, fileInfo.token);
      this.addToResumeQueue(fileInfo, resp.nick);
      this.SetupTimeout({
        candidate,
        eventType: 'error',
        message: `couldn't resume download of %cyan%${fileInfo.file}`,
        padding: 6,
        delay: this.timeout,
        fileInfo,
      });
      this.emit('debug', 'xdccJS:: BEFORE_TCP_REQUEST_RESUME');
      return true;
    }
    return false;
  }

  private static fileNameWithQuotes(string: string): string {
    if (/\s/.test(string)) {
      return `"${string}"`;
    }
    return string;
  }

  private static ctcpMatch(text: string): RegExpMatchArray {
    const match = text.match(/(?:[^\s"]+|"[^"]*")+/g);
    if (match === null) {
      throw new TypeError(`CTCP : received unexpected msg : ${text}`);
    } else {
      return match;
    }
  }

  private fileInfoBuilder(parts: RegExpMatchArray, resume?: ResumeQueue): FileInfo {
    if (resume) {
      return {
        type: `${parts[0]} ${parts[1]}`,
        file: parts[2].replace(/"/g, ''),
        filePath: resume.filePath,
        ip: resume.ip,
        port: resume.port,
        position: resume.position,
        length: resume.length,
        token: resume.token,
      };
    }
    return {
      type: `${parts[0]} ${parts[1]}`,
      file: parts[2].replace(/"/g, ''),
      filePath: this.path ? path.normalize(`${this.path}/${parts[2].replace(/"/g, '')}`) : 'pipe',
      ip: CtcpParser.uint32ToIP(parts[3]),
      port: parseInt(parts[4], 10),
      position: 0,
      length: parseInt(parts[5], 10),
      token: parts[6],
    };
  }

  protected parseCtcp(text: string, nick: string): FileInfo | undefined {
    const parts = CtcpParser.ctcpMatch(text);
    const type = `${parts[0]} ${parts[1]}`;
    if (type === 'DCC ACCEPT') {
      this.emit('debug', 'xdccJS:: BEFORE_TCP_RESUME_ACCEPT');
      const resume = this.resumequeue.filter((q) => q.nick === nick);
      this.resumequeue = this.resumequeue.filter((q) => q.nick !== nick);
      if (resume.length) {
        return this.fileInfoBuilder(parts, resume[0]);
      }
    }
    if (type === 'DCC SEND') {
      this.emit('debug', 'xdccJS:: BEFORE_TCP_ACCEPT');
      return this.fileInfoBuilder(parts);
    }
    return undefined;
  }

  protected static uint32ToIP(value: string): string {
    const n = parseInt(value, 10);
    if (Number.isNaN(n)) {
      return value;
    }
    const byte1 = n & 255;
    const byte2 = (n >> 8) & 255;
    const byte3 = (n >> 16) & 255;
    const byte4 = (n >> 24) & 255;
    return `${byte4}.${byte3}.${byte2}.${byte1}`;
  }
}