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