services/common/modules-testing/storageserver.js

changeset 0
6474c204b198
     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/services/common/modules-testing/storageserver.js	Wed Dec 31 06:09:35 2014 +0100
     1.3 @@ -0,0 +1,1659 @@
     1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public
     1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this file,
     1.6 + * You can obtain one at http://mozilla.org/MPL/2.0/. */
     1.7 +
     1.8 +/**
     1.9 + * This file contains an implementation of the Storage Server in JavaScript.
    1.10 + *
    1.11 + * The server should not be used for any production purposes.
    1.12 + */
    1.13 +
    1.14 +const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
    1.15 +
    1.16 +this.EXPORTED_SYMBOLS = [
    1.17 +  "ServerBSO",
    1.18 +  "StorageServerCallback",
    1.19 +  "StorageServerCollection",
    1.20 +  "StorageServer",
    1.21 +  "storageServerForUsers",
    1.22 +];
    1.23 +
    1.24 +Cu.import("resource://testing-common/httpd.js");
    1.25 +Cu.import("resource://services-common/async.js");
    1.26 +Cu.import("resource://gre/modules/Log.jsm");
    1.27 +Cu.import("resource://services-common/utils.js");
    1.28 +
    1.29 +const STORAGE_HTTP_LOGGER = "Services.Common.Test.Server";
    1.30 +const STORAGE_API_VERSION = "2.0";
    1.31 +
    1.32 +// Use the same method that record.js does, which mirrors the server.
    1.33 +function new_timestamp() {
    1.34 +  return Math.round(Date.now());
    1.35 +}
    1.36 +
    1.37 +function isInteger(s) {
    1.38 +  let re = /^[0-9]+$/;
    1.39 +  return re.test(s);
    1.40 +}
    1.41 +
    1.42 +function writeHttpBody(response, body) {
    1.43 +  if (!body) {
    1.44 +    return;
    1.45 +  }
    1.46 +
    1.47 +  response.bodyOutputStream.write(body, body.length);
    1.48 +}
    1.49 +
    1.50 +function sendMozSvcError(request, response, code) {
    1.51 +  response.setStatusLine(request.httpVersion, 400, "Bad Request");
    1.52 +  response.setHeader("Content-Type", "text/plain", false);
    1.53 +  response.bodyOutputStream.write(code, code.length);
    1.54 +}
    1.55 +
    1.56 +/**
    1.57 + * Represent a BSO on the server.
    1.58 + *
    1.59 + * A BSO is constructed from an ID, content, and a modified time.
    1.60 + *
    1.61 + * @param id
    1.62 + *        (string) ID of the BSO being created.
    1.63 + * @param payload
    1.64 + *        (strong|object) Payload for the BSO. Should ideally be a string. If
    1.65 + *        an object is passed, it will be fed into JSON.stringify and that
    1.66 + *        output will be set as the payload.
    1.67 + * @param modified
    1.68 + *        (number) Milliseconds since UNIX epoch that the BSO was last
    1.69 + *        modified. If not defined or null, the current time will be used.
    1.70 + */
    1.71 +this.ServerBSO = function ServerBSO(id, payload, modified) {
    1.72 +  if (!id) {
    1.73 +    throw new Error("No ID for ServerBSO!");
    1.74 +  }
    1.75 +
    1.76 +  if (!id.match(/^[a-zA-Z0-9_-]{1,64}$/)) {
    1.77 +    throw new Error("BSO ID is invalid: " + id);
    1.78 +  }
    1.79 +
    1.80 +  this._log = Log.repository.getLogger(STORAGE_HTTP_LOGGER);
    1.81 +
    1.82 +  this.id = id;
    1.83 +  if (!payload) {
    1.84 +    return;
    1.85 +  }
    1.86 +
    1.87 +  CommonUtils.ensureMillisecondsTimestamp(modified);
    1.88 +
    1.89 +  if (typeof payload == "object") {
    1.90 +    payload = JSON.stringify(payload);
    1.91 +  }
    1.92 +
    1.93 +  this.payload = payload;
    1.94 +  this.modified = modified || new_timestamp();
    1.95 +}
    1.96 +ServerBSO.prototype = {
    1.97 +  FIELDS: [
    1.98 +    "id",
    1.99 +    "modified",
   1.100 +    "payload",
   1.101 +    "ttl",
   1.102 +    "sortindex",
   1.103 +  ],
   1.104 +
   1.105 +  toJSON: function toJSON() {
   1.106 +    let obj = {};
   1.107 +
   1.108 +    for each (let key in this.FIELDS) {
   1.109 +      if (this[key] !== undefined) {
   1.110 +        obj[key] = this[key];
   1.111 +      }
   1.112 +    }
   1.113 +
   1.114 +    return obj;
   1.115 +  },
   1.116 +
   1.117 +  delete: function delete_() {
   1.118 +    this.deleted = true;
   1.119 +
   1.120 +    delete this.payload;
   1.121 +    delete this.modified;
   1.122 +  },
   1.123 +
   1.124 +  /**
   1.125 +   * Handler for GET requests for this BSO.
   1.126 +   */
   1.127 +  getHandler: function getHandler(request, response) {
   1.128 +    let code = 200;
   1.129 +    let status = "OK";
   1.130 +    let body;
   1.131 +
   1.132 +    function sendResponse() {
   1.133 +      response.setStatusLine(request.httpVersion, code, status);
   1.134 +      writeHttpBody(response, body);
   1.135 +    }
   1.136 +
   1.137 +    if (request.hasHeader("x-if-modified-since")) {
   1.138 +      let headerModified = parseInt(request.getHeader("x-if-modified-since"),
   1.139 +                                    10);
   1.140 +      CommonUtils.ensureMillisecondsTimestamp(headerModified);
   1.141 +
   1.142 +      if (headerModified >= this.modified) {
   1.143 +        code = 304;
   1.144 +        status = "Not Modified";
   1.145 +
   1.146 +        sendResponse();
   1.147 +        return;
   1.148 +      }
   1.149 +    } else if (request.hasHeader("x-if-unmodified-since")) {
   1.150 +      let requestModified = parseInt(request.getHeader("x-if-unmodified-since"),
   1.151 +                                     10);
   1.152 +      let serverModified = this.modified;
   1.153 +
   1.154 +      if (serverModified > requestModified) {
   1.155 +        code = 412;
   1.156 +        status = "Precondition Failed";
   1.157 +        sendResponse();
   1.158 +        return;
   1.159 +      }
   1.160 +    }
   1.161 +
   1.162 +    if (!this.deleted) {
   1.163 +      body = JSON.stringify(this.toJSON());
   1.164 +      response.setHeader("Content-Type", "application/json", false);
   1.165 +      response.setHeader("X-Last-Modified", "" + this.modified, false);
   1.166 +    } else {
   1.167 +      code = 404;
   1.168 +      status = "Not Found";
   1.169 +    }
   1.170 +
   1.171 +    sendResponse();
   1.172 +  },
   1.173 +
   1.174 +  /**
   1.175 +   * Handler for PUT requests for this BSO.
   1.176 +   */
   1.177 +  putHandler: function putHandler(request, response) {
   1.178 +    if (request.hasHeader("Content-Type")) {
   1.179 +      let ct = request.getHeader("Content-Type");
   1.180 +      if (ct != "application/json") {
   1.181 +        throw HTTP_415;
   1.182 +      }
   1.183 +    }
   1.184 +
   1.185 +    let input = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
   1.186 +    let parsed;
   1.187 +    try {
   1.188 +      parsed = JSON.parse(input);
   1.189 +    } catch (ex) {
   1.190 +      return sendMozSvcError(request, response, "8");
   1.191 +    }
   1.192 +
   1.193 +    if (typeof(parsed) != "object") {
   1.194 +      return sendMozSvcError(request, response, "8");
   1.195 +    }
   1.196 +
   1.197 +    // Don't update if a conditional request fails preconditions.
   1.198 +    if (request.hasHeader("x-if-unmodified-since")) {
   1.199 +      let reqModified = parseInt(request.getHeader("x-if-unmodified-since"));
   1.200 +
   1.201 +      if (reqModified < this.modified) {
   1.202 +        response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
   1.203 +        return;
   1.204 +      }
   1.205 +    }
   1.206 +
   1.207 +    let code, status;
   1.208 +    if (this.payload) {
   1.209 +      code = 204;
   1.210 +      status = "No Content";
   1.211 +    } else {
   1.212 +      code = 201;
   1.213 +      status = "Created";
   1.214 +    }
   1.215 +
   1.216 +    // Alert when we see unrecognized fields.
   1.217 +    for (let [key, value] in Iterator(parsed)) {
   1.218 +      switch (key) {
   1.219 +        case "payload":
   1.220 +          if (typeof(value) != "string") {
   1.221 +            sendMozSvcError(request, response, "8");
   1.222 +            return true;
   1.223 +          }
   1.224 +
   1.225 +          this.payload = value;
   1.226 +          break;
   1.227 +
   1.228 +        case "ttl":
   1.229 +          if (!isInteger(value)) {
   1.230 +            sendMozSvcError(request, response, "8");
   1.231 +            return true;
   1.232 +          }
   1.233 +          this.ttl = parseInt(value, 10);
   1.234 +          break;
   1.235 +
   1.236 +        case "sortindex":
   1.237 +          if (!isInteger(value) || value.length > 9) {
   1.238 +            sendMozSvcError(request, response, "8");
   1.239 +            return true;
   1.240 +          }
   1.241 +          this.sortindex = parseInt(value, 10);
   1.242 +          break;
   1.243 +
   1.244 +        case "id":
   1.245 +          break;
   1.246 +
   1.247 +        default:
   1.248 +          this._log.warn("Unexpected field in BSO record: " + key);
   1.249 +          sendMozSvcError(request, response, "8");
   1.250 +          return true;
   1.251 +      }
   1.252 +    }
   1.253 +
   1.254 +    this.modified = request.timestamp;
   1.255 +    this.deleted = false;
   1.256 +    response.setHeader("X-Last-Modified", "" + this.modified, false);
   1.257 +
   1.258 +    response.setStatusLine(request.httpVersion, code, status);
   1.259 +  },
   1.260 +};
   1.261 +
   1.262 +/**
   1.263 + * Represent a collection on the server.
   1.264 + *
   1.265 + * The '_bsos' attribute is a mapping of id -> ServerBSO objects.
   1.266 + *
   1.267 + * Note that if you want these records to be accessible individually,
   1.268 + * you need to register their handlers with the server separately, or use a
   1.269 + * containing HTTP server that will do so on your behalf.
   1.270 + *
   1.271 + * @param bsos
   1.272 + *        An object mapping BSO IDs to ServerBSOs.
   1.273 + * @param acceptNew
   1.274 + *        If true, POSTs to this collection URI will result in new BSOs being
   1.275 + *        created and wired in on the fly.
   1.276 + * @param timestamp
   1.277 + *        An optional timestamp value to initialize the modified time of the
   1.278 + *        collection. This should be in the format returned by new_timestamp().
   1.279 + */
   1.280 +this.StorageServerCollection =
   1.281 + function StorageServerCollection(bsos, acceptNew, timestamp=new_timestamp()) {
   1.282 +  this._bsos = bsos || {};
   1.283 +  this.acceptNew = acceptNew || false;
   1.284 +
   1.285 +  /*
   1.286 +   * Track modified timestamp.
   1.287 +   * We can't just use the timestamps of contained BSOs: an empty collection
   1.288 +   * has a modified time.
   1.289 +   */
   1.290 +  CommonUtils.ensureMillisecondsTimestamp(timestamp);
   1.291 +  this._timestamp = timestamp;
   1.292 +
   1.293 +  this._log = Log.repository.getLogger(STORAGE_HTTP_LOGGER);
   1.294 +}
   1.295 +StorageServerCollection.prototype = {
   1.296 +  BATCH_MAX_COUNT: 100,         // # of records.
   1.297 +  BATCH_MAX_SIZE: 1024 * 1024,  // # bytes.
   1.298 +
   1.299 +  _timestamp: null,
   1.300 +
   1.301 +  get timestamp() {
   1.302 +    return this._timestamp;
   1.303 +  },
   1.304 +
   1.305 +  set timestamp(timestamp) {
   1.306 +    CommonUtils.ensureMillisecondsTimestamp(timestamp);
   1.307 +    this._timestamp = timestamp;
   1.308 +  },
   1.309 +
   1.310 +  get totalPayloadSize() {
   1.311 +    let size = 0;
   1.312 +    for each (let bso in this.bsos()) {
   1.313 +      size += bso.payload.length;
   1.314 +    }
   1.315 +
   1.316 +    return size;
   1.317 +  },
   1.318 +
   1.319 +  /**
   1.320 +   * Convenience accessor for our BSO keys.
   1.321 +   * Excludes deleted items, of course.
   1.322 +   *
   1.323 +   * @param filter
   1.324 +   *        A predicate function (applied to the ID and BSO) which dictates
   1.325 +   *        whether to include the BSO's ID in the output.
   1.326 +   *
   1.327 +   * @return an array of IDs.
   1.328 +   */
   1.329 +  keys: function keys(filter) {
   1.330 +    return [id for ([id, bso] in Iterator(this._bsos))
   1.331 +               if (!bso.deleted && (!filter || filter(id, bso)))];
   1.332 +  },
   1.333 +
   1.334 +  /**
   1.335 +   * Convenience method to get an array of BSOs.
   1.336 +   * Optionally provide a filter function.
   1.337 +   *
   1.338 +   * @param filter
   1.339 +   *        A predicate function, applied to the BSO, which dictates whether to
   1.340 +   *        include the BSO in the output.
   1.341 +   *
   1.342 +   * @return an array of ServerBSOs.
   1.343 +   */
   1.344 +  bsos: function bsos(filter) {
   1.345 +    let os = [bso for ([id, bso] in Iterator(this._bsos))
   1.346 +              if (!bso.deleted)];
   1.347 +
   1.348 +    if (!filter) {
   1.349 +      return os;
   1.350 +    }
   1.351 +
   1.352 +    return os.filter(filter);
   1.353 +  },
   1.354 +
   1.355 +  /**
   1.356 +   * Obtain a BSO by ID.
   1.357 +   */
   1.358 +  bso: function bso(id) {
   1.359 +    return this._bsos[id];
   1.360 +  },
   1.361 +
   1.362 +  /**
   1.363 +   * Obtain the payload of a specific BSO.
   1.364 +   *
   1.365 +   * Raises if the specified BSO does not exist.
   1.366 +   */
   1.367 +  payload: function payload(id) {
   1.368 +    return this.bso(id).payload;
   1.369 +  },
   1.370 +
   1.371 +  /**
   1.372 +   * Insert the provided BSO under its ID.
   1.373 +   *
   1.374 +   * @return the provided BSO.
   1.375 +   */
   1.376 +  insertBSO: function insertBSO(bso) {
   1.377 +    return this._bsos[bso.id] = bso;
   1.378 +  },
   1.379 +
   1.380 +  /**
   1.381 +   * Insert the provided payload as part of a new ServerBSO with the provided
   1.382 +   * ID.
   1.383 +   *
   1.384 +   * @param id
   1.385 +   *        The GUID for the BSO.
   1.386 +   * @param payload
   1.387 +   *        The payload, as provided to the ServerBSO constructor.
   1.388 +   * @param modified
   1.389 +   *        An optional modified time for the ServerBSO. If not specified, the
   1.390 +   *        current time will be used.
   1.391 +   *
   1.392 +   * @return the inserted BSO.
   1.393 +   */
   1.394 +  insert: function insert(id, payload, modified) {
   1.395 +    return this.insertBSO(new ServerBSO(id, payload, modified));
   1.396 +  },
   1.397 +
   1.398 +  /**
   1.399 +   * Removes an object entirely from the collection.
   1.400 +   *
   1.401 +   * @param id
   1.402 +   *        (string) ID to remove.
   1.403 +   */
   1.404 +  remove: function remove(id) {
   1.405 +    delete this._bsos[id];
   1.406 +  },
   1.407 +
   1.408 +  _inResultSet: function _inResultSet(bso, options) {
   1.409 +    if (!bso.payload) {
   1.410 +      return false;
   1.411 +    }
   1.412 +
   1.413 +    if (options.ids) {
   1.414 +      if (options.ids.indexOf(bso.id) == -1) {
   1.415 +        return false;
   1.416 +      }
   1.417 +    }
   1.418 +
   1.419 +    if (options.newer) {
   1.420 +      if (bso.modified <= options.newer) {
   1.421 +        return false;
   1.422 +      }
   1.423 +    }
   1.424 +
   1.425 +    if (options.older) {
   1.426 +      if (bso.modified >= options.older) {
   1.427 +        return false;
   1.428 +      }
   1.429 +    }
   1.430 +
   1.431 +    return true;
   1.432 +  },
   1.433 +
   1.434 +  count: function count(options) {
   1.435 +    options = options || {};
   1.436 +    let c = 0;
   1.437 +    for (let [id, bso] in Iterator(this._bsos)) {
   1.438 +      if (bso.modified && this._inResultSet(bso, options)) {
   1.439 +        c++;
   1.440 +      }
   1.441 +    }
   1.442 +    return c;
   1.443 +  },
   1.444 +
   1.445 +  get: function get(options) {
   1.446 +    let data = [];
   1.447 +    for each (let bso in this._bsos) {
   1.448 +      if (!bso.modified) {
   1.449 +        continue;
   1.450 +      }
   1.451 +
   1.452 +      if (!this._inResultSet(bso, options)) {
   1.453 +        continue;
   1.454 +      }
   1.455 +
   1.456 +      data.push(bso);
   1.457 +    }
   1.458 +
   1.459 +    if (options.sort) {
   1.460 +      if (options.sort == "oldest") {
   1.461 +        data.sort(function sortOldest(a, b) {
   1.462 +          if (a.modified == b.modified) {
   1.463 +            return 0;
   1.464 +          }
   1.465 +
   1.466 +          return a.modified < b.modified ? -1 : 1;
   1.467 +        });
   1.468 +      } else if (options.sort == "newest") {
   1.469 +        data.sort(function sortNewest(a, b) {
   1.470 +          if (a.modified == b.modified) {
   1.471 +            return 0;
   1.472 +          }
   1.473 +
   1.474 +          return a.modified > b.modified ? -1 : 1;
   1.475 +        });
   1.476 +      } else if (options.sort == "index") {
   1.477 +        data.sort(function sortIndex(a, b) {
   1.478 +          if (a.sortindex == b.sortindex) {
   1.479 +            return 0;
   1.480 +          }
   1.481 +
   1.482 +          if (a.sortindex !== undefined && b.sortindex == undefined) {
   1.483 +            return 1;
   1.484 +          }
   1.485 +
   1.486 +          if (a.sortindex === undefined && b.sortindex !== undefined) {
   1.487 +            return -1;
   1.488 +          }
   1.489 +
   1.490 +          return a.sortindex > b.sortindex ? -1 : 1;
   1.491 +        });
   1.492 +      }
   1.493 +    }
   1.494 +
   1.495 +    if (options.limit) {
   1.496 +      data = data.slice(0, options.limit);
   1.497 +    }
   1.498 +
   1.499 +    return data;
   1.500 +  },
   1.501 +
   1.502 +  post: function post(input, timestamp) {
   1.503 +    let success = [];
   1.504 +    let failed = {};
   1.505 +    let count = 0;
   1.506 +    let size = 0;
   1.507 +
   1.508 +    // This will count records where we have an existing ServerBSO
   1.509 +    // registered with us as successful and all other records as failed.
   1.510 +    for each (let record in input) {
   1.511 +      count += 1;
   1.512 +      if (count > this.BATCH_MAX_COUNT) {
   1.513 +        failed[record.id] = "Max record count exceeded.";
   1.514 +        continue;
   1.515 +      }
   1.516 +
   1.517 +      if (typeof(record.payload) != "string") {
   1.518 +        failed[record.id] = "Payload is not a string!";
   1.519 +        continue;
   1.520 +      }
   1.521 +
   1.522 +      size += record.payload.length;
   1.523 +      if (size > this.BATCH_MAX_SIZE) {
   1.524 +        failed[record.id] = "Payload max size exceeded!";
   1.525 +        continue;
   1.526 +      }
   1.527 +
   1.528 +      if (record.sortindex) {
   1.529 +        if (!isInteger(record.sortindex)) {
   1.530 +          failed[record.id] = "sortindex is not an integer.";
   1.531 +          continue;
   1.532 +        }
   1.533 +
   1.534 +        if (record.sortindex.length > 9) {
   1.535 +          failed[record.id] = "sortindex is too long.";
   1.536 +          continue;
   1.537 +        }
   1.538 +      }
   1.539 +
   1.540 +      if ("ttl" in record) {
   1.541 +        if (!isInteger(record.ttl)) {
   1.542 +          failed[record.id] = "ttl is not an integer.";
   1.543 +          continue;
   1.544 +        }
   1.545 +      }
   1.546 +
   1.547 +      try {
   1.548 +        let bso = this.bso(record.id);
   1.549 +        if (!bso && this.acceptNew) {
   1.550 +          this._log.debug("Creating BSO " + JSON.stringify(record.id) +
   1.551 +                          " on the fly.");
   1.552 +          bso = new ServerBSO(record.id);
   1.553 +          this.insertBSO(bso);
   1.554 +        }
   1.555 +        if (bso) {
   1.556 +          bso.payload = record.payload;
   1.557 +          bso.modified = timestamp;
   1.558 +          bso.deleted = false;
   1.559 +          success.push(record.id);
   1.560 +
   1.561 +          if (record.sortindex) {
   1.562 +            bso.sortindex = parseInt(record.sortindex, 10);
   1.563 +          }
   1.564 +
   1.565 +        } else {
   1.566 +          failed[record.id] = "no bso configured";
   1.567 +        }
   1.568 +      } catch (ex) {
   1.569 +        this._log.info("Exception when processing BSO: " +
   1.570 +                       CommonUtils.exceptionStr(ex));
   1.571 +        failed[record.id] = "Exception when processing.";
   1.572 +      }
   1.573 +    }
   1.574 +    return {success: success, failed: failed};
   1.575 +  },
   1.576 +
   1.577 +  delete: function delete_(options) {
   1.578 +    options = options || {};
   1.579 +
   1.580 +    // Protocol 2.0 only allows the "ids" query string argument.
   1.581 +    let keys = Object.keys(options).filter(function(k) {
   1.582 +      return k != "ids";
   1.583 +    });
   1.584 +    if (keys.length) {
   1.585 +      this._log.warn("Invalid query string parameter to collection delete: " +
   1.586 +                     keys.join(", "));
   1.587 +      throw new Error("Malformed client request.");
   1.588 +    }
   1.589 +
   1.590 +    if (options.ids && options.ids.length > this.BATCH_MAX_COUNT) {
   1.591 +      throw HTTP_400;
   1.592 +    }
   1.593 +
   1.594 +    let deleted = [];
   1.595 +    for (let [id, bso] in Iterator(this._bsos)) {
   1.596 +      if (this._inResultSet(bso, options)) {
   1.597 +        this._log.debug("Deleting " + JSON.stringify(bso));
   1.598 +        deleted.push(bso.id);
   1.599 +        bso.delete();
   1.600 +      }
   1.601 +    }
   1.602 +    return deleted;
   1.603 +  },
   1.604 +
   1.605 +  parseOptions: function parseOptions(request) {
   1.606 +    let options = {};
   1.607 +
   1.608 +    for each (let chunk in request.queryString.split("&")) {
   1.609 +      if (!chunk) {
   1.610 +        continue;
   1.611 +      }
   1.612 +      chunk = chunk.split("=");
   1.613 +      let key = decodeURIComponent(chunk[0]);
   1.614 +      if (chunk.length == 1) {
   1.615 +        options[key] = "";
   1.616 +      } else {
   1.617 +        options[key] = decodeURIComponent(chunk[1]);
   1.618 +      }
   1.619 +    }
   1.620 +
   1.621 +    if (options.ids) {
   1.622 +      options.ids = options.ids.split(",");
   1.623 +    }
   1.624 +
   1.625 +    if (options.newer) {
   1.626 +      if (!isInteger(options.newer)) {
   1.627 +        throw HTTP_400;
   1.628 +      }
   1.629 +
   1.630 +      CommonUtils.ensureMillisecondsTimestamp(options.newer);
   1.631 +      options.newer = parseInt(options.newer, 10);
   1.632 +    }
   1.633 +
   1.634 +    if (options.older) {
   1.635 +      if (!isInteger(options.older)) {
   1.636 +        throw HTTP_400;
   1.637 +      }
   1.638 +
   1.639 +      CommonUtils.ensureMillisecondsTimestamp(options.older);
   1.640 +      options.older = parseInt(options.older, 10);
   1.641 +    }
   1.642 +
   1.643 +    if (options.limit) {
   1.644 +      if (!isInteger(options.limit)) {
   1.645 +        throw HTTP_400;
   1.646 +      }
   1.647 +
   1.648 +      options.limit = parseInt(options.limit, 10);
   1.649 +    }
   1.650 +
   1.651 +    return options;
   1.652 +  },
   1.653 +
   1.654 +  getHandler: function getHandler(request, response) {
   1.655 +    let options = this.parseOptions(request);
   1.656 +    let data = this.get(options);
   1.657 +
   1.658 +    if (request.hasHeader("x-if-modified-since")) {
   1.659 +      let requestModified = parseInt(request.getHeader("x-if-modified-since"),
   1.660 +                                     10);
   1.661 +      let newestBSO = 0;
   1.662 +      for each (let bso in data) {
   1.663 +        if (bso.modified > newestBSO) {
   1.664 +          newestBSO = bso.modified;
   1.665 +        }
   1.666 +      }
   1.667 +
   1.668 +      if (requestModified >= newestBSO) {
   1.669 +        response.setHeader("X-Last-Modified", "" + newestBSO);
   1.670 +        response.setStatusLine(request.httpVersion, 304, "Not Modified");
   1.671 +        return;
   1.672 +      }
   1.673 +    } else if (request.hasHeader("x-if-unmodified-since")) {
   1.674 +      let requestModified = parseInt(request.getHeader("x-if-unmodified-since"),
   1.675 +                                     10);
   1.676 +      let serverModified = this.timestamp;
   1.677 +
   1.678 +      if (serverModified > requestModified) {
   1.679 +        response.setHeader("X-Last-Modified", "" + serverModified);
   1.680 +        response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
   1.681 +        return;
   1.682 +      }
   1.683 +    }
   1.684 +
   1.685 +    if (options.full) {
   1.686 +      data = data.map(function map(bso) {
   1.687 +        return bso.toJSON();
   1.688 +      });
   1.689 +    } else {
   1.690 +      data = data.map(function map(bso) {
   1.691 +        return bso.id;
   1.692 +      });
   1.693 +    }
   1.694 +
   1.695 +    // application/json is default media type.
   1.696 +    let newlines = false;
   1.697 +    if (request.hasHeader("accept")) {
   1.698 +      let accept = request.getHeader("accept");
   1.699 +      if (accept == "application/newlines") {
   1.700 +        newlines = true;
   1.701 +      } else if (accept != "application/json") {
   1.702 +        throw HTTP_406;
   1.703 +      }
   1.704 +    }
   1.705 +
   1.706 +    let body;
   1.707 +    if (newlines) {
   1.708 +      response.setHeader("Content-Type", "application/newlines", false);
   1.709 +      let normalized = data.map(function map(d) {
   1.710 +        return JSON.stringify(d);
   1.711 +      });
   1.712 +
   1.713 +      body = normalized.join("\n") + "\n";
   1.714 +    } else {
   1.715 +      response.setHeader("Content-Type", "application/json", false);
   1.716 +      body = JSON.stringify({items: data});
   1.717 +    }
   1.718 +
   1.719 +    this._log.info("Records: " + data.length);
   1.720 +    response.setHeader("X-Num-Records", "" + data.length, false);
   1.721 +    response.setHeader("X-Last-Modified", "" + this.timestamp, false);
   1.722 +    response.setStatusLine(request.httpVersion, 200, "OK");
   1.723 +    response.bodyOutputStream.write(body, body.length);
   1.724 +  },
   1.725 +
   1.726 +  postHandler: function postHandler(request, response) {
   1.727 +    let options = this.parseOptions(request);
   1.728 +
   1.729 +    if (!request.hasHeader("content-type")) {
   1.730 +      this._log.info("No Content-Type request header!");
   1.731 +      throw HTTP_400;
   1.732 +    }
   1.733 +
   1.734 +    let inputStream = request.bodyInputStream;
   1.735 +    let inputBody = CommonUtils.readBytesFromInputStream(inputStream);
   1.736 +    let input = [];
   1.737 +
   1.738 +    let inputMediaType = request.getHeader("content-type");
   1.739 +    if (inputMediaType == "application/json") {
   1.740 +      try {
   1.741 +        input = JSON.parse(inputBody);
   1.742 +      } catch (ex) {
   1.743 +        this._log.info("JSON parse error on input body!");
   1.744 +        throw HTTP_400;
   1.745 +      }
   1.746 +
   1.747 +      if (!Array.isArray(input)) {
   1.748 +        this._log.info("Input JSON type not an array!");
   1.749 +        return sendMozSvcError(request, response, "8");
   1.750 +      }
   1.751 +    } else if (inputMediaType == "application/newlines") {
   1.752 +      for each (let line in inputBody.split("\n")) {
   1.753 +        let record;
   1.754 +        try {
   1.755 +          record = JSON.parse(line);
   1.756 +        } catch (ex) {
   1.757 +          this._log.info("JSON parse error on line!");
   1.758 +          return sendMozSvcError(request, response, "8");
   1.759 +        }
   1.760 +
   1.761 +        input.push(record);
   1.762 +      }
   1.763 +    } else {
   1.764 +      this._log.info("Unknown media type: " + inputMediaType);
   1.765 +      throw HTTP_415;
   1.766 +    }
   1.767 +
   1.768 +    if (this._ensureUnmodifiedSince(request, response)) {
   1.769 +      return;
   1.770 +    }
   1.771 +
   1.772 +    let res = this.post(input, request.timestamp);
   1.773 +    let body = JSON.stringify(res);
   1.774 +    response.setHeader("Content-Type", "application/json", false);
   1.775 +    this.timestamp = request.timestamp;
   1.776 +    response.setHeader("X-Last-Modified", "" + this.timestamp, false);
   1.777 +
   1.778 +    response.setStatusLine(request.httpVersion, "200", "OK");
   1.779 +    response.bodyOutputStream.write(body, body.length);
   1.780 +  },
   1.781 +
   1.782 +  deleteHandler: function deleteHandler(request, response) {
   1.783 +    this._log.debug("Invoking StorageServerCollection.DELETE.");
   1.784 +
   1.785 +    let options = this.parseOptions(request);
   1.786 +
   1.787 +    if (this._ensureUnmodifiedSince(request, response)) {
   1.788 +      return;
   1.789 +    }
   1.790 +
   1.791 +    let deleted = this.delete(options);
   1.792 +    response.deleted = deleted;
   1.793 +    this.timestamp = request.timestamp;
   1.794 +
   1.795 +    response.setStatusLine(request.httpVersion, 204, "No Content");
   1.796 +  },
   1.797 +
   1.798 +  handler: function handler() {
   1.799 +    let self = this;
   1.800 +
   1.801 +    return function(request, response) {
   1.802 +      switch(request.method) {
   1.803 +        case "GET":
   1.804 +          return self.getHandler(request, response);
   1.805 +
   1.806 +        case "POST":
   1.807 +          return self.postHandler(request, response);
   1.808 +
   1.809 +        case "DELETE":
   1.810 +          return self.deleteHandler(request, response);
   1.811 +
   1.812 +      }
   1.813 +
   1.814 +      request.setHeader("Allow", "GET,POST,DELETE");
   1.815 +      response.setStatusLine(request.httpVersion, 405, "Method Not Allowed");
   1.816 +    };
   1.817 +  },
   1.818 +
   1.819 +  _ensureUnmodifiedSince: function _ensureUnmodifiedSince(request, response) {
   1.820 +    if (!request.hasHeader("x-if-unmodified-since")) {
   1.821 +      return false;
   1.822 +    }
   1.823 +
   1.824 +    let requestModified = parseInt(request.getHeader("x-if-unmodified-since"),
   1.825 +                                   10);
   1.826 +    let serverModified = this.timestamp;
   1.827 +
   1.828 +    this._log.debug("Request modified time: " + requestModified +
   1.829 +                    "; Server modified time: " + serverModified);
   1.830 +    if (serverModified <= requestModified) {
   1.831 +      return false;
   1.832 +    }
   1.833 +
   1.834 +    this._log.info("Conditional request rejected because client time older " +
   1.835 +                   "than collection timestamp.");
   1.836 +    response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
   1.837 +    return true;
   1.838 +  },
   1.839 +};
   1.840 +
   1.841 +
   1.842 +//===========================================================================//
   1.843 +// httpd.js-based Storage server.                                            //
   1.844 +//===========================================================================//
   1.845 +
   1.846 +/**
   1.847 + * In general, the preferred way of using StorageServer is to directly
   1.848 + * introspect it. Callbacks are available for operations which are hard to
   1.849 + * verify through introspection, such as deletions.
   1.850 + *
   1.851 + * One of the goals of this server is to provide enough hooks for test code to
   1.852 + * find out what it needs without monkeypatching. Use this object as your
   1.853 + * prototype, and override as appropriate.
   1.854 + */
   1.855 +this.StorageServerCallback = {
   1.856 +  onCollectionDeleted: function onCollectionDeleted(user, collection) {},
   1.857 +  onItemDeleted: function onItemDeleted(user, collection, bsoID) {},
   1.858 +
   1.859 +  /**
   1.860 +   * Called at the top of every request.
   1.861 +   *
   1.862 +   * Allows the test to inspect the request. Hooks should be careful not to
   1.863 +   * modify or change state of the request or they may impact future processing.
   1.864 +   */
   1.865 +  onRequest: function onRequest(request) {},
   1.866 +};
   1.867 +
   1.868 +/**
   1.869 + * Construct a new test Storage server. Takes a callback object (e.g.,
   1.870 + * StorageServerCallback) as input.
   1.871 + */
   1.872 +this.StorageServer = function StorageServer(callback) {
   1.873 +  this.callback     = callback || {__proto__: StorageServerCallback};
   1.874 +  this.server       = new HttpServer();
   1.875 +  this.started      = false;
   1.876 +  this.users        = {};
   1.877 +  this.requestCount = 0;
   1.878 +  this._log         = Log.repository.getLogger(STORAGE_HTTP_LOGGER);
   1.879 +
   1.880 +  // Install our own default handler. This allows us to mess around with the
   1.881 +  // whole URL space.
   1.882 +  let handler = this.server._handler;
   1.883 +  handler._handleDefault = this.handleDefault.bind(this, handler);
   1.884 +}
   1.885 +StorageServer.prototype = {
   1.886 +  DEFAULT_QUOTA: 1024 * 1024, // # bytes.
   1.887 +
   1.888 +  server: null,    // HttpServer.
   1.889 +  users:  null,    // Map of username => {collections, password}.
   1.890 +
   1.891 +  /**
   1.892 +   * If true, the server will allow any arbitrary user to be used.
   1.893 +   *
   1.894 +   * No authentication will be performed. Whatever user is detected from the
   1.895 +   * URL or auth headers will be created (if needed) and used.
   1.896 +   */
   1.897 +  allowAllUsers: false,
   1.898 +
   1.899 +  /**
   1.900 +   * Start the StorageServer's underlying HTTP server.
   1.901 +   *
   1.902 +   * @param port
   1.903 +   *        The numeric port on which to start. A falsy value implies to
   1.904 +   *        select any available port.
   1.905 +   * @param cb
   1.906 +   *        A callback function (of no arguments) which is invoked after
   1.907 +   *        startup.
   1.908 +   */
   1.909 +  start: function start(port, cb) {
   1.910 +    if (this.started) {
   1.911 +      this._log.warn("Warning: server already started on " + this.port);
   1.912 +      return;
   1.913 +    }
   1.914 +    if (!port) {
   1.915 +      port = -1;
   1.916 +    }
   1.917 +    this.port = port;
   1.918 +
   1.919 +    try {
   1.920 +      this.server.start(this.port);
   1.921 +      this.port = this.server.identity.primaryPort;
   1.922 +      this.started = true;
   1.923 +      if (cb) {
   1.924 +        cb();
   1.925 +      }
   1.926 +    } catch (ex) {
   1.927 +      _("==========================================");
   1.928 +      _("Got exception starting Storage HTTP server on port " + this.port);
   1.929 +      _("Error: " + CommonUtils.exceptionStr(ex));
   1.930 +      _("Is there a process already listening on port " + this.port + "?");
   1.931 +      _("==========================================");
   1.932 +      do_throw(ex);
   1.933 +    }
   1.934 +  },
   1.935 +
   1.936 +  /**
   1.937 +   * Start the server synchronously.
   1.938 +   *
   1.939 +   * @param port
   1.940 +   *        The numeric port on which to start. The default is to choose
   1.941 +   *        any available port.
   1.942 +   */
   1.943 +  startSynchronous: function startSynchronous(port=-1) {
   1.944 +    let cb = Async.makeSpinningCallback();
   1.945 +    this.start(port, cb);
   1.946 +    cb.wait();
   1.947 +  },
   1.948 +
   1.949 +  /**
   1.950 +   * Stop the StorageServer's HTTP server.
   1.951 +   *
   1.952 +   * @param cb
   1.953 +   *        A callback function. Invoked after the server has been stopped.
   1.954 +   *
   1.955 +   */
   1.956 +  stop: function stop(cb) {
   1.957 +    if (!this.started) {
   1.958 +      this._log.warn("StorageServer: Warning: server not running. Can't stop " +
   1.959 +                     "me now!");
   1.960 +      return;
   1.961 +    }
   1.962 +
   1.963 +    this.server.stop(cb);
   1.964 +    this.started = false;
   1.965 +  },
   1.966 +
   1.967 +  serverTime: function serverTime() {
   1.968 +    return new_timestamp();
   1.969 +  },
   1.970 +
   1.971 +  /**
   1.972 +   * Create a new user, complete with an empty set of collections.
   1.973 +   *
   1.974 +   * @param username
   1.975 +   *        The username to use. An Error will be thrown if a user by that name
   1.976 +   *        already exists.
   1.977 +   * @param password
   1.978 +   *        A password string.
   1.979 +   *
   1.980 +   * @return a user object, as would be returned by server.user(username).
   1.981 +   */
   1.982 +  registerUser: function registerUser(username, password) {
   1.983 +    if (username in this.users) {
   1.984 +      throw new Error("User already exists.");
   1.985 +    }
   1.986 +
   1.987 +    if (!isFinite(parseInt(username))) {
   1.988 +      throw new Error("Usernames must be numeric: " + username);
   1.989 +    }
   1.990 +
   1.991 +    this._log.info("Registering new user with server: " + username);
   1.992 +    this.users[username] = {
   1.993 +      password: password,
   1.994 +      collections: {},
   1.995 +      quota: this.DEFAULT_QUOTA,
   1.996 +    };
   1.997 +    return this.user(username);
   1.998 +  },
   1.999 +
  1.1000 +  userExists: function userExists(username) {
  1.1001 +    return username in this.users;
  1.1002 +  },
  1.1003 +
  1.1004 +  getCollection: function getCollection(username, collection) {
  1.1005 +    return this.users[username].collections[collection];
  1.1006 +  },
  1.1007 +
  1.1008 +  _insertCollection: function _insertCollection(collections, collection, bsos) {
  1.1009 +    let coll = new StorageServerCollection(bsos, true);
  1.1010 +    coll.collectionHandler = coll.handler();
  1.1011 +    collections[collection] = coll;
  1.1012 +    return coll;
  1.1013 +  },
  1.1014 +
  1.1015 +  createCollection: function createCollection(username, collection, bsos) {
  1.1016 +    if (!(username in this.users)) {
  1.1017 +      throw new Error("Unknown user.");
  1.1018 +    }
  1.1019 +    let collections = this.users[username].collections;
  1.1020 +    if (collection in collections) {
  1.1021 +      throw new Error("Collection already exists.");
  1.1022 +    }
  1.1023 +    return this._insertCollection(collections, collection, bsos);
  1.1024 +  },
  1.1025 +
  1.1026 +  deleteCollection: function deleteCollection(username, collection) {
  1.1027 +    if (!(username in this.users)) {
  1.1028 +      throw new Error("Unknown user.");
  1.1029 +    }
  1.1030 +    delete this.users[username].collections[collection];
  1.1031 +  },
  1.1032 +
  1.1033 +  /**
  1.1034 +   * Accept a map like the following:
  1.1035 +   * {
  1.1036 +   *   meta: {global: {version: 1, ...}},
  1.1037 +   *   crypto: {"keys": {}, foo: {bar: 2}},
  1.1038 +   *   bookmarks: {}
  1.1039 +   * }
  1.1040 +   * to cause collections and BSOs to be created.
  1.1041 +   * If a collection already exists, no error is raised.
  1.1042 +   * If a BSO already exists, it will be updated to the new contents.
  1.1043 +   */
  1.1044 +  createContents: function createContents(username, collections) {
  1.1045 +    if (!(username in this.users)) {
  1.1046 +      throw new Error("Unknown user.");
  1.1047 +    }
  1.1048 +    let userCollections = this.users[username].collections;
  1.1049 +    for (let [id, contents] in Iterator(collections)) {
  1.1050 +      let coll = userCollections[id] ||
  1.1051 +                 this._insertCollection(userCollections, id);
  1.1052 +      for (let [bsoID, payload] in Iterator(contents)) {
  1.1053 +        coll.insert(bsoID, payload);
  1.1054 +      }
  1.1055 +    }
  1.1056 +  },
  1.1057 +
  1.1058 +  /**
  1.1059 +   * Insert a BSO in an existing collection.
  1.1060 +   */
  1.1061 +  insertBSO: function insertBSO(username, collection, bso) {
  1.1062 +    if (!(username in this.users)) {
  1.1063 +      throw new Error("Unknown user.");
  1.1064 +    }
  1.1065 +    let userCollections = this.users[username].collections;
  1.1066 +    if (!(collection in userCollections)) {
  1.1067 +      throw new Error("Unknown collection.");
  1.1068 +    }
  1.1069 +    userCollections[collection].insertBSO(bso);
  1.1070 +    return bso;
  1.1071 +  },
  1.1072 +
  1.1073 +  /**
  1.1074 +   * Delete all of the collections for the named user.
  1.1075 +   *
  1.1076 +   * @param username
  1.1077 +   *        The name of the affected user.
  1.1078 +   */
  1.1079 +  deleteCollections: function deleteCollections(username) {
  1.1080 +    if (!(username in this.users)) {
  1.1081 +      throw new Error("Unknown user.");
  1.1082 +    }
  1.1083 +    let userCollections = this.users[username].collections;
  1.1084 +    for each (let [name, coll] in Iterator(userCollections)) {
  1.1085 +      this._log.trace("Bulk deleting " + name + " for " + username + "...");
  1.1086 +      coll.delete({});
  1.1087 +    }
  1.1088 +    this.users[username].collections = {};
  1.1089 +  },
  1.1090 +
  1.1091 +  getQuota: function getQuota(username) {
  1.1092 +    if (!(username in this.users)) {
  1.1093 +      throw new Error("Unknown user.");
  1.1094 +    }
  1.1095 +
  1.1096 +    return this.users[username].quota;
  1.1097 +  },
  1.1098 +
  1.1099 +  /**
  1.1100 +   * Obtain the newest timestamp of all collections for a user.
  1.1101 +   */
  1.1102 +  newestCollectionTimestamp: function newestCollectionTimestamp(username) {
  1.1103 +    let collections = this.users[username].collections;
  1.1104 +    let newest = 0;
  1.1105 +    for each (let collection in collections) {
  1.1106 +      if (collection.timestamp > newest) {
  1.1107 +        newest = collection.timestamp;
  1.1108 +      }
  1.1109 +    }
  1.1110 +
  1.1111 +    return newest;
  1.1112 +  },
  1.1113 +
  1.1114 +  /**
  1.1115 +   * Compute the object that is returned for an info/collections request.
  1.1116 +   */
  1.1117 +  infoCollections: function infoCollections(username) {
  1.1118 +    let responseObject = {};
  1.1119 +    let colls = this.users[username].collections;
  1.1120 +    for (let coll in colls) {
  1.1121 +      responseObject[coll] = colls[coll].timestamp;
  1.1122 +    }
  1.1123 +    this._log.trace("StorageServer: info/collections returning " +
  1.1124 +                    JSON.stringify(responseObject));
  1.1125 +    return responseObject;
  1.1126 +  },
  1.1127 +
  1.1128 +  infoCounts: function infoCounts(username) {
  1.1129 +    let data = {};
  1.1130 +    let collections = this.users[username].collections;
  1.1131 +    for (let [k, v] in Iterator(collections)) {
  1.1132 +      let count = v.count();
  1.1133 +      if (!count) {
  1.1134 +        continue;
  1.1135 +      }
  1.1136 +
  1.1137 +      data[k] = count;
  1.1138 +    }
  1.1139 +
  1.1140 +    return data;
  1.1141 +  },
  1.1142 +
  1.1143 +  infoUsage: function infoUsage(username) {
  1.1144 +    let data = {};
  1.1145 +    let collections = this.users[username].collections;
  1.1146 +    for (let [k, v] in Iterator(collections)) {
  1.1147 +      data[k] = v.totalPayloadSize;
  1.1148 +    }
  1.1149 +
  1.1150 +    return data;
  1.1151 +  },
  1.1152 +
  1.1153 +  infoQuota: function infoQuota(username) {
  1.1154 +    let total = 0;
  1.1155 +    for each (let value in this.infoUsage(username)) {
  1.1156 +      total += value;
  1.1157 +    }
  1.1158 +
  1.1159 +    return {
  1.1160 +      quota: this.getQuota(username),
  1.1161 +      usage: total
  1.1162 +    };
  1.1163 +  },
  1.1164 +
  1.1165 +  /**
  1.1166 +   * Simple accessor to allow collective binding and abbreviation of a bunch of
  1.1167 +   * methods. Yay!
  1.1168 +   * Use like this:
  1.1169 +   *
  1.1170 +   *   let u = server.user("john");
  1.1171 +   *   u.collection("bookmarks").bso("abcdefg").payload;  // Etc.
  1.1172 +   *
  1.1173 +   * @return a proxy for the user data stored in this server.
  1.1174 +   */
  1.1175 +  user: function user(username) {
  1.1176 +    let collection       = this.getCollection.bind(this, username);
  1.1177 +    let createCollection = this.createCollection.bind(this, username);
  1.1178 +    let createContents   = this.createContents.bind(this, username);
  1.1179 +    let modified         = function (collectionName) {
  1.1180 +      return collection(collectionName).timestamp;
  1.1181 +    }
  1.1182 +    let deleteCollections = this.deleteCollections.bind(this, username);
  1.1183 +    let quota             = this.getQuota.bind(this, username);
  1.1184 +    return {
  1.1185 +      collection:        collection,
  1.1186 +      createCollection:  createCollection,
  1.1187 +      createContents:    createContents,
  1.1188 +      deleteCollections: deleteCollections,
  1.1189 +      modified:          modified,
  1.1190 +      quota:             quota,
  1.1191 +    };
  1.1192 +  },
  1.1193 +
  1.1194 +  _pruneExpired: function _pruneExpired() {
  1.1195 +    let now = Date.now();
  1.1196 +
  1.1197 +    for each (let user in this.users) {
  1.1198 +      for each (let collection in user.collections) {
  1.1199 +        for each (let bso in collection.bsos()) {
  1.1200 +          // ttl === 0 is a special case, so we can't simply !ttl.
  1.1201 +          if (typeof(bso.ttl) != "number") {
  1.1202 +            continue;
  1.1203 +          }
  1.1204 +
  1.1205 +          let ttlDate = bso.modified + (bso.ttl * 1000);
  1.1206 +          if (ttlDate < now) {
  1.1207 +            this._log.info("Deleting BSO because TTL expired: " + bso.id);
  1.1208 +            bso.delete();
  1.1209 +          }
  1.1210 +        }
  1.1211 +      }
  1.1212 +    }
  1.1213 +  },
  1.1214 +
  1.1215 +  /*
  1.1216 +   * Regular expressions for splitting up Storage request paths.
  1.1217 +   * Storage URLs are of the form:
  1.1218 +   *   /$apipath/$version/$userid/$further
  1.1219 +   * where $further is usually:
  1.1220 +   *   storage/$collection/$bso
  1.1221 +   * or
  1.1222 +   *   storage/$collection
  1.1223 +   * or
  1.1224 +   *   info/$op
  1.1225 +   *
  1.1226 +   * We assume for the sake of simplicity that $apipath is empty.
  1.1227 +   *
  1.1228 +   * N.B., we don't follow any kind of username spec here, because as far as I
  1.1229 +   * can tell there isn't one. See Bug 689671. Instead we follow the Python
  1.1230 +   * server code.
  1.1231 +   *
  1.1232 +   * Path: [all, version, first, rest]
  1.1233 +   * Storage: [all, collection?, id?]
  1.1234 +   */
  1.1235 +  pathRE: /^\/([0-9]+(?:\.[0-9]+)?)(?:\/([0-9]+)\/([^\/]+)(?:\/(.+))?)?$/,
  1.1236 +  storageRE: /^([-_a-zA-Z0-9]+)(?:\/([-_a-zA-Z0-9]+)\/?)?$/,
  1.1237 +
  1.1238 +  defaultHeaders: {},
  1.1239 +
  1.1240 +  /**
  1.1241 +   * HTTP response utility.
  1.1242 +   */
  1.1243 +  respond: function respond(req, resp, code, status, body, headers, timestamp) {
  1.1244 +    this._log.info("Response: " + code + " " + status);
  1.1245 +    resp.setStatusLine(req.httpVersion, code, status);
  1.1246 +    for each (let [header, value] in Iterator(headers || this.defaultHeaders)) {
  1.1247 +      resp.setHeader(header, value, false);
  1.1248 +    }
  1.1249 +
  1.1250 +    if (timestamp) {
  1.1251 +      resp.setHeader("X-Timestamp", "" + timestamp, false);
  1.1252 +    }
  1.1253 +
  1.1254 +    if (body) {
  1.1255 +      resp.bodyOutputStream.write(body, body.length);
  1.1256 +    }
  1.1257 +  },
  1.1258 +
  1.1259 +  /**
  1.1260 +   * This is invoked by the HttpServer. `this` is bound to the StorageServer;
  1.1261 +   * `handler` is the HttpServer's handler.
  1.1262 +   *
  1.1263 +   * TODO: need to use the correct Storage API response codes and errors here.
  1.1264 +   */
  1.1265 +  handleDefault: function handleDefault(handler, req, resp) {
  1.1266 +    this.requestCount++;
  1.1267 +    let timestamp = new_timestamp();
  1.1268 +    try {
  1.1269 +      this._handleDefault(handler, req, resp, timestamp);
  1.1270 +    } catch (e) {
  1.1271 +      if (e instanceof HttpError) {
  1.1272 +        this.respond(req, resp, e.code, e.description, "", {}, timestamp);
  1.1273 +      } else {
  1.1274 +        this._log.warn(CommonUtils.exceptionStr(e));
  1.1275 +        throw e;
  1.1276 +      }
  1.1277 +    }
  1.1278 +  },
  1.1279 +
  1.1280 +  _handleDefault: function _handleDefault(handler, req, resp, timestamp) {
  1.1281 +    let path = req.path;
  1.1282 +    if (req.queryString.length) {
  1.1283 +      path += "?" + req.queryString;
  1.1284 +    }
  1.1285 +
  1.1286 +    this._log.debug("StorageServer: Handling request: " + req.method + " " +
  1.1287 +                    path);
  1.1288 +
  1.1289 +    if (this.callback.onRequest) {
  1.1290 +      this.callback.onRequest(req);
  1.1291 +    }
  1.1292 +
  1.1293 +    // Prune expired records for all users at top of request. This is the
  1.1294 +    // easiest way to process TTLs since all requests go through here.
  1.1295 +    this._pruneExpired();
  1.1296 +
  1.1297 +    req.timestamp = timestamp;
  1.1298 +    resp.setHeader("X-Timestamp", "" + timestamp, false);
  1.1299 +
  1.1300 +    let parts = this.pathRE.exec(req.path);
  1.1301 +    if (!parts) {
  1.1302 +      this._log.debug("StorageServer: Unexpected request: bad URL " + req.path);
  1.1303 +      throw HTTP_404;
  1.1304 +    }
  1.1305 +
  1.1306 +    let [all, version, userPath, first, rest] = parts;
  1.1307 +    if (version != STORAGE_API_VERSION) {
  1.1308 +      this._log.debug("StorageServer: Unknown version.");
  1.1309 +      throw HTTP_404;
  1.1310 +    }
  1.1311 +
  1.1312 +    let username;
  1.1313 +
  1.1314 +    // By default, the server requires users to be authenticated. When a
  1.1315 +    // request arrives, the user must have been previously configured and
  1.1316 +    // the request must have authentication. In "allow all users" mode, we
  1.1317 +    // take the username from the URL, create the user on the fly, and don't
  1.1318 +    // perform any authentication.
  1.1319 +    if (!this.allowAllUsers) {
  1.1320 +      // Enforce authentication.
  1.1321 +      if (!req.hasHeader("authorization")) {
  1.1322 +        this.respond(req, resp, 401, "Authorization Required", "{}", {
  1.1323 +          "WWW-Authenticate": 'Basic realm="secret"'
  1.1324 +        });
  1.1325 +        return;
  1.1326 +      }
  1.1327 +
  1.1328 +      let ensureUserExists = function ensureUserExists(username) {
  1.1329 +        if (this.userExists(username)) {
  1.1330 +          return;
  1.1331 +        }
  1.1332 +
  1.1333 +        this._log.info("StorageServer: Unknown user: " + username);
  1.1334 +        throw HTTP_401;
  1.1335 +      }.bind(this);
  1.1336 +
  1.1337 +      let auth = req.getHeader("authorization");
  1.1338 +      this._log.debug("Authorization: " + auth);
  1.1339 +
  1.1340 +      if (auth.indexOf("Basic ") == 0) {
  1.1341 +        let decoded = CommonUtils.safeAtoB(auth.substr(6));
  1.1342 +        this._log.debug("Decoded Basic Auth: " + decoded);
  1.1343 +        let [user, password] = decoded.split(":", 2);
  1.1344 +
  1.1345 +        if (!password) {
  1.1346 +          this._log.debug("Malformed HTTP Basic Authorization header: " + auth);
  1.1347 +          throw HTTP_400;
  1.1348 +        }
  1.1349 +
  1.1350 +        this._log.debug("Got HTTP Basic auth for user: " + user);
  1.1351 +        ensureUserExists(user);
  1.1352 +        username = user;
  1.1353 +
  1.1354 +        if (this.users[user].password != password) {
  1.1355 +          this._log.debug("StorageServer: Provided password is not correct.");
  1.1356 +          throw HTTP_401;
  1.1357 +        }
  1.1358 +      // TODO support token auth.
  1.1359 +      } else {
  1.1360 +        this._log.debug("Unsupported HTTP authorization type: " + auth);
  1.1361 +        throw HTTP_500;
  1.1362 +      }
  1.1363 +    // All users mode.
  1.1364 +    } else {
  1.1365 +      // Auto create user with dummy password.
  1.1366 +      if (!this.userExists(userPath)) {
  1.1367 +        this.registerUser(userPath, "DUMMY-PASSWORD-*&%#");
  1.1368 +      }
  1.1369 +
  1.1370 +      username = userPath;
  1.1371 +    }
  1.1372 +
  1.1373 +    // Hand off to the appropriate handler for this path component.
  1.1374 +    if (first in this.toplevelHandlers) {
  1.1375 +      let handler = this.toplevelHandlers[first];
  1.1376 +      try {
  1.1377 +        return handler.call(this, handler, req, resp, version, username, rest);
  1.1378 +      } catch (ex) {
  1.1379 +        this._log.warn("Got exception during request: " +
  1.1380 +                       CommonUtils.exceptionStr(ex));
  1.1381 +        throw ex;
  1.1382 +      }
  1.1383 +    }
  1.1384 +    this._log.debug("StorageServer: Unknown top-level " + first);
  1.1385 +    throw HTTP_404;
  1.1386 +  },
  1.1387 +
  1.1388 +  /**
  1.1389 +   * Collection of the handler methods we use for top-level path components.
  1.1390 +   */
  1.1391 +  toplevelHandlers: {
  1.1392 +    "storage": function handleStorage(handler, req, resp, version, username,
  1.1393 +                                      rest) {
  1.1394 +      let respond = this.respond.bind(this, req, resp);
  1.1395 +      if (!rest || !rest.length) {
  1.1396 +        this._log.debug("StorageServer: top-level storage " +
  1.1397 +                        req.method + " request.");
  1.1398 +
  1.1399 +        if (req.method != "DELETE") {
  1.1400 +          respond(405, "Method Not Allowed", null, {"Allow": "DELETE"});
  1.1401 +          return;
  1.1402 +        }
  1.1403 +
  1.1404 +        this.user(username).deleteCollections();
  1.1405 +
  1.1406 +        respond(204, "No Content");
  1.1407 +        return;
  1.1408 +      }
  1.1409 +
  1.1410 +      let match = this.storageRE.exec(rest);
  1.1411 +      if (!match) {
  1.1412 +        this._log.warn("StorageServer: Unknown storage operation " + rest);
  1.1413 +        throw HTTP_404;
  1.1414 +      }
  1.1415 +      let [all, collection, bsoID] = match;
  1.1416 +      let coll = this.getCollection(username, collection);
  1.1417 +      let collectionExisted = !!coll;
  1.1418 +
  1.1419 +      switch (req.method) {
  1.1420 +        case "GET":
  1.1421 +          // Tried to GET on a collection that doesn't exist.
  1.1422 +          if (!coll) {
  1.1423 +            respond(404, "Not Found");
  1.1424 +            return;
  1.1425 +          }
  1.1426 +
  1.1427 +          // No BSO URL parameter goes to collection handler.
  1.1428 +          if (!bsoID) {
  1.1429 +            return coll.collectionHandler(req, resp);
  1.1430 +          }
  1.1431 +
  1.1432 +          // Handle non-existent BSO.
  1.1433 +          let bso = coll.bso(bsoID);
  1.1434 +          if (!bso) {
  1.1435 +            respond(404, "Not Found");
  1.1436 +            return;
  1.1437 +          }
  1.1438 +
  1.1439 +          // Proxy to BSO handler.
  1.1440 +          return bso.getHandler(req, resp);
  1.1441 +
  1.1442 +        case "DELETE":
  1.1443 +          // Collection doesn't exist.
  1.1444 +          if (!coll) {
  1.1445 +            respond(404, "Not Found");
  1.1446 +            return;
  1.1447 +          }
  1.1448 +
  1.1449 +          // Deleting a specific BSO.
  1.1450 +          if (bsoID) {
  1.1451 +            let bso = coll.bso(bsoID);
  1.1452 +
  1.1453 +            // BSO does not exist on the server. Nothing to do.
  1.1454 +            if (!bso) {
  1.1455 +              respond(404, "Not Found");
  1.1456 +              return;
  1.1457 +            }
  1.1458 +
  1.1459 +            if (req.hasHeader("x-if-unmodified-since")) {
  1.1460 +              let modified = parseInt(req.getHeader("x-if-unmodified-since"));
  1.1461 +              CommonUtils.ensureMillisecondsTimestamp(modified);
  1.1462 +
  1.1463 +              if (bso.modified > modified) {
  1.1464 +                respond(412, "Precondition Failed");
  1.1465 +                return;
  1.1466 +              }
  1.1467 +            }
  1.1468 +
  1.1469 +            bso.delete();
  1.1470 +            coll.timestamp = req.timestamp;
  1.1471 +            this.callback.onItemDeleted(username, collection, bsoID);
  1.1472 +            respond(204, "No Content");
  1.1473 +            return;
  1.1474 +          }
  1.1475 +
  1.1476 +          // Proxy to collection handler.
  1.1477 +          coll.collectionHandler(req, resp);
  1.1478 +
  1.1479 +          // Spot if this is a DELETE for some IDs, and don't blow away the
  1.1480 +          // whole collection!
  1.1481 +          //
  1.1482 +          // We already handled deleting the BSOs by invoking the deleted
  1.1483 +          // collection's handler. However, in the case of
  1.1484 +          //
  1.1485 +          //   DELETE storage/foobar
  1.1486 +          //
  1.1487 +          // we also need to remove foobar from the collections map. This
  1.1488 +          // clause tries to differentiate the above request from
  1.1489 +          //
  1.1490 +          //  DELETE storage/foobar?ids=foo,baz
  1.1491 +          //
  1.1492 +          // and do the right thing.
  1.1493 +          // TODO: less hacky method.
  1.1494 +          if (-1 == req.queryString.indexOf("ids=")) {
  1.1495 +            // When you delete the entire collection, we drop it.
  1.1496 +            this._log.debug("Deleting entire collection.");
  1.1497 +            delete this.users[username].collections[collection];
  1.1498 +            this.callback.onCollectionDeleted(username, collection);
  1.1499 +          }
  1.1500 +
  1.1501 +          // Notify of item deletion.
  1.1502 +          let deleted = resp.deleted || [];
  1.1503 +          for (let i = 0; i < deleted.length; ++i) {
  1.1504 +            this.callback.onItemDeleted(username, collection, deleted[i]);
  1.1505 +          }
  1.1506 +          return;
  1.1507 +
  1.1508 +        case "POST":
  1.1509 +        case "PUT":
  1.1510 +          // Auto-create collection if it doesn't exist.
  1.1511 +          if (!coll) {
  1.1512 +            coll = this.createCollection(username, collection);
  1.1513 +          }
  1.1514 +
  1.1515 +          try {
  1.1516 +            if (bsoID) {
  1.1517 +              let bso = coll.bso(bsoID);
  1.1518 +              if (!bso) {
  1.1519 +                this._log.trace("StorageServer: creating BSO " + collection +
  1.1520 +                                "/" + bsoID);
  1.1521 +                try {
  1.1522 +                  bso = coll.insert(bsoID);
  1.1523 +                } catch (ex) {
  1.1524 +                  return sendMozSvcError(req, resp, "8");
  1.1525 +                }
  1.1526 +              }
  1.1527 +
  1.1528 +              bso.putHandler(req, resp);
  1.1529 +
  1.1530 +              coll.timestamp = req.timestamp;
  1.1531 +              return resp;
  1.1532 +            }
  1.1533 +
  1.1534 +            return coll.collectionHandler(req, resp);
  1.1535 +          } catch (ex) {
  1.1536 +            if (ex instanceof HttpError) {
  1.1537 +              if (!collectionExisted) {
  1.1538 +                this.deleteCollection(username, collection);
  1.1539 +              }
  1.1540 +            }
  1.1541 +
  1.1542 +            throw ex;
  1.1543 +          }
  1.1544 +
  1.1545 +        default:
  1.1546 +          throw new Error("Request method " + req.method + " not implemented.");
  1.1547 +      }
  1.1548 +    },
  1.1549 +
  1.1550 +    "info": function handleInfo(handler, req, resp, version, username, rest) {
  1.1551 +      switch (rest) {
  1.1552 +        case "collections":
  1.1553 +          return this.handleInfoCollections(req, resp, username);
  1.1554 +
  1.1555 +        case "collection_counts":
  1.1556 +          return this.handleInfoCounts(req, resp, username);
  1.1557 +
  1.1558 +        case "collection_usage":
  1.1559 +          return this.handleInfoUsage(req, resp, username);
  1.1560 +
  1.1561 +        case "quota":
  1.1562 +          return this.handleInfoQuota(req, resp, username);
  1.1563 +
  1.1564 +        default:
  1.1565 +          this._log.warn("StorageServer: Unknown info operation " + rest);
  1.1566 +          throw HTTP_404;
  1.1567 +      }
  1.1568 +    }
  1.1569 +  },
  1.1570 +
  1.1571 +  handleInfoConditional: function handleInfoConditional(request, response,
  1.1572 +                                                        user) {
  1.1573 +    if (!request.hasHeader("x-if-modified-since")) {
  1.1574 +      return false;
  1.1575 +    }
  1.1576 +
  1.1577 +    let requestModified = request.getHeader("x-if-modified-since");
  1.1578 +    requestModified = parseInt(requestModified, 10);
  1.1579 +
  1.1580 +    let serverModified = this.newestCollectionTimestamp(user);
  1.1581 +
  1.1582 +    this._log.info("Server mtime: " + serverModified + "; Client modified: " +
  1.1583 +                   requestModified);
  1.1584 +    if (serverModified > requestModified) {
  1.1585 +      return false;
  1.1586 +    }
  1.1587 +
  1.1588 +    this.respond(request, response, 304, "Not Modified", null, {
  1.1589 +      "X-Last-Modified": "" + serverModified
  1.1590 +    });
  1.1591 +
  1.1592 +    return true;
  1.1593 +  },
  1.1594 +
  1.1595 +  handleInfoCollections: function handleInfoCollections(request, response,
  1.1596 +                                                        user) {
  1.1597 +    if (this.handleInfoConditional(request, response, user)) {
  1.1598 +      return;
  1.1599 +    }
  1.1600 +
  1.1601 +    let info = this.infoCollections(user);
  1.1602 +    let body = JSON.stringify(info);
  1.1603 +    this.respond(request, response, 200, "OK", body, {
  1.1604 +      "Content-Type":    "application/json",
  1.1605 +      "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
  1.1606 +    });
  1.1607 +  },
  1.1608 +
  1.1609 +  handleInfoCounts: function handleInfoCounts(request, response, user) {
  1.1610 +    if (this.handleInfoConditional(request, response, user)) {
  1.1611 +      return;
  1.1612 +    }
  1.1613 +
  1.1614 +    let counts = this.infoCounts(user);
  1.1615 +    let body = JSON.stringify(counts);
  1.1616 +
  1.1617 +    this.respond(request, response, 200, "OK", body, {
  1.1618 +      "Content-Type":    "application/json",
  1.1619 +      "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
  1.1620 +    });
  1.1621 +  },
  1.1622 +
  1.1623 +  handleInfoUsage: function handleInfoUsage(request, response, user) {
  1.1624 +    if (this.handleInfoConditional(request, response, user)) {
  1.1625 +      return;
  1.1626 +    }
  1.1627 +
  1.1628 +    let body = JSON.stringify(this.infoUsage(user));
  1.1629 +    this.respond(request, response, 200, "OK", body, {
  1.1630 +      "Content-Type":    "application/json",
  1.1631 +      "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
  1.1632 +    });
  1.1633 +  },
  1.1634 +
  1.1635 +  handleInfoQuota: function handleInfoQuota(request, response, user) {
  1.1636 +    if (this.handleInfoConditional(request, response, user)) {
  1.1637 +      return;
  1.1638 +    }
  1.1639 +
  1.1640 +    let body = JSON.stringify(this.infoQuota(user));
  1.1641 +    this.respond(request, response, 200, "OK", body, {
  1.1642 +      "Content-Type":    "application/json",
  1.1643 +      "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
  1.1644 +    });
  1.1645 +  },
  1.1646 +};
  1.1647 +
  1.1648 +/**
  1.1649 + * Helper to create a storage server for a set of users.
  1.1650 + *
  1.1651 + * Each user is specified by a map of username to password.
  1.1652 + */
  1.1653 +this.storageServerForUsers =
  1.1654 + function storageServerForUsers(users, contents, callback) {
  1.1655 +  let server = new StorageServer(callback);
  1.1656 +  for (let [user, pass] in Iterator(users)) {
  1.1657 +    server.registerUser(user, pass);
  1.1658 +    server.createContents(user, contents);
  1.1659 +  }
  1.1660 +  server.start();
  1.1661 +  return server;
  1.1662 +}

mercurial