services/common/modules-testing/storageserver.js

Thu, 22 Jan 2015 13:21:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Thu, 22 Jan 2015 13:21:57 +0100
branch
TOR_BUG_9701
changeset 15
b8a032363ba2
permissions
-rw-r--r--

Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6

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

mercurial