tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

head_http_server.js (38775B)


      1 /* import-globals-from head_appinfo.js */
      2 /* import-globals-from ../../../common/tests/unit/head_helpers.js */
      3 /* import-globals-from head_helpers.js */
      4 
      5 var Cm = Components.manager;
      6 
      7 // Shared logging for all HTTP server functions.
      8 var { Log } = ChromeUtils.importESModule("resource://gre/modules/Log.sys.mjs");
      9 var { CommonUtils } = ChromeUtils.importESModule(
     10  "resource://services-common/utils.sys.mjs"
     11 );
     12 var { TestUtils } = ChromeUtils.importESModule(
     13  "resource://testing-common/TestUtils.sys.mjs"
     14 );
     15 var {
     16  MockFxaStorageManager,
     17  SyncTestingInfrastructure,
     18  configureFxAccountIdentity,
     19  configureIdentity,
     20  encryptPayload,
     21  getLoginTelemetryScalar,
     22  makeFxAccountsInternalMock,
     23  makeIdentityConfig,
     24  promiseNamedTimer,
     25  promiseZeroTimer,
     26  sumHistogram,
     27  syncTestLogging,
     28  waitForZeroTimer,
     29 } = ChromeUtils.importESModule(
     30  "resource://testing-common/services/sync/utils.sys.mjs"
     31 );
     32 
     33 const SYNC_HTTP_LOGGER = "Sync.Test.Server";
     34 
     35 // While the sync code itself uses 1.5, the tests hard-code 1.1,
     36 // so we're sticking with 1.1 here.
     37 const SYNC_API_VERSION = "1.1";
     38 
     39 // Use the same method that record.js does, which mirrors the server.
     40 // The server returns timestamps with 1/100 sec granularity. Note that this is
     41 // subject to change: see Bug 650435.
     42 function new_timestamp() {
     43  return round_timestamp(Date.now());
     44 }
     45 
     46 // Rounds a millisecond timestamp `t` to seconds, with centisecond precision.
     47 function round_timestamp(t) {
     48  return Math.round(t / 10) / 100;
     49 }
     50 
     51 function return_timestamp(request, response, timestamp) {
     52  if (!timestamp) {
     53    timestamp = new_timestamp();
     54  }
     55  let body = "" + timestamp;
     56  response.setHeader("X-Weave-Timestamp", body);
     57  response.setStatusLine(request.httpVersion, 200, "OK");
     58  writeBytesToOutputStream(response.bodyOutputStream, body);
     59  return timestamp;
     60 }
     61 
     62 function has_hawk_header(req) {
     63  return (
     64    req.hasHeader("Authorization") &&
     65    req.getHeader("Authorization").startsWith("Hawk")
     66  );
     67 }
     68 
     69 function basic_auth_header(user, password) {
     70  return "Basic " + btoa(user + ":" + CommonUtils.encodeUTF8(password));
     71 }
     72 
     73 function basic_auth_matches(req, user, password) {
     74  if (!req.hasHeader("Authorization")) {
     75    return false;
     76  }
     77 
     78  let expected = basic_auth_header(user, CommonUtils.encodeUTF8(password));
     79  return req.getHeader("Authorization") == expected;
     80 }
     81 
     82 function httpd_basic_auth_handler(body, metadata, response) {
     83  if (basic_auth_matches(metadata, "guest", "guest")) {
     84    response.setStatusLine(metadata.httpVersion, 200, "OK, authorized");
     85    response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false);
     86  } else {
     87    body = "This path exists and is protected - failed";
     88    response.setStatusLine(metadata.httpVersion, 401, "Unauthorized");
     89    response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false);
     90  }
     91  writeBytesToOutputStream(response.bodyOutputStream, body);
     92 }
     93 
     94 /*
     95 * Represent a WBO on the server
     96 */
     97 function ServerWBO(id, initialPayload, modified) {
     98  if (!id) {
     99    throw new Error("No ID for ServerWBO!");
    100  }
    101  this.id = id;
    102  if (!initialPayload) {
    103    return;
    104  }
    105 
    106  if (typeof initialPayload == "object") {
    107    initialPayload = JSON.stringify(initialPayload);
    108  }
    109  this.payload = initialPayload;
    110  this.modified = modified || new_timestamp();
    111  this.sortindex = 0;
    112 }
    113 ServerWBO.prototype = {
    114  get data() {
    115    return JSON.parse(this.payload);
    116  },
    117 
    118  get() {
    119    return { id: this.id, modified: this.modified, payload: this.payload };
    120  },
    121 
    122  put(input) {
    123    input = JSON.parse(input);
    124    this.payload = input.payload;
    125    this.modified = new_timestamp();
    126    this.sortindex = input.sortindex || 0;
    127  },
    128 
    129  delete() {
    130    delete this.payload;
    131    delete this.modified;
    132    delete this.sortindex;
    133  },
    134 
    135  // This handler sets `newModified` on the response body if the collection
    136  // timestamp has changed. This allows wrapper handlers to extract information
    137  // that otherwise would exist only in the body stream.
    138  handler() {
    139    let self = this;
    140 
    141    return function (request, response) {
    142      var statusCode = 200;
    143      var status = "OK";
    144      var body;
    145 
    146      switch (request.method) {
    147        case "GET":
    148          if (self.payload) {
    149            body = JSON.stringify(self.get());
    150          } else {
    151            statusCode = 404;
    152            status = "Not Found";
    153            body = "Not Found";
    154          }
    155          break;
    156 
    157        case "PUT":
    158          self.put(readBytesFromInputStream(request.bodyInputStream));
    159          body = JSON.stringify(self.modified);
    160          response.setHeader("Content-Type", "application/json");
    161          response.newModified = self.modified;
    162          break;
    163 
    164        case "DELETE": {
    165          self.delete();
    166          let ts = new_timestamp();
    167          body = JSON.stringify(ts);
    168          response.setHeader("Content-Type", "application/json");
    169          response.newModified = ts;
    170          break;
    171        }
    172      }
    173      response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false);
    174      response.setStatusLine(request.httpVersion, statusCode, status);
    175      writeBytesToOutputStream(response.bodyOutputStream, body);
    176    };
    177  },
    178 
    179  /**
    180   * Get the cleartext data stored in the payload.
    181   *
    182   * This isn't `get cleartext`, because `x.cleartext.blah = 3;` wouldn't work,
    183   * which seems like a footgun.
    184   */
    185  getCleartext() {
    186    return JSON.parse(JSON.parse(this.payload).ciphertext);
    187  },
    188 
    189  /**
    190   * Setter for getCleartext(), but lets you adjust the modified timestamp too.
    191   * Returns this ServerWBO object.
    192   */
    193  setCleartext(cleartext, modifiedTimestamp = this.modified) {
    194    this.payload = JSON.stringify(encryptPayload(cleartext));
    195    this.modified = modifiedTimestamp;
    196    return this;
    197  },
    198 };
    199 
    200 /**
    201 * Represent a collection on the server. The '_wbos' attribute is a
    202 * mapping of id -> ServerWBO objects.
    203 *
    204 * Note that if you want these records to be accessible individually,
    205 * you need to register their handlers with the server separately, or use a
    206 * containing HTTP server that will do so on your behalf.
    207 *
    208 * @param wbos
    209 *        An object mapping WBO IDs to ServerWBOs.
    210 * @param acceptNew
    211 *        If true, POSTs to this collection URI will result in new WBOs being
    212 *        created and wired in on the fly.
    213 * @param timestamp
    214 *        An optional timestamp value to initialize the modified time of the
    215 *        collection. This should be in the format returned by new_timestamp().
    216 *
    217 * @return the new ServerCollection instance.
    218 */
    219 function ServerCollection(wbos, acceptNew, timestamp) {
    220  this._wbos = wbos || {};
    221  this.acceptNew = acceptNew || false;
    222 
    223  /*
    224   * Track modified timestamp.
    225   * We can't just use the timestamps of contained WBOs: an empty collection
    226   * has a modified time.
    227   */
    228  this.timestamp = timestamp || new_timestamp();
    229  this._log = Log.repository.getLogger(SYNC_HTTP_LOGGER);
    230 }
    231 ServerCollection.prototype = {
    232  /**
    233   * Convenience accessor for our WBO keys.
    234   * Excludes deleted items, of course.
    235   *
    236   * @param filter
    237   *        A predicate function (applied to the ID and WBO) which dictates
    238   *        whether to include the WBO's ID in the output.
    239   *
    240   * @return an array of IDs.
    241   */
    242  keys: function keys(filter) {
    243    let ids = [];
    244    for (let [id, wbo] of Object.entries(this._wbos)) {
    245      if (wbo.payload && (!filter || filter(id, wbo))) {
    246        ids.push(id);
    247      }
    248    }
    249    return ids;
    250  },
    251 
    252  /**
    253   * Convenience method to get an array of WBOs.
    254   * Optionally provide a filter function.
    255   *
    256   * @param filter
    257   *        A predicate function, applied to the WBO, which dictates whether to
    258   *        include the WBO in the output.
    259   *
    260   * @return an array of ServerWBOs.
    261   */
    262  wbos: function wbos(filter) {
    263    let os = [];
    264    for (let wbo of Object.values(this._wbos)) {
    265      if (wbo.payload) {
    266        os.push(wbo);
    267      }
    268    }
    269 
    270    if (filter) {
    271      return os.filter(filter);
    272    }
    273    return os;
    274  },
    275 
    276  /**
    277   * Convenience method to get an array of parsed ciphertexts.
    278   *
    279   * @return an array of the payloads of each stored WBO.
    280   */
    281  payloads() {
    282    return this.wbos().map(wbo => wbo.getCleartext());
    283  },
    284 
    285  // Just for syntactic elegance.
    286  wbo: function wbo(id) {
    287    return this._wbos[id];
    288  },
    289 
    290  payload: function payload(id) {
    291    return this.wbo(id).payload;
    292  },
    293 
    294  cleartext(id) {
    295    return this.wbo(id).getCleartext();
    296  },
    297 
    298  /**
    299   * Insert the provided WBO under its ID.
    300   *
    301   * @return the provided WBO.
    302   */
    303  insertWBO: function insertWBO(wbo) {
    304    this.timestamp = Math.max(this.timestamp, wbo.modified);
    305    return (this._wbos[wbo.id] = wbo);
    306  },
    307 
    308  /**
    309   * Update an existing WBO's cleartext using a callback function that modifies
    310   * the record in place, or returns a new record.
    311   */
    312  updateRecord(id, updateCallback, optTimestamp) {
    313    let wbo = this.wbo(id);
    314    if (!wbo) {
    315      throw new Error("No record with provided ID");
    316    }
    317    let curCleartext = wbo.getCleartext();
    318    // Allow update callback to either return a new cleartext, or modify in place.
    319    let newCleartext = updateCallback(curCleartext) || curCleartext;
    320    wbo.setCleartext(newCleartext, optTimestamp);
    321    // It is already inserted, but we might need to update our timestamp based
    322    // on it's `modified` value, if `optTimestamp` was provided.
    323    return this.insertWBO(wbo);
    324  },
    325 
    326  /**
    327   * Insert a record, which may either an object with a cleartext property, or
    328   * the cleartext property itself.
    329   */
    330  insertRecord(record, timestamp = Math.round(Date.now() / 10) / 100) {
    331    if (typeof timestamp != "number") {
    332      throw new TypeError("insertRecord: Timestamp is not a number.");
    333    }
    334    if (!record.id) {
    335      throw new Error("Attempt to insert record with no id");
    336    }
    337    // Allow providing either the cleartext directly, or the CryptoWrapper-like.
    338    let cleartext = record.cleartext || record;
    339    return this.insert(record.id, encryptPayload(cleartext), timestamp);
    340  },
    341 
    342  /**
    343   * Insert the provided payload as part of a new ServerWBO with the provided
    344   * ID.
    345   *
    346   * @param id
    347   *        The GUID for the WBO.
    348   * @param payload
    349   *        The payload, as provided to the ServerWBO constructor.
    350   * @param modified
    351   *        An optional modified time for the ServerWBO.
    352   *
    353   * @return the inserted WBO.
    354   */
    355  insert: function insert(id, payload, modified) {
    356    return this.insertWBO(new ServerWBO(id, payload, modified));
    357  },
    358 
    359  /**
    360   * Removes an object entirely from the collection.
    361   *
    362   * @param id
    363   *        (string) ID to remove.
    364   */
    365  remove: function remove(id) {
    366    delete this._wbos[id];
    367  },
    368 
    369  _inResultSet(wbo, options) {
    370    return (
    371      wbo.payload &&
    372      (!options.ids || options.ids.includes(wbo.id)) &&
    373      (!options.newer || wbo.modified > options.newer) &&
    374      (!options.older || wbo.modified < options.older)
    375    );
    376  },
    377 
    378  count(options) {
    379    options = options || {};
    380    let c = 0;
    381    for (let wbo of Object.values(this._wbos)) {
    382      if (wbo.modified && this._inResultSet(wbo, options)) {
    383        c++;
    384      }
    385    }
    386    return c;
    387  },
    388 
    389  get(options, request) {
    390    let data = [];
    391    for (let wbo of Object.values(this._wbos)) {
    392      if (wbo.modified && this._inResultSet(wbo, options)) {
    393        data.push(wbo);
    394      }
    395    }
    396    switch (options.sort) {
    397      case "newest":
    398        data.sort((a, b) => b.modified - a.modified);
    399        break;
    400 
    401      case "oldest":
    402        data.sort((a, b) => a.modified - b.modified);
    403        break;
    404 
    405      case "index":
    406        data.sort((a, b) => b.sortindex - a.sortindex);
    407        break;
    408 
    409      default:
    410        if (options.sort) {
    411          this._log.error(
    412            "Error: client requesting unknown sort order",
    413            options.sort
    414          );
    415          throw new Error("Unknown sort order");
    416        }
    417        // If the client didn't request a sort order, shuffle the records
    418        // to ensure that we don't accidentally depend on the default order.
    419        TestUtils.shuffle(data);
    420    }
    421    if (options.full) {
    422      data = data.map(wbo => wbo.get());
    423      let start = options.offset || 0;
    424      if (options.limit) {
    425        let numItemsPastOffset = data.length - start;
    426        data = data.slice(start, start + options.limit);
    427        // use options as a backchannel to set x-weave-next-offset
    428        if (numItemsPastOffset > options.limit) {
    429          options.nextOffset = start + options.limit;
    430        }
    431      } else if (start) {
    432        data = data.slice(start);
    433      }
    434 
    435      if (request && request.getHeader("accept") == "application/newlines") {
    436        this._log.error(
    437          "Error: client requesting application/newlines content"
    438        );
    439        throw new Error(
    440          "This server should not serve application/newlines content"
    441        );
    442      }
    443 
    444      // Use options as a backchannel to report count.
    445      options.recordCount = data.length;
    446    } else {
    447      data = data.map(wbo => wbo.id);
    448      let start = options.offset || 0;
    449      if (options.limit) {
    450        data = data.slice(start, start + options.limit);
    451        options.nextOffset = start + options.limit;
    452      } else if (start) {
    453        data = data.slice(start);
    454      }
    455      options.recordCount = data.length;
    456    }
    457    return JSON.stringify(data);
    458  },
    459 
    460  post(input) {
    461    input = JSON.parse(input);
    462    let success = [];
    463    let failed = {};
    464 
    465    // This will count records where we have an existing ServerWBO
    466    // registered with us as successful and all other records as failed.
    467    for (let key in input) {
    468      let record = input[key];
    469      let wbo = this.wbo(record.id);
    470      if (!wbo && this.acceptNew) {
    471        this._log.debug(
    472          "Creating WBO " + JSON.stringify(record.id) + " on the fly."
    473        );
    474        wbo = new ServerWBO(record.id);
    475        this.insertWBO(wbo);
    476      }
    477      if (wbo) {
    478        wbo.payload = record.payload;
    479        wbo.modified = new_timestamp();
    480        wbo.sortindex = record.sortindex || 0;
    481        success.push(record.id);
    482      } else {
    483        failed[record.id] = "no wbo configured";
    484      }
    485    }
    486    return { modified: new_timestamp(), success, failed };
    487  },
    488 
    489  delete(options) {
    490    let deleted = [];
    491    for (let wbo of Object.values(this._wbos)) {
    492      if (this._inResultSet(wbo, options)) {
    493        this._log.debug("Deleting " + JSON.stringify(wbo));
    494        deleted.push(wbo.id);
    495        wbo.delete();
    496      }
    497    }
    498    return deleted;
    499  },
    500 
    501  // This handler sets `newModified` on the response body if the collection
    502  // timestamp has changed.
    503  handler() {
    504    let self = this;
    505 
    506    return function (request, response) {
    507      var statusCode = 200;
    508      var status = "OK";
    509      var body;
    510 
    511      // Parse queryString
    512      let options = {};
    513      for (let chunk of request.queryString.split("&")) {
    514        if (!chunk) {
    515          continue;
    516        }
    517        chunk = chunk.split("=");
    518        if (chunk.length == 1) {
    519          options[chunk[0]] = "";
    520        } else {
    521          options[chunk[0]] = chunk[1];
    522        }
    523      }
    524      // The real servers return 400 if ids= is specified without a list of IDs.
    525      if (options.hasOwnProperty("ids")) {
    526        if (!options.ids) {
    527          response.setStatusLine(request.httpVersion, "400", "Bad Request");
    528          body = "Bad Request";
    529          writeBytesToOutputStream(response.bodyOutputStream, body);
    530          return;
    531        }
    532        options.ids = options.ids.split(",");
    533      }
    534      if (options.newer) {
    535        options.newer = parseFloat(options.newer);
    536      }
    537      if (options.older) {
    538        options.older = parseFloat(options.older);
    539      }
    540      if (options.limit) {
    541        options.limit = parseInt(options.limit, 10);
    542      }
    543      if (options.offset) {
    544        options.offset = parseInt(options.offset, 10);
    545      }
    546 
    547      switch (request.method) {
    548        case "GET": {
    549          body = self.get(options, request);
    550          // see http://moz-services-docs.readthedocs.io/en/latest/storage/apis-1.5.html
    551          // for description of these headers.
    552          let { recordCount: records, nextOffset } = options;
    553 
    554          self._log.info("Records: " + records + ", nextOffset: " + nextOffset);
    555          if (records != null) {
    556            response.setHeader("X-Weave-Records", "" + records);
    557          }
    558          if (nextOffset) {
    559            response.setHeader("X-Weave-Next-Offset", "" + nextOffset);
    560          }
    561          response.setHeader("X-Last-Modified", "" + self.timestamp);
    562          break;
    563        }
    564 
    565        case "POST": {
    566          let res = self.post(
    567            readBytesFromInputStream(request.bodyInputStream),
    568            request
    569          );
    570          body = JSON.stringify(res);
    571          response.newModified = res.modified;
    572          break;
    573        }
    574 
    575        case "DELETE": {
    576          self._log.debug("Invoking ServerCollection.DELETE.");
    577          let deleted = self.delete(options, request);
    578          let ts = new_timestamp();
    579          body = JSON.stringify(ts);
    580          response.newModified = ts;
    581          response.deleted = deleted;
    582          break;
    583        }
    584      }
    585      response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false);
    586 
    587      // Update the collection timestamp to the appropriate modified time.
    588      // This is either a value set by the handler, or the current time.
    589      if (request.method != "GET") {
    590        self.timestamp =
    591          response.newModified >= 0 ? response.newModified : new_timestamp();
    592      }
    593      response.setHeader("X-Last-Modified", "" + self.timestamp, false);
    594 
    595      response.setStatusLine(request.httpVersion, statusCode, status);
    596      writeBytesToOutputStream(response.bodyOutputStream, body);
    597    };
    598  },
    599 };
    600 
    601 /*
    602 * Test setup helpers.
    603 */
    604 function sync_httpd_setup(handlers) {
    605  handlers["/1.1/foo/storage/meta/global"] = new ServerWBO(
    606    "global",
    607    {}
    608  ).handler();
    609  return httpd_setup(handlers);
    610 }
    611 
    612 /*
    613 * Track collection modified times. Return closures.
    614 *
    615 * XXX - DO NOT USE IN NEW TESTS
    616 *
    617 * This code has very limited and very hacky timestamp support - the test
    618 * server now has more complete and correct support - using this helper
    619 * may cause strangeness wrt timestamp headers and 412 responses.
    620 */
    621 function track_collections_helper() {
    622  /*
    623   * Our tracking object.
    624   */
    625  let collections = {};
    626 
    627  /*
    628   * Update the timestamp of a collection.
    629   */
    630  function update_collection(coll, ts) {
    631    _("Updating collection " + coll + " to " + ts);
    632    let timestamp = ts || new_timestamp();
    633    collections[coll] = timestamp;
    634  }
    635 
    636  /*
    637   * Invoke a handler, updating the collection's modified timestamp unless
    638   * it's a GET request.
    639   */
    640  function with_updated_collection(coll, f) {
    641    return function (request, response) {
    642      f.call(this, request, response);
    643 
    644      // Update the collection timestamp to the appropriate modified time.
    645      // This is either a value set by the handler, or the current time.
    646      if (request.method != "GET") {
    647        update_collection(coll, response.newModified);
    648      }
    649    };
    650  }
    651 
    652  /*
    653   * Return the info/collections object.
    654   */
    655  function info_collections(request, response) {
    656    let body = "Error.";
    657    switch (request.method) {
    658      case "GET":
    659        body = JSON.stringify(collections);
    660        break;
    661      default:
    662        throw new Error("Non-GET on info_collections.");
    663    }
    664 
    665    response.setHeader("Content-Type", "application/json");
    666    response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false);
    667    response.setStatusLine(request.httpVersion, 200, "OK");
    668    writeBytesToOutputStream(response.bodyOutputStream, body);
    669  }
    670 
    671  return {
    672    collections,
    673    handler: info_collections,
    674    with_updated_collection,
    675    update_collection,
    676  };
    677 }
    678 
    679 // ===========================================================================//
    680 // httpd.js-based Sync server.                                               //
    681 // ===========================================================================//
    682 
    683 /**
    684 * In general, the preferred way of using SyncServer is to directly introspect
    685 * it. Callbacks are available for operations which are hard to verify through
    686 * introspection, such as deletions.
    687 *
    688 * One of the goals of this server is to provide enough hooks for test code to
    689 * find out what it needs without monkeypatching. Use this object as your
    690 * prototype, and override as appropriate.
    691 */
    692 var SyncServerCallback = {
    693  onCollectionDeleted: function onCollectionDeleted() {},
    694  onItemDeleted: function onItemDeleted() {},
    695 
    696  /**
    697   * Called at the top of every request.
    698   *
    699   * Allows the test to inspect the request. Hooks should be careful not to
    700   * modify or change state of the request or they may impact future processing.
    701   * The response is also passed so the callback can set headers etc - but care
    702   * must be taken to not screw with the response body or headers that may
    703   * conflict with normal operation of this server.
    704   */
    705  onRequest: function onRequest() {},
    706 };
    707 
    708 /**
    709 * Construct a new test Sync server. Takes a callback object (e.g.,
    710 * SyncServerCallback) as input.
    711 */
    712 function SyncServer(callback) {
    713  this.callback = callback || Object.create(SyncServerCallback);
    714  this.server = new HttpServer();
    715  this.started = false;
    716  this.users = {};
    717  this._log = Log.repository.getLogger(SYNC_HTTP_LOGGER);
    718 
    719  // Install our own default handler. This allows us to mess around with the
    720  // whole URL space.
    721  let handler = this.server._handler;
    722  handler._handleDefault = this.handleDefault.bind(this, handler);
    723 }
    724 SyncServer.prototype = {
    725  server: null, // HttpServer.
    726  users: null, // Map of username => {collections, password}.
    727 
    728  /**
    729   * Start the SyncServer's underlying HTTP server.
    730   *
    731   * @param port
    732   *        The numeric port on which to start. -1 implies the default, a
    733   *        randomly chosen port.
    734   * @param cb
    735   *        A callback function (of no arguments) which is invoked after
    736   *        startup.
    737   */
    738  start: function start(port = -1, cb) {
    739    if (this.started) {
    740      this._log.warn("Warning: server already started on " + this.port);
    741      return;
    742    }
    743    try {
    744      this.server.start(port);
    745      let i = this.server.identity;
    746      this.port = i.primaryPort;
    747      this.baseURI =
    748        i.primaryScheme + "://" + i.primaryHost + ":" + i.primaryPort + "/";
    749      this.started = true;
    750      if (cb) {
    751        cb();
    752      }
    753    } catch (ex) {
    754      _("==========================================");
    755      _("Got exception starting Sync HTTP server.");
    756      _("Error: " + Log.exceptionStr(ex));
    757      _("Is there a process already listening on port " + port + "?");
    758      _("==========================================");
    759      do_throw(ex);
    760    }
    761  },
    762 
    763  /**
    764   * Stop the SyncServer's HTTP server.
    765   *
    766   * @param cb
    767   *        A callback function. Invoked after the server has been stopped.
    768   */
    769  stop: function stop(cb) {
    770    if (!this.started) {
    771      this._log.warn(
    772        "SyncServer: Warning: server not running. Can't stop me now!"
    773      );
    774      return;
    775    }
    776 
    777    this.server.stop(cb);
    778    this.started = false;
    779  },
    780 
    781  /**
    782   * Return a server timestamp for a record.
    783   * The server returns timestamps with 1/100 sec granularity. Note that this is
    784   * subject to change: see Bug 650435.
    785   */
    786  timestamp: function timestamp() {
    787    return new_timestamp();
    788  },
    789 
    790  /**
    791   * Create a new user, complete with an empty set of collections.
    792   *
    793   * @param username
    794   *        The username to use. An Error will be thrown if a user by that name
    795   *        already exists.
    796   * @param password
    797   *        A password string.
    798   *
    799   * @return a user object, as would be returned by server.user(username).
    800   */
    801  registerUser: function registerUser(username, password) {
    802    if (username in this.users) {
    803      throw new Error("User already exists.");
    804    }
    805    this.users[username] = {
    806      password,
    807      collections: {},
    808    };
    809    return this.user(username);
    810  },
    811 
    812  userExists: function userExists(username) {
    813    return username in this.users;
    814  },
    815 
    816  getCollection: function getCollection(username, collection) {
    817    return this.users[username].collections[collection];
    818  },
    819 
    820  _insertCollection: function _insertCollection(collections, collection, wbos) {
    821    let coll = new ServerCollection(wbos, true);
    822    coll.collectionHandler = coll.handler();
    823    collections[collection] = coll;
    824    return coll;
    825  },
    826 
    827  createCollection: function createCollection(username, collection, wbos) {
    828    if (!(username in this.users)) {
    829      throw new Error("Unknown user.");
    830    }
    831    let collections = this.users[username].collections;
    832    if (collection in collections) {
    833      throw new Error("Collection already exists.");
    834    }
    835    return this._insertCollection(collections, collection, wbos);
    836  },
    837 
    838  /**
    839   * Accept a map like the following:
    840   * {
    841   *   meta: {global: {version: 1, ...}},
    842   *   crypto: {"keys": {}, foo: {bar: 2}},
    843   *   bookmarks: {}
    844   * }
    845   * to cause collections and WBOs to be created.
    846   * If a collection already exists, no error is raised.
    847   * If a WBO already exists, it will be updated to the new contents.
    848   */
    849  createContents: function createContents(username, collections) {
    850    if (!(username in this.users)) {
    851      throw new Error("Unknown user.");
    852    }
    853    let userCollections = this.users[username].collections;
    854    for (let [id, contents] of Object.entries(collections)) {
    855      let coll =
    856        userCollections[id] || this._insertCollection(userCollections, id);
    857      for (let [wboID, payload] of Object.entries(contents)) {
    858        coll.insert(wboID, payload);
    859      }
    860    }
    861  },
    862 
    863  /**
    864   * Insert a WBO in an existing collection.
    865   */
    866  insertWBO: function insertWBO(username, collection, wbo) {
    867    if (!(username in this.users)) {
    868      throw new Error("Unknown user.");
    869    }
    870    let userCollections = this.users[username].collections;
    871    if (!(collection in userCollections)) {
    872      throw new Error("Unknown collection.");
    873    }
    874    userCollections[collection].insertWBO(wbo);
    875    return wbo;
    876  },
    877 
    878  /**
    879   * Delete all of the collections for the named user.
    880   *
    881   * @param username
    882   *        The name of the affected user.
    883   *
    884   * @return a timestamp.
    885   */
    886  deleteCollections: function deleteCollections(username) {
    887    if (!(username in this.users)) {
    888      throw new Error("Unknown user.");
    889    }
    890    let userCollections = this.users[username].collections;
    891    for (let name in userCollections) {
    892      let coll = userCollections[name];
    893      this._log.trace("Bulk deleting " + name + " for " + username + "...");
    894      coll.delete({});
    895    }
    896    this.users[username].collections = {};
    897    return this.timestamp();
    898  },
    899 
    900  /**
    901   * Simple accessor to allow collective binding and abbreviation of a bunch of
    902   * methods. Yay!
    903   * Use like this:
    904   *
    905   *   let u = server.user("john");
    906   *   u.collection("bookmarks").wbo("abcdefg").payload;  // Etc.
    907   *
    908   * @return a proxy for the user data stored in this server.
    909   */
    910  user: function user(username) {
    911    let collection = this.getCollection.bind(this, username);
    912    let createCollection = this.createCollection.bind(this, username);
    913    let createContents = this.createContents.bind(this, username);
    914    let modified = function (collectionName) {
    915      return collection(collectionName).timestamp;
    916    };
    917    let deleteCollections = this.deleteCollections.bind(this, username);
    918    return {
    919      collection,
    920      createCollection,
    921      createContents,
    922      deleteCollections,
    923      modified,
    924    };
    925  },
    926 
    927  /*
    928   * Regular expressions for splitting up Sync request paths.
    929   * Sync URLs are of the form:
    930   *   /$apipath/$version/$user/$further
    931   * where $further is usually:
    932   *   storage/$collection/$wbo
    933   * or
    934   *   storage/$collection
    935   * or
    936   *   info/$op
    937   * We assume for the sake of simplicity that $apipath is empty.
    938   *
    939   * N.B., we don't follow any kind of username spec here, because as far as I
    940   * can tell there isn't one. See Bug 689671. Instead we follow the Python
    941   * server code.
    942   *
    943   * Path: [all, version, username, first, rest]
    944   * Storage: [all, collection?, id?]
    945   */
    946  pathRE:
    947    /^\/([0-9]+(?:\.[0-9]+)?)\/([-._a-zA-Z0-9]+)(?:\/([^\/]+)(?:\/(.+))?)?$/,
    948  storageRE: /^([-_a-zA-Z0-9]+)(?:\/([-_a-zA-Z0-9]+)\/?)?$/,
    949 
    950  defaultHeaders: {},
    951 
    952  /**
    953   * HTTP response utility.
    954   */
    955  respond: function respond(req, resp, code, status, body, headers) {
    956    resp.setStatusLine(req.httpVersion, code, status);
    957    if (!headers) {
    958      headers = this.defaultHeaders;
    959    }
    960    for (let header in headers) {
    961      let value = headers[header];
    962      resp.setHeader(header, value);
    963    }
    964    resp.setHeader("X-Weave-Timestamp", "" + this.timestamp(), false);
    965    writeBytesToOutputStream(resp.bodyOutputStream, body);
    966  },
    967 
    968  /**
    969   * This is invoked by the HttpServer. `this` is bound to the SyncServer;
    970   * `handler` is the HttpServer's handler.
    971   *
    972   * TODO: need to use the correct Sync API response codes and errors here.
    973   * TODO: Basic Auth.
    974   * TODO: check username in path against username in BasicAuth.
    975   */
    976  handleDefault: function handleDefault(handler, req, resp) {
    977    try {
    978      this._handleDefault(handler, req, resp);
    979    } catch (e) {
    980      if (e instanceof HttpError) {
    981        this.respond(req, resp, e.code, e.description, "", {});
    982      } else {
    983        throw e;
    984      }
    985    }
    986  },
    987 
    988  _handleDefault: function _handleDefault(handler, req, resp) {
    989    this._log.debug(
    990      "SyncServer: Handling request: " + req.method + " " + req.path
    991    );
    992 
    993    if (this.callback.onRequest) {
    994      this.callback.onRequest(req, resp);
    995    }
    996 
    997    let parts = this.pathRE.exec(req.path);
    998    if (!parts) {
    999      this._log.debug("SyncServer: Unexpected request: bad URL " + req.path);
   1000      throw HTTP_404;
   1001    }
   1002 
   1003    let [, version, username, first, rest] = parts;
   1004    // Doing a float compare of the version allows for us to pretend there was
   1005    // a node-reassignment - eg, we could re-assign from "1.1/user/" to
   1006    // "1.10/user" - this server will then still accept requests with the new
   1007    // URL while any code in sync itself which compares URLs will see a
   1008    // different URL.
   1009    if (parseFloat(version) != parseFloat(SYNC_API_VERSION)) {
   1010      this._log.debug("SyncServer: Unknown version.");
   1011      throw HTTP_404;
   1012    }
   1013 
   1014    if (!this.userExists(username)) {
   1015      this._log.debug("SyncServer: Unknown user.");
   1016      throw HTTP_401;
   1017    }
   1018 
   1019    // Hand off to the appropriate handler for this path component.
   1020    if (first in this.toplevelHandlers) {
   1021      let newHandler = this.toplevelHandlers[first];
   1022      return newHandler.call(
   1023        this,
   1024        newHandler,
   1025        req,
   1026        resp,
   1027        version,
   1028        username,
   1029        rest
   1030      );
   1031    }
   1032    this._log.debug("SyncServer: Unknown top-level " + first);
   1033    throw HTTP_404;
   1034  },
   1035 
   1036  /**
   1037   * Compute the object that is returned for an info/collections request.
   1038   */
   1039  infoCollections: function infoCollections(username) {
   1040    let responseObject = {};
   1041    let colls = this.users[username].collections;
   1042    for (let coll in colls) {
   1043      responseObject[coll] = colls[coll].timestamp;
   1044    }
   1045    this._log.trace(
   1046      "SyncServer: info/collections returning " + JSON.stringify(responseObject)
   1047    );
   1048    return responseObject;
   1049  },
   1050 
   1051  /**
   1052   * Collection of the handler methods we use for top-level path components.
   1053   */
   1054  toplevelHandlers: {
   1055    storage: function handleStorage(
   1056      handler,
   1057      req,
   1058      resp,
   1059      version,
   1060      username,
   1061      rest
   1062    ) {
   1063      let respond = this.respond.bind(this, req, resp);
   1064      if (!rest || !rest.length) {
   1065        this._log.debug(
   1066          "SyncServer: top-level storage " + req.method + " request."
   1067        );
   1068 
   1069        // TODO: verify if this is spec-compliant.
   1070        if (req.method != "DELETE") {
   1071          respond(405, "Method Not Allowed", "[]", { Allow: "DELETE" });
   1072          return undefined;
   1073        }
   1074 
   1075        // Delete all collections and track the timestamp for the response.
   1076        let timestamp = this.user(username).deleteCollections();
   1077 
   1078        // Return timestamp and OK for deletion.
   1079        respond(200, "OK", JSON.stringify(timestamp));
   1080        return undefined;
   1081      }
   1082 
   1083      let match = this.storageRE.exec(rest);
   1084      if (!match) {
   1085        this._log.warn("SyncServer: Unknown storage operation " + rest);
   1086        throw HTTP_404;
   1087      }
   1088      let [, collection, wboID] = match;
   1089      let coll = this.getCollection(username, collection);
   1090 
   1091      let checkXIUSFailure = () => {
   1092        if (req.hasHeader("x-if-unmodified-since")) {
   1093          let xius = parseFloat(req.getHeader("x-if-unmodified-since"));
   1094          // Sadly the way our tests are setup, we often end up with xius of
   1095          // zero (typically when syncing just one engine, so the date from
   1096          // info/collections isn't used) - so we allow that to work.
   1097          // Further, the Python server treats non-existing collections as
   1098          // having a timestamp of 0.
   1099          let collTimestamp = coll ? coll.timestamp : 0;
   1100          if (xius && xius < collTimestamp) {
   1101            this._log.info(
   1102              `x-if-unmodified-since mismatch - request wants ${xius} but our collection has ${collTimestamp}`
   1103            );
   1104            respond(412, "precondition failed", "precondition failed");
   1105            return true;
   1106          }
   1107        }
   1108        return false;
   1109      };
   1110 
   1111      switch (req.method) {
   1112        case "GET": {
   1113          if (!coll) {
   1114            if (wboID) {
   1115              respond(404, "Not found", "Not found");
   1116              return undefined;
   1117            }
   1118            // *cries inside*: - apparently the real sync server returned 200
   1119            // here for some time, then returned 404 for some time (bug 687299),
   1120            // and now is back to 200 (bug 963332).
   1121            respond(200, "OK", "[]");
   1122            return undefined;
   1123          }
   1124          if (!wboID) {
   1125            return coll.collectionHandler(req, resp);
   1126          }
   1127          let wbo = coll.wbo(wboID);
   1128          if (!wbo) {
   1129            respond(404, "Not found", "Not found");
   1130            return undefined;
   1131          }
   1132          return wbo.handler()(req, resp);
   1133        }
   1134        case "DELETE": {
   1135          if (!coll) {
   1136            respond(200, "OK", "{}");
   1137            return undefined;
   1138          }
   1139          if (checkXIUSFailure()) {
   1140            return undefined;
   1141          }
   1142          if (wboID) {
   1143            let wbo = coll.wbo(wboID);
   1144            if (wbo) {
   1145              wbo.delete();
   1146              this.callback.onItemDeleted(username, collection, wboID);
   1147            }
   1148            respond(200, "OK", "{}");
   1149            return undefined;
   1150          }
   1151          coll.collectionHandler(req, resp);
   1152 
   1153          // Spot if this is a DELETE for some IDs, and don't blow away the
   1154          // whole collection!
   1155          //
   1156          // We already handled deleting the WBOs by invoking the deleted
   1157          // collection's handler. However, in the case of
   1158          //
   1159          //   DELETE storage/foobar
   1160          //
   1161          // we also need to remove foobar from the collections map. This
   1162          // clause tries to differentiate the above request from
   1163          //
   1164          //  DELETE storage/foobar?ids=foo,baz
   1165          //
   1166          // and do the right thing.
   1167          // TODO: less hacky method.
   1168          if (-1 == req.queryString.indexOf("ids=")) {
   1169            // When you delete the entire collection, we drop it.
   1170            this._log.debug("Deleting entire collection.");
   1171            delete this.users[username].collections[collection];
   1172            this.callback.onCollectionDeleted(username, collection);
   1173          }
   1174 
   1175          // Notify of item deletion.
   1176          let deleted = resp.deleted || [];
   1177          for (let i = 0; i < deleted.length; ++i) {
   1178            this.callback.onItemDeleted(username, collection, deleted[i]);
   1179          }
   1180          return undefined;
   1181        }
   1182        case "PUT":
   1183          // PUT and POST have slightly different XIUS semantics - for PUT,
   1184          // the check is against the item, whereas for POST it is against
   1185          // the collection. So first, a special-case for PUT.
   1186          if (req.hasHeader("x-if-unmodified-since")) {
   1187            let xius = parseFloat(req.getHeader("x-if-unmodified-since"));
   1188            // treat and xius of zero as if it wasn't specified - this happens
   1189            // in some of our tests for a new collection.
   1190            if (xius > 0) {
   1191              let wbo = coll.wbo(wboID);
   1192              if (xius < wbo.modified) {
   1193                this._log.info(
   1194                  `x-if-unmodified-since mismatch - request wants ${xius} but wbo has ${wbo.modified}`
   1195                );
   1196                respond(412, "precondition failed", "precondition failed");
   1197                return undefined;
   1198              }
   1199              wbo.handler()(req, resp);
   1200              coll.timestamp = resp.newModified;
   1201              return resp;
   1202            }
   1203          }
   1204        // fall through to post.
   1205        case "POST":
   1206          if (checkXIUSFailure()) {
   1207            return undefined;
   1208          }
   1209          if (!coll) {
   1210            coll = this.createCollection(username, collection);
   1211          }
   1212 
   1213          if (wboID) {
   1214            let wbo = coll.wbo(wboID);
   1215            if (!wbo) {
   1216              this._log.trace(
   1217                "SyncServer: creating WBO " + collection + "/" + wboID
   1218              );
   1219              wbo = coll.insert(wboID);
   1220            }
   1221            // Rather than instantiate each WBO's handler function, do it once
   1222            // per request. They get hit far less often than do collections.
   1223            wbo.handler()(req, resp);
   1224            coll.timestamp = resp.newModified;
   1225            return resp;
   1226          }
   1227          return coll.collectionHandler(req, resp);
   1228        default:
   1229          throw new Error("Request method " + req.method + " not implemented.");
   1230      }
   1231    },
   1232 
   1233    info: function handleInfo(handler, req, resp, version, username, rest) {
   1234      switch (rest) {
   1235        case "collections": {
   1236          let body = JSON.stringify(this.infoCollections(username));
   1237          this.respond(req, resp, 200, "OK", body, {
   1238            "Content-Type": "application/json",
   1239          });
   1240          return;
   1241        }
   1242        case "collection_usage":
   1243        case "collection_counts":
   1244        case "quota":
   1245          // TODO: implement additional info methods.
   1246          this.respond(req, resp, 200, "OK", "TODO");
   1247          return;
   1248        default:
   1249          // TODO
   1250          this._log.warn("SyncServer: Unknown info operation " + rest);
   1251          throw HTTP_404;
   1252      }
   1253    },
   1254  },
   1255 };
   1256 
   1257 /**
   1258 * Test helper.
   1259 */
   1260 function serverForUsers(users, contents, callback) {
   1261  let server = new SyncServer(callback);
   1262  for (let [user, pass] of Object.entries(users)) {
   1263    server.registerUser(user, pass);
   1264    server.createContents(user, contents);
   1265  }
   1266  server.start();
   1267  return server;
   1268 }