services/common/modules-testing/storageserver.js

Wed, 31 Dec 2014 06:09:35 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Wed, 31 Dec 2014 06:09:35 +0100
changeset 0
6474c204b198
permissions
-rw-r--r--

Cloned upstream origin tor-browser at tor-browser-31.3.0esr-4.5-1-build1
revision ID fc1c9ff7c1b2defdbc039f12214767608f46423f for hacking purpose.

     1 /* This Source Code Form is subject to the terms of the Mozilla Public
     2  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
     3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
     5 /**
     6  * This file contains an implementation of the Storage Server in JavaScript.
     7  *
     8  * The server should not be used for any production purposes.
     9  */
    11 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
    13 this.EXPORTED_SYMBOLS = [
    14   "ServerBSO",
    15   "StorageServerCallback",
    16   "StorageServerCollection",
    17   "StorageServer",
    18   "storageServerForUsers",
    19 ];
    21 Cu.import("resource://testing-common/httpd.js");
    22 Cu.import("resource://services-common/async.js");
    23 Cu.import("resource://gre/modules/Log.jsm");
    24 Cu.import("resource://services-common/utils.js");
    26 const STORAGE_HTTP_LOGGER = "Services.Common.Test.Server";
    27 const STORAGE_API_VERSION = "2.0";
    29 // Use the same method that record.js does, which mirrors the server.
    30 function new_timestamp() {
    31   return Math.round(Date.now());
    32 }
    34 function isInteger(s) {
    35   let re = /^[0-9]+$/;
    36   return re.test(s);
    37 }
    39 function writeHttpBody(response, body) {
    40   if (!body) {
    41     return;
    42   }
    44   response.bodyOutputStream.write(body, body.length);
    45 }
    47 function sendMozSvcError(request, response, code) {
    48   response.setStatusLine(request.httpVersion, 400, "Bad Request");
    49   response.setHeader("Content-Type", "text/plain", false);
    50   response.bodyOutputStream.write(code, code.length);
    51 }
    53 /**
    54  * Represent a BSO on the server.
    55  *
    56  * A BSO is constructed from an ID, content, and a modified time.
    57  *
    58  * @param id
    59  *        (string) ID of the BSO being created.
    60  * @param payload
    61  *        (strong|object) Payload for the BSO. Should ideally be a string. If
    62  *        an object is passed, it will be fed into JSON.stringify and that
    63  *        output will be set as the payload.
    64  * @param modified
    65  *        (number) Milliseconds since UNIX epoch that the BSO was last
    66  *        modified. If not defined or null, the current time will be used.
    67  */
    68 this.ServerBSO = function ServerBSO(id, payload, modified) {
    69   if (!id) {
    70     throw new Error("No ID for ServerBSO!");
    71   }
    73   if (!id.match(/^[a-zA-Z0-9_-]{1,64}$/)) {
    74     throw new Error("BSO ID is invalid: " + id);
    75   }
    77   this._log = Log.repository.getLogger(STORAGE_HTTP_LOGGER);
    79   this.id = id;
    80   if (!payload) {
    81     return;
    82   }
    84   CommonUtils.ensureMillisecondsTimestamp(modified);
    86   if (typeof payload == "object") {
    87     payload = JSON.stringify(payload);
    88   }
    90   this.payload = payload;
    91   this.modified = modified || new_timestamp();
    92 }
    93 ServerBSO.prototype = {
    94   FIELDS: [
    95     "id",
    96     "modified",
    97     "payload",
    98     "ttl",
    99     "sortindex",
   100   ],
   102   toJSON: function toJSON() {
   103     let obj = {};
   105     for each (let key in this.FIELDS) {
   106       if (this[key] !== undefined) {
   107         obj[key] = this[key];
   108       }
   109     }
   111     return obj;
   112   },
   114   delete: function delete_() {
   115     this.deleted = true;
   117     delete this.payload;
   118     delete this.modified;
   119   },
   121   /**
   122    * Handler for GET requests for this BSO.
   123    */
   124   getHandler: function getHandler(request, response) {
   125     let code = 200;
   126     let status = "OK";
   127     let body;
   129     function sendResponse() {
   130       response.setStatusLine(request.httpVersion, code, status);
   131       writeHttpBody(response, body);
   132     }
   134     if (request.hasHeader("x-if-modified-since")) {
   135       let headerModified = parseInt(request.getHeader("x-if-modified-since"),
   136                                     10);
   137       CommonUtils.ensureMillisecondsTimestamp(headerModified);
   139       if (headerModified >= this.modified) {
   140         code = 304;
   141         status = "Not Modified";
   143         sendResponse();
   144         return;
   145       }
   146     } else if (request.hasHeader("x-if-unmodified-since")) {
   147       let requestModified = parseInt(request.getHeader("x-if-unmodified-since"),
   148                                      10);
   149       let serverModified = this.modified;
   151       if (serverModified > requestModified) {
   152         code = 412;
   153         status = "Precondition Failed";
   154         sendResponse();
   155         return;
   156       }
   157     }
   159     if (!this.deleted) {
   160       body = JSON.stringify(this.toJSON());
   161       response.setHeader("Content-Type", "application/json", false);
   162       response.setHeader("X-Last-Modified", "" + this.modified, false);
   163     } else {
   164       code = 404;
   165       status = "Not Found";
   166     }
   168     sendResponse();
   169   },
   171   /**
   172    * Handler for PUT requests for this BSO.
   173    */
   174   putHandler: function putHandler(request, response) {
   175     if (request.hasHeader("Content-Type")) {
   176       let ct = request.getHeader("Content-Type");
   177       if (ct != "application/json") {
   178         throw HTTP_415;
   179       }
   180     }
   182     let input = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
   183     let parsed;
   184     try {
   185       parsed = JSON.parse(input);
   186     } catch (ex) {
   187       return sendMozSvcError(request, response, "8");
   188     }
   190     if (typeof(parsed) != "object") {
   191       return sendMozSvcError(request, response, "8");
   192     }
   194     // Don't update if a conditional request fails preconditions.
   195     if (request.hasHeader("x-if-unmodified-since")) {
   196       let reqModified = parseInt(request.getHeader("x-if-unmodified-since"));
   198       if (reqModified < this.modified) {
   199         response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
   200         return;
   201       }
   202     }
   204     let code, status;
   205     if (this.payload) {
   206       code = 204;
   207       status = "No Content";
   208     } else {
   209       code = 201;
   210       status = "Created";
   211     }
   213     // Alert when we see unrecognized fields.
   214     for (let [key, value] in Iterator(parsed)) {
   215       switch (key) {
   216         case "payload":
   217           if (typeof(value) != "string") {
   218             sendMozSvcError(request, response, "8");
   219             return true;
   220           }
   222           this.payload = value;
   223           break;
   225         case "ttl":
   226           if (!isInteger(value)) {
   227             sendMozSvcError(request, response, "8");
   228             return true;
   229           }
   230           this.ttl = parseInt(value, 10);
   231           break;
   233         case "sortindex":
   234           if (!isInteger(value) || value.length > 9) {
   235             sendMozSvcError(request, response, "8");
   236             return true;
   237           }
   238           this.sortindex = parseInt(value, 10);
   239           break;
   241         case "id":
   242           break;
   244         default:
   245           this._log.warn("Unexpected field in BSO record: " + key);
   246           sendMozSvcError(request, response, "8");
   247           return true;
   248       }
   249     }
   251     this.modified = request.timestamp;
   252     this.deleted = false;
   253     response.setHeader("X-Last-Modified", "" + this.modified, false);
   255     response.setStatusLine(request.httpVersion, code, status);
   256   },
   257 };
   259 /**
   260  * Represent a collection on the server.
   261  *
   262  * The '_bsos' attribute is a mapping of id -> ServerBSO objects.
   263  *
   264  * Note that if you want these records to be accessible individually,
   265  * you need to register their handlers with the server separately, or use a
   266  * containing HTTP server that will do so on your behalf.
   267  *
   268  * @param bsos
   269  *        An object mapping BSO IDs to ServerBSOs.
   270  * @param acceptNew
   271  *        If true, POSTs to this collection URI will result in new BSOs being
   272  *        created and wired in on the fly.
   273  * @param timestamp
   274  *        An optional timestamp value to initialize the modified time of the
   275  *        collection. This should be in the format returned by new_timestamp().
   276  */
   277 this.StorageServerCollection =
   278  function StorageServerCollection(bsos, acceptNew, timestamp=new_timestamp()) {
   279   this._bsos = bsos || {};
   280   this.acceptNew = acceptNew || false;
   282   /*
   283    * Track modified timestamp.
   284    * We can't just use the timestamps of contained BSOs: an empty collection
   285    * has a modified time.
   286    */
   287   CommonUtils.ensureMillisecondsTimestamp(timestamp);
   288   this._timestamp = timestamp;
   290   this._log = Log.repository.getLogger(STORAGE_HTTP_LOGGER);
   291 }
   292 StorageServerCollection.prototype = {
   293   BATCH_MAX_COUNT: 100,         // # of records.
   294   BATCH_MAX_SIZE: 1024 * 1024,  // # bytes.
   296   _timestamp: null,
   298   get timestamp() {
   299     return this._timestamp;
   300   },
   302   set timestamp(timestamp) {
   303     CommonUtils.ensureMillisecondsTimestamp(timestamp);
   304     this._timestamp = timestamp;
   305   },
   307   get totalPayloadSize() {
   308     let size = 0;
   309     for each (let bso in this.bsos()) {
   310       size += bso.payload.length;
   311     }
   313     return size;
   314   },
   316   /**
   317    * Convenience accessor for our BSO keys.
   318    * Excludes deleted items, of course.
   319    *
   320    * @param filter
   321    *        A predicate function (applied to the ID and BSO) which dictates
   322    *        whether to include the BSO's ID in the output.
   323    *
   324    * @return an array of IDs.
   325    */
   326   keys: function keys(filter) {
   327     return [id for ([id, bso] in Iterator(this._bsos))
   328                if (!bso.deleted && (!filter || filter(id, bso)))];
   329   },
   331   /**
   332    * Convenience method to get an array of BSOs.
   333    * Optionally provide a filter function.
   334    *
   335    * @param filter
   336    *        A predicate function, applied to the BSO, which dictates whether to
   337    *        include the BSO in the output.
   338    *
   339    * @return an array of ServerBSOs.
   340    */
   341   bsos: function bsos(filter) {
   342     let os = [bso for ([id, bso] in Iterator(this._bsos))
   343               if (!bso.deleted)];
   345     if (!filter) {
   346       return os;
   347     }
   349     return os.filter(filter);
   350   },
   352   /**
   353    * Obtain a BSO by ID.
   354    */
   355   bso: function bso(id) {
   356     return this._bsos[id];
   357   },
   359   /**
   360    * Obtain the payload of a specific BSO.
   361    *
   362    * Raises if the specified BSO does not exist.
   363    */
   364   payload: function payload(id) {
   365     return this.bso(id).payload;
   366   },
   368   /**
   369    * Insert the provided BSO under its ID.
   370    *
   371    * @return the provided BSO.
   372    */
   373   insertBSO: function insertBSO(bso) {
   374     return this._bsos[bso.id] = bso;
   375   },
   377   /**
   378    * Insert the provided payload as part of a new ServerBSO with the provided
   379    * ID.
   380    *
   381    * @param id
   382    *        The GUID for the BSO.
   383    * @param payload
   384    *        The payload, as provided to the ServerBSO constructor.
   385    * @param modified
   386    *        An optional modified time for the ServerBSO. If not specified, the
   387    *        current time will be used.
   388    *
   389    * @return the inserted BSO.
   390    */
   391   insert: function insert(id, payload, modified) {
   392     return this.insertBSO(new ServerBSO(id, payload, modified));
   393   },
   395   /**
   396    * Removes an object entirely from the collection.
   397    *
   398    * @param id
   399    *        (string) ID to remove.
   400    */
   401   remove: function remove(id) {
   402     delete this._bsos[id];
   403   },
   405   _inResultSet: function _inResultSet(bso, options) {
   406     if (!bso.payload) {
   407       return false;
   408     }
   410     if (options.ids) {
   411       if (options.ids.indexOf(bso.id) == -1) {
   412         return false;
   413       }
   414     }
   416     if (options.newer) {
   417       if (bso.modified <= options.newer) {
   418         return false;
   419       }
   420     }
   422     if (options.older) {
   423       if (bso.modified >= options.older) {
   424         return false;
   425       }
   426     }
   428     return true;
   429   },
   431   count: function count(options) {
   432     options = options || {};
   433     let c = 0;
   434     for (let [id, bso] in Iterator(this._bsos)) {
   435       if (bso.modified && this._inResultSet(bso, options)) {
   436         c++;
   437       }
   438     }
   439     return c;
   440   },
   442   get: function get(options) {
   443     let data = [];
   444     for each (let bso in this._bsos) {
   445       if (!bso.modified) {
   446         continue;
   447       }
   449       if (!this._inResultSet(bso, options)) {
   450         continue;
   451       }
   453       data.push(bso);
   454     }
   456     if (options.sort) {
   457       if (options.sort == "oldest") {
   458         data.sort(function sortOldest(a, b) {
   459           if (a.modified == b.modified) {
   460             return 0;
   461           }
   463           return a.modified < b.modified ? -1 : 1;
   464         });
   465       } else if (options.sort == "newest") {
   466         data.sort(function sortNewest(a, b) {
   467           if (a.modified == b.modified) {
   468             return 0;
   469           }
   471           return a.modified > b.modified ? -1 : 1;
   472         });
   473       } else if (options.sort == "index") {
   474         data.sort(function sortIndex(a, b) {
   475           if (a.sortindex == b.sortindex) {
   476             return 0;
   477           }
   479           if (a.sortindex !== undefined && b.sortindex == undefined) {
   480             return 1;
   481           }
   483           if (a.sortindex === undefined && b.sortindex !== undefined) {
   484             return -1;
   485           }
   487           return a.sortindex > b.sortindex ? -1 : 1;
   488         });
   489       }
   490     }
   492     if (options.limit) {
   493       data = data.slice(0, options.limit);
   494     }
   496     return data;
   497   },
   499   post: function post(input, timestamp) {
   500     let success = [];
   501     let failed = {};
   502     let count = 0;
   503     let size = 0;
   505     // This will count records where we have an existing ServerBSO
   506     // registered with us as successful and all other records as failed.
   507     for each (let record in input) {
   508       count += 1;
   509       if (count > this.BATCH_MAX_COUNT) {
   510         failed[record.id] = "Max record count exceeded.";
   511         continue;
   512       }
   514       if (typeof(record.payload) != "string") {
   515         failed[record.id] = "Payload is not a string!";
   516         continue;
   517       }
   519       size += record.payload.length;
   520       if (size > this.BATCH_MAX_SIZE) {
   521         failed[record.id] = "Payload max size exceeded!";
   522         continue;
   523       }
   525       if (record.sortindex) {
   526         if (!isInteger(record.sortindex)) {
   527           failed[record.id] = "sortindex is not an integer.";
   528           continue;
   529         }
   531         if (record.sortindex.length > 9) {
   532           failed[record.id] = "sortindex is too long.";
   533           continue;
   534         }
   535       }
   537       if ("ttl" in record) {
   538         if (!isInteger(record.ttl)) {
   539           failed[record.id] = "ttl is not an integer.";
   540           continue;
   541         }
   542       }
   544       try {
   545         let bso = this.bso(record.id);
   546         if (!bso && this.acceptNew) {
   547           this._log.debug("Creating BSO " + JSON.stringify(record.id) +
   548                           " on the fly.");
   549           bso = new ServerBSO(record.id);
   550           this.insertBSO(bso);
   551         }
   552         if (bso) {
   553           bso.payload = record.payload;
   554           bso.modified = timestamp;
   555           bso.deleted = false;
   556           success.push(record.id);
   558           if (record.sortindex) {
   559             bso.sortindex = parseInt(record.sortindex, 10);
   560           }
   562         } else {
   563           failed[record.id] = "no bso configured";
   564         }
   565       } catch (ex) {
   566         this._log.info("Exception when processing BSO: " +
   567                        CommonUtils.exceptionStr(ex));
   568         failed[record.id] = "Exception when processing.";
   569       }
   570     }
   571     return {success: success, failed: failed};
   572   },
   574   delete: function delete_(options) {
   575     options = options || {};
   577     // Protocol 2.0 only allows the "ids" query string argument.
   578     let keys = Object.keys(options).filter(function(k) {
   579       return k != "ids";
   580     });
   581     if (keys.length) {
   582       this._log.warn("Invalid query string parameter to collection delete: " +
   583                      keys.join(", "));
   584       throw new Error("Malformed client request.");
   585     }
   587     if (options.ids && options.ids.length > this.BATCH_MAX_COUNT) {
   588       throw HTTP_400;
   589     }
   591     let deleted = [];
   592     for (let [id, bso] in Iterator(this._bsos)) {
   593       if (this._inResultSet(bso, options)) {
   594         this._log.debug("Deleting " + JSON.stringify(bso));
   595         deleted.push(bso.id);
   596         bso.delete();
   597       }
   598     }
   599     return deleted;
   600   },
   602   parseOptions: function parseOptions(request) {
   603     let options = {};
   605     for each (let chunk in request.queryString.split("&")) {
   606       if (!chunk) {
   607         continue;
   608       }
   609       chunk = chunk.split("=");
   610       let key = decodeURIComponent(chunk[0]);
   611       if (chunk.length == 1) {
   612         options[key] = "";
   613       } else {
   614         options[key] = decodeURIComponent(chunk[1]);
   615       }
   616     }
   618     if (options.ids) {
   619       options.ids = options.ids.split(",");
   620     }
   622     if (options.newer) {
   623       if (!isInteger(options.newer)) {
   624         throw HTTP_400;
   625       }
   627       CommonUtils.ensureMillisecondsTimestamp(options.newer);
   628       options.newer = parseInt(options.newer, 10);
   629     }
   631     if (options.older) {
   632       if (!isInteger(options.older)) {
   633         throw HTTP_400;
   634       }
   636       CommonUtils.ensureMillisecondsTimestamp(options.older);
   637       options.older = parseInt(options.older, 10);
   638     }
   640     if (options.limit) {
   641       if (!isInteger(options.limit)) {
   642         throw HTTP_400;
   643       }
   645       options.limit = parseInt(options.limit, 10);
   646     }
   648     return options;
   649   },
   651   getHandler: function getHandler(request, response) {
   652     let options = this.parseOptions(request);
   653     let data = this.get(options);
   655     if (request.hasHeader("x-if-modified-since")) {
   656       let requestModified = parseInt(request.getHeader("x-if-modified-since"),
   657                                      10);
   658       let newestBSO = 0;
   659       for each (let bso in data) {
   660         if (bso.modified > newestBSO) {
   661           newestBSO = bso.modified;
   662         }
   663       }
   665       if (requestModified >= newestBSO) {
   666         response.setHeader("X-Last-Modified", "" + newestBSO);
   667         response.setStatusLine(request.httpVersion, 304, "Not Modified");
   668         return;
   669       }
   670     } else if (request.hasHeader("x-if-unmodified-since")) {
   671       let requestModified = parseInt(request.getHeader("x-if-unmodified-since"),
   672                                      10);
   673       let serverModified = this.timestamp;
   675       if (serverModified > requestModified) {
   676         response.setHeader("X-Last-Modified", "" + serverModified);
   677         response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
   678         return;
   679       }
   680     }
   682     if (options.full) {
   683       data = data.map(function map(bso) {
   684         return bso.toJSON();
   685       });
   686     } else {
   687       data = data.map(function map(bso) {
   688         return bso.id;
   689       });
   690     }
   692     // application/json is default media type.
   693     let newlines = false;
   694     if (request.hasHeader("accept")) {
   695       let accept = request.getHeader("accept");
   696       if (accept == "application/newlines") {
   697         newlines = true;
   698       } else if (accept != "application/json") {
   699         throw HTTP_406;
   700       }
   701     }
   703     let body;
   704     if (newlines) {
   705       response.setHeader("Content-Type", "application/newlines", false);
   706       let normalized = data.map(function map(d) {
   707         return JSON.stringify(d);
   708       });
   710       body = normalized.join("\n") + "\n";
   711     } else {
   712       response.setHeader("Content-Type", "application/json", false);
   713       body = JSON.stringify({items: data});
   714     }
   716     this._log.info("Records: " + data.length);
   717     response.setHeader("X-Num-Records", "" + data.length, false);
   718     response.setHeader("X-Last-Modified", "" + this.timestamp, false);
   719     response.setStatusLine(request.httpVersion, 200, "OK");
   720     response.bodyOutputStream.write(body, body.length);
   721   },
   723   postHandler: function postHandler(request, response) {
   724     let options = this.parseOptions(request);
   726     if (!request.hasHeader("content-type")) {
   727       this._log.info("No Content-Type request header!");
   728       throw HTTP_400;
   729     }
   731     let inputStream = request.bodyInputStream;
   732     let inputBody = CommonUtils.readBytesFromInputStream(inputStream);
   733     let input = [];
   735     let inputMediaType = request.getHeader("content-type");
   736     if (inputMediaType == "application/json") {
   737       try {
   738         input = JSON.parse(inputBody);
   739       } catch (ex) {
   740         this._log.info("JSON parse error on input body!");
   741         throw HTTP_400;
   742       }
   744       if (!Array.isArray(input)) {
   745         this._log.info("Input JSON type not an array!");
   746         return sendMozSvcError(request, response, "8");
   747       }
   748     } else if (inputMediaType == "application/newlines") {
   749       for each (let line in inputBody.split("\n")) {
   750         let record;
   751         try {
   752           record = JSON.parse(line);
   753         } catch (ex) {
   754           this._log.info("JSON parse error on line!");
   755           return sendMozSvcError(request, response, "8");
   756         }
   758         input.push(record);
   759       }
   760     } else {
   761       this._log.info("Unknown media type: " + inputMediaType);
   762       throw HTTP_415;
   763     }
   765     if (this._ensureUnmodifiedSince(request, response)) {
   766       return;
   767     }
   769     let res = this.post(input, request.timestamp);
   770     let body = JSON.stringify(res);
   771     response.setHeader("Content-Type", "application/json", false);
   772     this.timestamp = request.timestamp;
   773     response.setHeader("X-Last-Modified", "" + this.timestamp, false);
   775     response.setStatusLine(request.httpVersion, "200", "OK");
   776     response.bodyOutputStream.write(body, body.length);
   777   },
   779   deleteHandler: function deleteHandler(request, response) {
   780     this._log.debug("Invoking StorageServerCollection.DELETE.");
   782     let options = this.parseOptions(request);
   784     if (this._ensureUnmodifiedSince(request, response)) {
   785       return;
   786     }
   788     let deleted = this.delete(options);
   789     response.deleted = deleted;
   790     this.timestamp = request.timestamp;
   792     response.setStatusLine(request.httpVersion, 204, "No Content");
   793   },
   795   handler: function handler() {
   796     let self = this;
   798     return function(request, response) {
   799       switch(request.method) {
   800         case "GET":
   801           return self.getHandler(request, response);
   803         case "POST":
   804           return self.postHandler(request, response);
   806         case "DELETE":
   807           return self.deleteHandler(request, response);
   809       }
   811       request.setHeader("Allow", "GET,POST,DELETE");
   812       response.setStatusLine(request.httpVersion, 405, "Method Not Allowed");
   813     };
   814   },
   816   _ensureUnmodifiedSince: function _ensureUnmodifiedSince(request, response) {
   817     if (!request.hasHeader("x-if-unmodified-since")) {
   818       return false;
   819     }
   821     let requestModified = parseInt(request.getHeader("x-if-unmodified-since"),
   822                                    10);
   823     let serverModified = this.timestamp;
   825     this._log.debug("Request modified time: " + requestModified +
   826                     "; Server modified time: " + serverModified);
   827     if (serverModified <= requestModified) {
   828       return false;
   829     }
   831     this._log.info("Conditional request rejected because client time older " +
   832                    "than collection timestamp.");
   833     response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
   834     return true;
   835   },
   836 };
   839 //===========================================================================//
   840 // httpd.js-based Storage server.                                            //
   841 //===========================================================================//
   843 /**
   844  * In general, the preferred way of using StorageServer is to directly
   845  * introspect it. Callbacks are available for operations which are hard to
   846  * verify through introspection, such as deletions.
   847  *
   848  * One of the goals of this server is to provide enough hooks for test code to
   849  * find out what it needs without monkeypatching. Use this object as your
   850  * prototype, and override as appropriate.
   851  */
   852 this.StorageServerCallback = {
   853   onCollectionDeleted: function onCollectionDeleted(user, collection) {},
   854   onItemDeleted: function onItemDeleted(user, collection, bsoID) {},
   856   /**
   857    * Called at the top of every request.
   858    *
   859    * Allows the test to inspect the request. Hooks should be careful not to
   860    * modify or change state of the request or they may impact future processing.
   861    */
   862   onRequest: function onRequest(request) {},
   863 };
   865 /**
   866  * Construct a new test Storage server. Takes a callback object (e.g.,
   867  * StorageServerCallback) as input.
   868  */
   869 this.StorageServer = function StorageServer(callback) {
   870   this.callback     = callback || {__proto__: StorageServerCallback};
   871   this.server       = new HttpServer();
   872   this.started      = false;
   873   this.users        = {};
   874   this.requestCount = 0;
   875   this._log         = Log.repository.getLogger(STORAGE_HTTP_LOGGER);
   877   // Install our own default handler. This allows us to mess around with the
   878   // whole URL space.
   879   let handler = this.server._handler;
   880   handler._handleDefault = this.handleDefault.bind(this, handler);
   881 }
   882 StorageServer.prototype = {
   883   DEFAULT_QUOTA: 1024 * 1024, // # bytes.
   885   server: null,    // HttpServer.
   886   users:  null,    // Map of username => {collections, password}.
   888   /**
   889    * If true, the server will allow any arbitrary user to be used.
   890    *
   891    * No authentication will be performed. Whatever user is detected from the
   892    * URL or auth headers will be created (if needed) and used.
   893    */
   894   allowAllUsers: false,
   896   /**
   897    * Start the StorageServer's underlying HTTP server.
   898    *
   899    * @param port
   900    *        The numeric port on which to start. A falsy value implies to
   901    *        select any available port.
   902    * @param cb
   903    *        A callback function (of no arguments) which is invoked after
   904    *        startup.
   905    */
   906   start: function start(port, cb) {
   907     if (this.started) {
   908       this._log.warn("Warning: server already started on " + this.port);
   909       return;
   910     }
   911     if (!port) {
   912       port = -1;
   913     }
   914     this.port = port;
   916     try {
   917       this.server.start(this.port);
   918       this.port = this.server.identity.primaryPort;
   919       this.started = true;
   920       if (cb) {
   921         cb();
   922       }
   923     } catch (ex) {
   924       _("==========================================");
   925       _("Got exception starting Storage HTTP server on port " + this.port);
   926       _("Error: " + CommonUtils.exceptionStr(ex));
   927       _("Is there a process already listening on port " + this.port + "?");
   928       _("==========================================");
   929       do_throw(ex);
   930     }
   931   },
   933   /**
   934    * Start the server synchronously.
   935    *
   936    * @param port
   937    *        The numeric port on which to start. The default is to choose
   938    *        any available port.
   939    */
   940   startSynchronous: function startSynchronous(port=-1) {
   941     let cb = Async.makeSpinningCallback();
   942     this.start(port, cb);
   943     cb.wait();
   944   },
   946   /**
   947    * Stop the StorageServer's HTTP server.
   948    *
   949    * @param cb
   950    *        A callback function. Invoked after the server has been stopped.
   951    *
   952    */
   953   stop: function stop(cb) {
   954     if (!this.started) {
   955       this._log.warn("StorageServer: Warning: server not running. Can't stop " +
   956                      "me now!");
   957       return;
   958     }
   960     this.server.stop(cb);
   961     this.started = false;
   962   },
   964   serverTime: function serverTime() {
   965     return new_timestamp();
   966   },
   968   /**
   969    * Create a new user, complete with an empty set of collections.
   970    *
   971    * @param username
   972    *        The username to use. An Error will be thrown if a user by that name
   973    *        already exists.
   974    * @param password
   975    *        A password string.
   976    *
   977    * @return a user object, as would be returned by server.user(username).
   978    */
   979   registerUser: function registerUser(username, password) {
   980     if (username in this.users) {
   981       throw new Error("User already exists.");
   982     }
   984     if (!isFinite(parseInt(username))) {
   985       throw new Error("Usernames must be numeric: " + username);
   986     }
   988     this._log.info("Registering new user with server: " + username);
   989     this.users[username] = {
   990       password: password,
   991       collections: {},
   992       quota: this.DEFAULT_QUOTA,
   993     };
   994     return this.user(username);
   995   },
   997   userExists: function userExists(username) {
   998     return username in this.users;
   999   },
  1001   getCollection: function getCollection(username, collection) {
  1002     return this.users[username].collections[collection];
  1003   },
  1005   _insertCollection: function _insertCollection(collections, collection, bsos) {
  1006     let coll = new StorageServerCollection(bsos, true);
  1007     coll.collectionHandler = coll.handler();
  1008     collections[collection] = coll;
  1009     return coll;
  1010   },
  1012   createCollection: function createCollection(username, collection, bsos) {
  1013     if (!(username in this.users)) {
  1014       throw new Error("Unknown user.");
  1016     let collections = this.users[username].collections;
  1017     if (collection in collections) {
  1018       throw new Error("Collection already exists.");
  1020     return this._insertCollection(collections, collection, bsos);
  1021   },
  1023   deleteCollection: function deleteCollection(username, collection) {
  1024     if (!(username in this.users)) {
  1025       throw new Error("Unknown user.");
  1027     delete this.users[username].collections[collection];
  1028   },
  1030   /**
  1031    * Accept a map like the following:
  1032    * {
  1033    *   meta: {global: {version: 1, ...}},
  1034    *   crypto: {"keys": {}, foo: {bar: 2}},
  1035    *   bookmarks: {}
  1036    * }
  1037    * to cause collections and BSOs to be created.
  1038    * If a collection already exists, no error is raised.
  1039    * If a BSO already exists, it will be updated to the new contents.
  1040    */
  1041   createContents: function createContents(username, collections) {
  1042     if (!(username in this.users)) {
  1043       throw new Error("Unknown user.");
  1045     let userCollections = this.users[username].collections;
  1046     for (let [id, contents] in Iterator(collections)) {
  1047       let coll = userCollections[id] ||
  1048                  this._insertCollection(userCollections, id);
  1049       for (let [bsoID, payload] in Iterator(contents)) {
  1050         coll.insert(bsoID, payload);
  1053   },
  1055   /**
  1056    * Insert a BSO in an existing collection.
  1057    */
  1058   insertBSO: function insertBSO(username, collection, bso) {
  1059     if (!(username in this.users)) {
  1060       throw new Error("Unknown user.");
  1062     let userCollections = this.users[username].collections;
  1063     if (!(collection in userCollections)) {
  1064       throw new Error("Unknown collection.");
  1066     userCollections[collection].insertBSO(bso);
  1067     return bso;
  1068   },
  1070   /**
  1071    * Delete all of the collections for the named user.
  1073    * @param username
  1074    *        The name of the affected user.
  1075    */
  1076   deleteCollections: function deleteCollections(username) {
  1077     if (!(username in this.users)) {
  1078       throw new Error("Unknown user.");
  1080     let userCollections = this.users[username].collections;
  1081     for each (let [name, coll] in Iterator(userCollections)) {
  1082       this._log.trace("Bulk deleting " + name + " for " + username + "...");
  1083       coll.delete({});
  1085     this.users[username].collections = {};
  1086   },
  1088   getQuota: function getQuota(username) {
  1089     if (!(username in this.users)) {
  1090       throw new Error("Unknown user.");
  1093     return this.users[username].quota;
  1094   },
  1096   /**
  1097    * Obtain the newest timestamp of all collections for a user.
  1098    */
  1099   newestCollectionTimestamp: function newestCollectionTimestamp(username) {
  1100     let collections = this.users[username].collections;
  1101     let newest = 0;
  1102     for each (let collection in collections) {
  1103       if (collection.timestamp > newest) {
  1104         newest = collection.timestamp;
  1108     return newest;
  1109   },
  1111   /**
  1112    * Compute the object that is returned for an info/collections request.
  1113    */
  1114   infoCollections: function infoCollections(username) {
  1115     let responseObject = {};
  1116     let colls = this.users[username].collections;
  1117     for (let coll in colls) {
  1118       responseObject[coll] = colls[coll].timestamp;
  1120     this._log.trace("StorageServer: info/collections returning " +
  1121                     JSON.stringify(responseObject));
  1122     return responseObject;
  1123   },
  1125   infoCounts: function infoCounts(username) {
  1126     let data = {};
  1127     let collections = this.users[username].collections;
  1128     for (let [k, v] in Iterator(collections)) {
  1129       let count = v.count();
  1130       if (!count) {
  1131         continue;
  1134       data[k] = count;
  1137     return data;
  1138   },
  1140   infoUsage: function infoUsage(username) {
  1141     let data = {};
  1142     let collections = this.users[username].collections;
  1143     for (let [k, v] in Iterator(collections)) {
  1144       data[k] = v.totalPayloadSize;
  1147     return data;
  1148   },
  1150   infoQuota: function infoQuota(username) {
  1151     let total = 0;
  1152     for each (let value in this.infoUsage(username)) {
  1153       total += value;
  1156     return {
  1157       quota: this.getQuota(username),
  1158       usage: total
  1159     };
  1160   },
  1162   /**
  1163    * Simple accessor to allow collective binding and abbreviation of a bunch of
  1164    * methods. Yay!
  1165    * Use like this:
  1167    *   let u = server.user("john");
  1168    *   u.collection("bookmarks").bso("abcdefg").payload;  // Etc.
  1170    * @return a proxy for the user data stored in this server.
  1171    */
  1172   user: function user(username) {
  1173     let collection       = this.getCollection.bind(this, username);
  1174     let createCollection = this.createCollection.bind(this, username);
  1175     let createContents   = this.createContents.bind(this, username);
  1176     let modified         = function (collectionName) {
  1177       return collection(collectionName).timestamp;
  1179     let deleteCollections = this.deleteCollections.bind(this, username);
  1180     let quota             = this.getQuota.bind(this, username);
  1181     return {
  1182       collection:        collection,
  1183       createCollection:  createCollection,
  1184       createContents:    createContents,
  1185       deleteCollections: deleteCollections,
  1186       modified:          modified,
  1187       quota:             quota,
  1188     };
  1189   },
  1191   _pruneExpired: function _pruneExpired() {
  1192     let now = Date.now();
  1194     for each (let user in this.users) {
  1195       for each (let collection in user.collections) {
  1196         for each (let bso in collection.bsos()) {
  1197           // ttl === 0 is a special case, so we can't simply !ttl.
  1198           if (typeof(bso.ttl) != "number") {
  1199             continue;
  1202           let ttlDate = bso.modified + (bso.ttl * 1000);
  1203           if (ttlDate < now) {
  1204             this._log.info("Deleting BSO because TTL expired: " + bso.id);
  1205             bso.delete();
  1210   },
  1212   /*
  1213    * Regular expressions for splitting up Storage request paths.
  1214    * Storage URLs are of the form:
  1215    *   /$apipath/$version/$userid/$further
  1216    * where $further is usually:
  1217    *   storage/$collection/$bso
  1218    * or
  1219    *   storage/$collection
  1220    * or
  1221    *   info/$op
  1223    * We assume for the sake of simplicity that $apipath is empty.
  1225    * N.B., we don't follow any kind of username spec here, because as far as I
  1226    * can tell there isn't one. See Bug 689671. Instead we follow the Python
  1227    * server code.
  1229    * Path: [all, version, first, rest]
  1230    * Storage: [all, collection?, id?]
  1231    */
  1232   pathRE: /^\/([0-9]+(?:\.[0-9]+)?)(?:\/([0-9]+)\/([^\/]+)(?:\/(.+))?)?$/,
  1233   storageRE: /^([-_a-zA-Z0-9]+)(?:\/([-_a-zA-Z0-9]+)\/?)?$/,
  1235   defaultHeaders: {},
  1237   /**
  1238    * HTTP response utility.
  1239    */
  1240   respond: function respond(req, resp, code, status, body, headers, timestamp) {
  1241     this._log.info("Response: " + code + " " + status);
  1242     resp.setStatusLine(req.httpVersion, code, status);
  1243     for each (let [header, value] in Iterator(headers || this.defaultHeaders)) {
  1244       resp.setHeader(header, value, false);
  1247     if (timestamp) {
  1248       resp.setHeader("X-Timestamp", "" + timestamp, false);
  1251     if (body) {
  1252       resp.bodyOutputStream.write(body, body.length);
  1254   },
  1256   /**
  1257    * This is invoked by the HttpServer. `this` is bound to the StorageServer;
  1258    * `handler` is the HttpServer's handler.
  1260    * TODO: need to use the correct Storage API response codes and errors here.
  1261    */
  1262   handleDefault: function handleDefault(handler, req, resp) {
  1263     this.requestCount++;
  1264     let timestamp = new_timestamp();
  1265     try {
  1266       this._handleDefault(handler, req, resp, timestamp);
  1267     } catch (e) {
  1268       if (e instanceof HttpError) {
  1269         this.respond(req, resp, e.code, e.description, "", {}, timestamp);
  1270       } else {
  1271         this._log.warn(CommonUtils.exceptionStr(e));
  1272         throw e;
  1275   },
  1277   _handleDefault: function _handleDefault(handler, req, resp, timestamp) {
  1278     let path = req.path;
  1279     if (req.queryString.length) {
  1280       path += "?" + req.queryString;
  1283     this._log.debug("StorageServer: Handling request: " + req.method + " " +
  1284                     path);
  1286     if (this.callback.onRequest) {
  1287       this.callback.onRequest(req);
  1290     // Prune expired records for all users at top of request. This is the
  1291     // easiest way to process TTLs since all requests go through here.
  1292     this._pruneExpired();
  1294     req.timestamp = timestamp;
  1295     resp.setHeader("X-Timestamp", "" + timestamp, false);
  1297     let parts = this.pathRE.exec(req.path);
  1298     if (!parts) {
  1299       this._log.debug("StorageServer: Unexpected request: bad URL " + req.path);
  1300       throw HTTP_404;
  1303     let [all, version, userPath, first, rest] = parts;
  1304     if (version != STORAGE_API_VERSION) {
  1305       this._log.debug("StorageServer: Unknown version.");
  1306       throw HTTP_404;
  1309     let username;
  1311     // By default, the server requires users to be authenticated. When a
  1312     // request arrives, the user must have been previously configured and
  1313     // the request must have authentication. In "allow all users" mode, we
  1314     // take the username from the URL, create the user on the fly, and don't
  1315     // perform any authentication.
  1316     if (!this.allowAllUsers) {
  1317       // Enforce authentication.
  1318       if (!req.hasHeader("authorization")) {
  1319         this.respond(req, resp, 401, "Authorization Required", "{}", {
  1320           "WWW-Authenticate": 'Basic realm="secret"'
  1321         });
  1322         return;
  1325       let ensureUserExists = function ensureUserExists(username) {
  1326         if (this.userExists(username)) {
  1327           return;
  1330         this._log.info("StorageServer: Unknown user: " + username);
  1331         throw HTTP_401;
  1332       }.bind(this);
  1334       let auth = req.getHeader("authorization");
  1335       this._log.debug("Authorization: " + auth);
  1337       if (auth.indexOf("Basic ") == 0) {
  1338         let decoded = CommonUtils.safeAtoB(auth.substr(6));
  1339         this._log.debug("Decoded Basic Auth: " + decoded);
  1340         let [user, password] = decoded.split(":", 2);
  1342         if (!password) {
  1343           this._log.debug("Malformed HTTP Basic Authorization header: " + auth);
  1344           throw HTTP_400;
  1347         this._log.debug("Got HTTP Basic auth for user: " + user);
  1348         ensureUserExists(user);
  1349         username = user;
  1351         if (this.users[user].password != password) {
  1352           this._log.debug("StorageServer: Provided password is not correct.");
  1353           throw HTTP_401;
  1355       // TODO support token auth.
  1356       } else {
  1357         this._log.debug("Unsupported HTTP authorization type: " + auth);
  1358         throw HTTP_500;
  1360     // All users mode.
  1361     } else {
  1362       // Auto create user with dummy password.
  1363       if (!this.userExists(userPath)) {
  1364         this.registerUser(userPath, "DUMMY-PASSWORD-*&%#");
  1367       username = userPath;
  1370     // Hand off to the appropriate handler for this path component.
  1371     if (first in this.toplevelHandlers) {
  1372       let handler = this.toplevelHandlers[first];
  1373       try {
  1374         return handler.call(this, handler, req, resp, version, username, rest);
  1375       } catch (ex) {
  1376         this._log.warn("Got exception during request: " +
  1377                        CommonUtils.exceptionStr(ex));
  1378         throw ex;
  1381     this._log.debug("StorageServer: Unknown top-level " + first);
  1382     throw HTTP_404;
  1383   },
  1385   /**
  1386    * Collection of the handler methods we use for top-level path components.
  1387    */
  1388   toplevelHandlers: {
  1389     "storage": function handleStorage(handler, req, resp, version, username,
  1390                                       rest) {
  1391       let respond = this.respond.bind(this, req, resp);
  1392       if (!rest || !rest.length) {
  1393         this._log.debug("StorageServer: top-level storage " +
  1394                         req.method + " request.");
  1396         if (req.method != "DELETE") {
  1397           respond(405, "Method Not Allowed", null, {"Allow": "DELETE"});
  1398           return;
  1401         this.user(username).deleteCollections();
  1403         respond(204, "No Content");
  1404         return;
  1407       let match = this.storageRE.exec(rest);
  1408       if (!match) {
  1409         this._log.warn("StorageServer: Unknown storage operation " + rest);
  1410         throw HTTP_404;
  1412       let [all, collection, bsoID] = match;
  1413       let coll = this.getCollection(username, collection);
  1414       let collectionExisted = !!coll;
  1416       switch (req.method) {
  1417         case "GET":
  1418           // Tried to GET on a collection that doesn't exist.
  1419           if (!coll) {
  1420             respond(404, "Not Found");
  1421             return;
  1424           // No BSO URL parameter goes to collection handler.
  1425           if (!bsoID) {
  1426             return coll.collectionHandler(req, resp);
  1429           // Handle non-existent BSO.
  1430           let bso = coll.bso(bsoID);
  1431           if (!bso) {
  1432             respond(404, "Not Found");
  1433             return;
  1436           // Proxy to BSO handler.
  1437           return bso.getHandler(req, resp);
  1439         case "DELETE":
  1440           // Collection doesn't exist.
  1441           if (!coll) {
  1442             respond(404, "Not Found");
  1443             return;
  1446           // Deleting a specific BSO.
  1447           if (bsoID) {
  1448             let bso = coll.bso(bsoID);
  1450             // BSO does not exist on the server. Nothing to do.
  1451             if (!bso) {
  1452               respond(404, "Not Found");
  1453               return;
  1456             if (req.hasHeader("x-if-unmodified-since")) {
  1457               let modified = parseInt(req.getHeader("x-if-unmodified-since"));
  1458               CommonUtils.ensureMillisecondsTimestamp(modified);
  1460               if (bso.modified > modified) {
  1461                 respond(412, "Precondition Failed");
  1462                 return;
  1466             bso.delete();
  1467             coll.timestamp = req.timestamp;
  1468             this.callback.onItemDeleted(username, collection, bsoID);
  1469             respond(204, "No Content");
  1470             return;
  1473           // Proxy to collection handler.
  1474           coll.collectionHandler(req, resp);
  1476           // Spot if this is a DELETE for some IDs, and don't blow away the
  1477           // whole collection!
  1478           //
  1479           // We already handled deleting the BSOs by invoking the deleted
  1480           // collection's handler. However, in the case of
  1481           //
  1482           //   DELETE storage/foobar
  1483           //
  1484           // we also need to remove foobar from the collections map. This
  1485           // clause tries to differentiate the above request from
  1486           //
  1487           //  DELETE storage/foobar?ids=foo,baz
  1488           //
  1489           // and do the right thing.
  1490           // TODO: less hacky method.
  1491           if (-1 == req.queryString.indexOf("ids=")) {
  1492             // When you delete the entire collection, we drop it.
  1493             this._log.debug("Deleting entire collection.");
  1494             delete this.users[username].collections[collection];
  1495             this.callback.onCollectionDeleted(username, collection);
  1498           // Notify of item deletion.
  1499           let deleted = resp.deleted || [];
  1500           for (let i = 0; i < deleted.length; ++i) {
  1501             this.callback.onItemDeleted(username, collection, deleted[i]);
  1503           return;
  1505         case "POST":
  1506         case "PUT":
  1507           // Auto-create collection if it doesn't exist.
  1508           if (!coll) {
  1509             coll = this.createCollection(username, collection);
  1512           try {
  1513             if (bsoID) {
  1514               let bso = coll.bso(bsoID);
  1515               if (!bso) {
  1516                 this._log.trace("StorageServer: creating BSO " + collection +
  1517                                 "/" + bsoID);
  1518                 try {
  1519                   bso = coll.insert(bsoID);
  1520                 } catch (ex) {
  1521                   return sendMozSvcError(req, resp, "8");
  1525               bso.putHandler(req, resp);
  1527               coll.timestamp = req.timestamp;
  1528               return resp;
  1531             return coll.collectionHandler(req, resp);
  1532           } catch (ex) {
  1533             if (ex instanceof HttpError) {
  1534               if (!collectionExisted) {
  1535                 this.deleteCollection(username, collection);
  1539             throw ex;
  1542         default:
  1543           throw new Error("Request method " + req.method + " not implemented.");
  1545     },
  1547     "info": function handleInfo(handler, req, resp, version, username, rest) {
  1548       switch (rest) {
  1549         case "collections":
  1550           return this.handleInfoCollections(req, resp, username);
  1552         case "collection_counts":
  1553           return this.handleInfoCounts(req, resp, username);
  1555         case "collection_usage":
  1556           return this.handleInfoUsage(req, resp, username);
  1558         case "quota":
  1559           return this.handleInfoQuota(req, resp, username);
  1561         default:
  1562           this._log.warn("StorageServer: Unknown info operation " + rest);
  1563           throw HTTP_404;
  1566   },
  1568   handleInfoConditional: function handleInfoConditional(request, response,
  1569                                                         user) {
  1570     if (!request.hasHeader("x-if-modified-since")) {
  1571       return false;
  1574     let requestModified = request.getHeader("x-if-modified-since");
  1575     requestModified = parseInt(requestModified, 10);
  1577     let serverModified = this.newestCollectionTimestamp(user);
  1579     this._log.info("Server mtime: " + serverModified + "; Client modified: " +
  1580                    requestModified);
  1581     if (serverModified > requestModified) {
  1582       return false;
  1585     this.respond(request, response, 304, "Not Modified", null, {
  1586       "X-Last-Modified": "" + serverModified
  1587     });
  1589     return true;
  1590   },
  1592   handleInfoCollections: function handleInfoCollections(request, response,
  1593                                                         user) {
  1594     if (this.handleInfoConditional(request, response, user)) {
  1595       return;
  1598     let info = this.infoCollections(user);
  1599     let body = JSON.stringify(info);
  1600     this.respond(request, response, 200, "OK", body, {
  1601       "Content-Type":    "application/json",
  1602       "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
  1603     });
  1604   },
  1606   handleInfoCounts: function handleInfoCounts(request, response, user) {
  1607     if (this.handleInfoConditional(request, response, user)) {
  1608       return;
  1611     let counts = this.infoCounts(user);
  1612     let body = JSON.stringify(counts);
  1614     this.respond(request, response, 200, "OK", body, {
  1615       "Content-Type":    "application/json",
  1616       "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
  1617     });
  1618   },
  1620   handleInfoUsage: function handleInfoUsage(request, response, user) {
  1621     if (this.handleInfoConditional(request, response, user)) {
  1622       return;
  1625     let body = JSON.stringify(this.infoUsage(user));
  1626     this.respond(request, response, 200, "OK", body, {
  1627       "Content-Type":    "application/json",
  1628       "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
  1629     });
  1630   },
  1632   handleInfoQuota: function handleInfoQuota(request, response, user) {
  1633     if (this.handleInfoConditional(request, response, user)) {
  1634       return;
  1637     let body = JSON.stringify(this.infoQuota(user));
  1638     this.respond(request, response, 200, "OK", body, {
  1639       "Content-Type":    "application/json",
  1640       "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
  1641     });
  1642   },
  1643 };
  1645 /**
  1646  * Helper to create a storage server for a set of users.
  1648  * Each user is specified by a map of username to password.
  1649  */
  1650 this.storageServerForUsers =
  1651  function storageServerForUsers(users, contents, callback) {
  1652   let server = new StorageServer(callback);
  1653   for (let [user, pass] in Iterator(users)) {
  1654     server.registerUser(user, pass);
  1655     server.createContents(user, contents);
  1657   server.start();
  1658   return server;

mercurial