shoes 0.2.1

A multi-protocol proxy server.
'use strict';

const fs = require('fs'),
  http = require('http'),
  https = require('https'),
  path = require('path'),
  stream = require('stream');

const { normalizeAddress } = require('../core/utils/ip-util');
const { handleHttpPipe } = require('../core/utils/pipe-util');

function splitQueryString(url) {
  const i = url.indexOf('?');
  if (i >= 0) {
    return [
      stripTrailingSlashes(unescape(url.slice(0, i))),
      url.slice(i + 1)
    ];
  } else {
    return [
      stripTrailingSlashes(unescape(url)),
      null
    ];
  }
}

function stripTrailingSlashes(s) {
  while (s.length > 1 && s.endsWith('/')) {
    s = s.slice(0, s.length - 1);
  }
  return s || '/';
}

function toBase64(str) {
  return Buffer.from(str, 'utf-8').toString('base64');
}

function fromBase64(str) {
  return Buffer.from(str, 'base64').toString('utf-8');
}

function streamToBuffer(s) {
  return new Promise(resolve => {
    const data = [];

    function onData(chunk) {
      data.push(chunk);
    }

    function onClose() {
      if (!s.readableEnded) {
        doResolve({ error: 'stream closed unexpectedly' });
      }
    }

    function onError(e) {
      doResolve({ error: e.toString() });
    }

    function onEnd() {
      doResolve({
        buffer: Buffer.concat(data)
      });
    }

    s.on('data', onData);
    s.on('close', onClose);
    s.on('error', onError);
    s.on('end', onEnd);

    function doResolve(res) {
      s.removeListener('data', onData);
      s.removeListener('close', onClose);
      s.removeListener('error', onError);
      s.removeListener('end', onEnd);
      s.addListener('error', function() { });
      resolve(res);
    }
  });
}

class MediaServer {
  constructor({ credentials, allowlist, webdavAddress, webdavExternalUrl }, newznabClients, transferClients) {
    this._authHeader = credentials ? 'Basic ' + toBase64(credentials) : null;
    if (!this._authHeader) {
      console.log('Warning: Authentication is disabled!');
    }

    this._connectAllowlist = new Set(allowlist);
    console.log('Allowed IPs:', [...this._connectAllowlist]);

    const match = /^(https?):\/\/([^:]+):([^@]+)@([^:]+):([0-9]+)$/.exec(webdavAddress);
    if (!match) {
      throw new Error('Invalid webdav address: ' + webdavAddress);
    }
    const protocol = match[1], username = match[2], password = match[3], host = match[4], port = match[5];

    this._webdavAuthHeader = 'Basic ' + toBase64(username + ':' + password);
    this._webdavUrl = `${protocol}://${host}:${port}`;
    this._webdavExternalUrl = webdavExternalUrl;

    // This needs to match the path in util.js.
    this._actionPath = '/action';

    this._newznabClients = newznabClients;
    this._transferClients = transferClients;

    this._staticAssets = {};
    this._staticAssets['/favicon.ico'] = {
      contentType: 'image/x-icon',
      cache: true,
      data: fs.readFileSync(path.join(__dirname, 'assets', 'cloud.ico'))
    };
    this._staticAssets['/bootstrap.min.css'] = {
      contentType: 'text/css; charset=utf-8',
      cache: true,
      data: fs.readFileSync(path.join(__dirname, 'assets', 'bootstrap.min.css'))
    };
    this._staticAssets['/bootstrap.bundle.min.js'] = {
      contentType: 'application/x-javascript',
      cache: true,
      data: fs.readFileSync(path.join(__dirname, 'assets', 'bootstrap.bundle.min.js'))
    };
    this._staticAssets['/util.js'] = {
      contentType: 'application/x-javascript',
      cache: true,
      data: fs.readFileSync(path.join(__dirname, 'assets', 'util.js'))
    };
    this._staticAssets['/app.js'] = {
      contentType: 'application/x-javascript',
      cache: true,
      data: fs.readFileSync(path.join(__dirname, 'assets', 'app.js'))
    };
    this._staticAssets['/'] = {
      contentType: 'text/html; charset=utf-8',
      cache: true,
      data: fs.readFileSync(path.join(__dirname, 'assets', 'app.html'))
    };
    this.handleConnect = this.handleConnect.bind(this);
    this.handleRequest = this.handleRequest.bind(this);
  }

  async init() {
    for (const name in this._transferClients) {
      await this._transferClients[name].init();
    }
  }

  handleConnect(socket) {
    const remoteAddress = normalizeAddress(socket.remoteAddress);
    if (!this._connectAllowlist.has(remoteAddress)) {
      console.log('* Denying connection from ' + remoteAddress);
      socket.destroy();
    }
  }

  handleRequest(req, res) {
    const { method, url, headers } = req;
    const [reqPath, queryStr] = splitQueryString(url);

    if (this._authHeader && headers['authorization'] !== this._authHeader) {
      res.writeHead(401, 'Unauthorized', {
        'Www-Authenticate': 'Basic realm="transfer"'
      });
      res.end();
      return;
    }

    console.log('DEBUG: handleRequest', method, url);

    if (method == 'OPTIONS') {
      res.writeHead(200, 'OK', {
        'Cache-Control': 'no-store, no-cache, must-revalidate',
        'Allow': 'OPTIONS, GET, DELETE, PROPFIND, PUT, MOVE',
        'DAV': '1, 3, extended-mkcol, 2',
        'MS-Author-Via': 'DAV',
        'Accept-Ranges': 'bytes'
      });
      res.end();
      return;
    } else if (method === 'HEAD') {
      this._handleHead(headers, reqPath, queryStr, res);
    } else if (method === 'GET') {
      this._handleGet(headers, reqPath, queryStr, req, res);
    } else if (method === 'POST') {
      this._handlePost(headers, reqPath, req, res);
    } else {
      console.log('Unknown method: ' + method + ' ' + url);
      res.writeHead(501, 'Not Implemented');
      res.end();
    }
  }

  _handleHead(headers, reqPath, queryStr, res) {
    if (reqPath in this._staticAssets) {
      const { contentType, cache } = this._staticAssets[reqPath];
      res.writeHead(200, 'OK', {
        'Cache-Control': cache ? 'public, max-age=31536000, immutable' : 'no-store, no-cache',
        'Content-Type': contentType
      });
      res.end();
    } else {
      res.writeHead(404, 'Not Found');
      res.end();
    }
  }

  _handleGet(headers, reqPath, queryStr, req, res) {
    if (reqPath in this._staticAssets) {
      const { contentType, cache, path, data } = this._staticAssets[reqPath];
      if (path) {
        res.writeHead(200, 'OK', {
          'Cache-Control': cache ? 'public, max-age=31536000, immutable' : 'no-store, no-cache',
          'Content-Type': contentType
        });
        const stream = fs.createReadStream(path);
        handleHttpPipe(stream, req, res);
      } else if (data) {
        const buf = Buffer.from(data);
        res.writeHead(200, 'OK', {
          'Cache-Control': cache ? 'public, max-age=31536000, immutable' : 'no-store, no-cache',
          'Content-Type': contentType,
          'Content-Length': buf.length
        });
        res.end(buf);
      } else {
        throw new Error('Invalic static asset entry: ' + reqPath);
      }
      return;
    } else if (reqPath === this._actionPath || reqPath.startsWith(this._actionPath + '?')) {
      this._handleGetAction(headers, queryStr, res);
    } else {
      res.writeHead(404, 'Not Found');
      res.end();
    }
  }

  [truncated]
}