/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const EXPORTED_SYMBOLS = ["WebRequestUpload"];

/* exported WebRequestUpload */

const Ci = Components.interfaces;
const Cc = Components.classes;
const Cu = Components.utils;
const Cr = Components.results;

Cu.import("resource://gre/modules/XPCOMUtils.jsm");

var WebRequestUpload;

function rewind(stream) {
  try {
    if (stream instanceof Ci.nsISeekableStream) {
      stream.seek(0, 0);
    }
  } catch (e) {
    // It might be already closed, e.g. because of a previous error.
  }
}

function parseFormData(stream, channel, lenient = false) {
  const BUFFER_SIZE = 8192; // Empirically it seemed a good compromise.

  let mimeStream = null;

  if (stream instanceof Ci.nsIMIMEInputStream && stream.data) {
    mimeStream = stream;
    stream = stream.data;
  }
  let multiplexStream = null;
  if (stream instanceof Ci.nsIMultiplexInputStream) {
    multiplexStream = stream;
  }

  let touchedStreams = new Set();

  function createTextStream(stream) {
    let textStream = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance(Ci.nsIConverterInputStream);
    textStream.init(stream, "UTF-8", 0, lenient ? textStream.DEFAULT_REPLACEMENT_CHARACTER : 0);
    if (stream instanceof Ci.nsISeekableStream) {
      touchedStreams.add(stream);
    }
    return textStream;
  }

  let streamIdx = 0;
  function nextTextStream() {
    for (; streamIdx < multiplexStream.count;) {
      let currentStream = multiplexStream.getStream(streamIdx++);
      if (currentStream instanceof Ci.nsIStringInputStream) {
        touchedStreams.add(multiplexStream);
        return createTextStream(currentStream);
      }
    }
    return null;
  }

  let textStream;
  if (multiplexStream) {
    textStream = nextTextStream();
  } else {
    textStream = createTextStream(mimeStream || stream);
  }

  if (!textStream) {
    return null;
  }

  function readString() {
    if (textStream) {
      let textBuffer = {};
      textStream.readString(BUFFER_SIZE, textBuffer);
      return textBuffer.value;
    }
    return "";
  }

  function multiplexRead() {
    let str = readString();
    if (!str) {
      textStream = nextTextStream();
      if (textStream) {
        str = multiplexRead();
      }
    }
    return str;
  }

  let readChunk;
  if (multiplexStream) {
    readChunk = multiplexRead;
  } else {
    readChunk = readString;
  }

  function appendFormData(formData, name, value) {
    if (name in formData) {
      formData[name].push(value);
    } else {
      formData[name] = [value];
    }
  }

  function parseMultiPart(firstChunk, boundary = "") {
    let formData = Object.create(null);

    if (!boundary) {
      let match = firstChunk.match(/^--\S+/);
      if (!match) {
        return null;
      }
      boundary = match[0];
    }

    let unslash = (s) => s.replace(/\\"/g, '"');
    let tail = "";
    for (let chunk = firstChunk;
         chunk || tail;
         chunk = readChunk()) {
      let parts;
      if (chunk) {
        chunk = tail + chunk;
        parts = chunk.split(boundary);
        tail = parts.pop();
      } else {
        parts = [tail];
        tail = "";
      }

      for (let part of parts) {
        let match = part.match(/^\r\nContent-Disposition: form-data; name="(.*)"\r\n(?:Content-Type: (\S+))?.*\r\n/i);
        if (!match) {
          continue;
        }
        let [header, name, contentType] = match;
        if (contentType) {
          let fileName;
          // Since escaping inside Content-Disposition subfields is still poorly defined and buggy (see Bug 136676),
          // currently we always consider backslash-prefixed quotes as escaped even if that's not generally true
          // (i.e. in a field whose value actually ends with a backslash).
          // Therefore in this edge case we may end coalescing name and filename, which is marginally better than
          // potentially truncating the name field at the wrong point, at least from a XSS filter POV.
          match = name.match(/^(.*[^\\])"; filename="(.*)/);
          if (match) {
            [, name, fileName] = match;
          }
          appendFormData(formData, unslash(name), fileName ? unslash(fileName) : "");
        } else {
          appendFormData(formData, unslash(name), part.slice(header.length, -2));
        }
      }
    }

    return formData;
  }

  function parseUrlEncoded(firstChunk) {
    let formData = Object.create(null);

    let tail = "";
    for (let chunk = firstChunk;
         chunk || tail;
         chunk = readChunk()) {
      let pairs;
      if (chunk) {
        chunk = tail + chunk.trim();
        pairs = chunk.split("&");
        tail = pairs.pop();
      } else {
        chunk = tail;
        tail = "";
        pairs = [chunk];
      }
      for (let pair of pairs) {
        let [name, value] = pair.replace(/\+/g, " ").split("=").map(decodeURIComponent);
        appendFormData(formData, name, value);
      }
    }

    return formData;
  }

  try {
    let chunk = readChunk();

    if (multiplexStream) {
      touchedStreams.add(multiplexStream);
      return parseMultiPart(chunk);
    }
    let contentType;
    if (/^Content-Type:/i.test(chunk)) {
      contentType = chunk.replace(/^Content-Type:\s*/i, "");
      chunk = chunk.slice(chunk.indexOf("\r\n\r\n") + 4);
    } else {
      try {
        contentType = channel.getRequestHeader("Content-Type");
      } catch (e) {
        Cu.reportError(e);
        return null;
      }
    }

    let match = contentType.match(/^(?:multipart\/form-data;\s*boundary=(\S*)|application\/x-www-form-urlencoded\s)/i);
    if (match) {
      let boundary = match[1];
      if (boundary) {
        return parseMultiPart(chunk, boundary);
      }
      return parseUrlEncoded(chunk);
    }
  } finally {
    for (let stream of touchedStreams) {
      rewind(stream);
    }
  }

  return null;
}

function createFormData(stream, channel) {
  try {
    rewind(stream);
    return parseFormData(stream, channel);
  } catch (e) {
    Cu.reportError(e);
  } finally {
    rewind(stream);
  }
  return null;
}

function convertRawData(outerStream) {
  let raw = [];
  let totalBytes = 0;

  // Here we read the stream up to WebRequestUpload.MAX_RAW_BYTES, returning false if we had to truncate the result.
  function readAll(stream) {
    let unbuffered = stream.unbufferedStream || stream;
    if (unbuffered instanceof Ci.nsIFileInputStream) {
      raw.push({file: "<file>"}); // Full paths not supported yet for naked files (follow up bug)
      return true;
    }
    rewind(stream);

    let binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(Ci.nsIBinaryInputStream);
    binaryStream.setInputStream(stream);
    const MAX_BYTES = WebRequestUpload.MAX_RAW_BYTES;
    try {
      for (let available; (available = binaryStream.available());) {
        let size = Math.min(MAX_BYTES - totalBytes, available);
        let bytes = new ArrayBuffer(size);
        binaryStream.readArrayBuffer(size, bytes);
        let chunk = {bytes};
        raw.push(chunk);
        totalBytes += size;

        if (totalBytes >= MAX_BYTES) {
          if (size < available) {
            chunk.truncated = true;
            chunk.originalSize = available;
            return false;
          }
          break;
        }
      }
    } finally {
      rewind(stream);
    }
    return true;
  }

  let unbuffered = outerStream;
  if (outerStream instanceof Ci.nsIStreamBufferAccess) {
    unbuffered = outerStream.unbufferedStream;
  }

  if (unbuffered instanceof Ci.nsIMultiplexInputStream) {
    for (let i = 0, count = unbuffered.count; i < count; i++) {
      if (!readAll(unbuffered.getStream(i))) {
        break;
      }
    }
  } else {
    readAll(outerStream);
  }

  return raw;
}

WebRequestUpload = {
  createRequestBody(channel) {
    let requestBody = null;
    if (channel instanceof Ci.nsIUploadChannel && channel.uploadStream) {
      try {
        let stream = channel.uploadStream.QueryInterface(Ci.nsISeekableStream);
        let formData = createFormData(stream, channel);
        if (formData) {
          requestBody = {formData};
        } else {
          requestBody = {raw: convertRawData(stream), lenientFormData: createFormData(stream, channel, true)};
        }
      } catch (e) {
        Cu.reportError(e);
        requestBody = {error: e.message || String(e)};
      }
      requestBody = Object.freeze(requestBody);
    }
    return requestBody;
  },
};

XPCOMUtils.defineLazyPreferenceGetter(WebRequestUpload, "MAX_RAW_BYTES", "webextensions.webRequest.requestBodyMaxRawBytes");