RemoteSettingsServer.sys.mjs (19129B)
1 /* Any copyright is dedicated to the Public Domain. 2 http://creativecommons.org/publicdomain/zero/1.0/ */ 3 4 /* eslint-disable jsdoc/require-param-description */ 5 6 const lazy = {}; 7 8 ChromeUtils.defineESModuleGetters(lazy, { 9 HttpError: "resource://testing-common/httpd.sys.mjs", 10 HttpServer: "resource://testing-common/httpd.sys.mjs", 11 HTTP_404: "resource://testing-common/httpd.sys.mjs", 12 }); 13 14 /** 15 * @import {HttpError} from "resource://testing-common/httpd.sys.mjs" 16 */ 17 18 const SERVER_PREF = "services.settings.server"; 19 20 /** 21 * A remote settings server. Tested with the desktop and Rust remote settings 22 * clients. 23 */ 24 export class RemoteSettingsServer { 25 /** 26 * The server must be started by calling `start()`. 27 * 28 * @param {object} options 29 * @param {ConsoleLogLevel} [options.maxLogLevel] 30 * A log level value as defined by ConsoleInstance. `Info` logs server start 31 * and stop. `Debug` logs requests, responses, and added and removed 32 * records. 33 */ 34 constructor({ maxLogLevel = "Info" } = {}) { 35 this.#log = console.createInstance({ 36 prefix: "RemoteSettingsServer", 37 maxLogLevel, 38 }); 39 } 40 41 /** 42 * @returns {URL} 43 * The server's URL. Null when the server is stopped. 44 */ 45 get url() { 46 return this.#url; 47 } 48 49 /** 50 * Starts the server and sets the `services.settings.server` pref to its 51 * URL. The server's `url` property will be non-null on return. 52 */ 53 async start() { 54 this.#log.info("Starting"); 55 56 if (this.#url) { 57 this.#log.info("Already started at " + this.#url); 58 return; 59 } 60 61 if (!this.#server) { 62 this.#server = new lazy.HttpServer(); 63 this.#server.registerPrefixHandler("/", this); 64 } 65 this.#server.start(-1); 66 67 this.#url = new URL("http://localhost/v1"); 68 this.#url.port = this.#server.identity.primaryPort; 69 70 this.#originalServerPrefValue = Services.prefs.getCharPref( 71 SERVER_PREF, 72 null 73 ); 74 Services.prefs.setCharPref(SERVER_PREF, this.#url.toString()); 75 76 this.#log.info("Server is now started at " + this.#url); 77 } 78 79 /** 80 * Stops the server and clears the `services.settings.server` pref. The 81 * server's `url` property will be null on return. 82 */ 83 async stop() { 84 this.#log.info("Stopping"); 85 86 if (!this.#url) { 87 this.#log.info("Already stopped"); 88 return; 89 } 90 91 await this.#server.stop(); 92 this.#url = null; 93 94 if (this.#originalServerPrefValue === null) { 95 Services.prefs.clearUserPref(SERVER_PREF); 96 } else { 97 Services.prefs.setCharPref(SERVER_PREF, this.#originalServerPrefValue); 98 } 99 100 this.#log.info("Server is now stopped"); 101 } 102 103 /** 104 * Adds remote settings records to the server. Records may have attachments; 105 * see the param doc below. 106 * 107 * @param {object} options 108 * @param {string} options.bucket 109 * @param {string} options.collection 110 * @param {Array} options.records 111 * Each object in this array should be a realistic remote settings record 112 * with the following exceptions: 113 * 114 * - `record.id` will be generated if it's undefined. 115 * - `record.last_modified` will be set to the `#lastModified` property of 116 * the server if it's undefined. 117 * - `record.attachment`, if defined, should be the attachment itself and 118 * not its metadata. The server will automatically create some dummy 119 * metadata. Currently the only supported attachment type is plain 120 * JSON'able objects that the server will convert to JSON in responses. 121 */ 122 async addRecords({ bucket = "main", collection = "test", records }) { 123 this.#log.debug("Adding records:", { bucket, collection, records }); 124 125 this.#lastModified++; 126 127 let key = this.#recordsKey(bucket, collection); 128 let allRecords = this.#records.get(key); 129 if (!allRecords) { 130 allRecords = []; 131 this.#records.set(key, allRecords); 132 } 133 134 for (let record of records) { 135 let copy = { ...record }; 136 137 if (!copy.hasOwnProperty("id")) { 138 copy.id = String(this.#nextRecordId++); 139 } 140 if (!copy.hasOwnProperty("last_modified")) { 141 copy.last_modified = this.#lastModified; 142 } 143 if (copy.attachment) { 144 await this.#addAttachment({ bucket, collection, record: copy }); 145 } 146 allRecords.push(copy); 147 } 148 149 this.#log.debug("Done adding records. All records are now:", [ 150 ...this.#records.entries(), 151 ]); 152 } 153 154 /** 155 * Marks records as deleted. Deleted records will still be returned in 156 * responses, but they'll have a `deleted = true` property. Their attachments 157 * will be deleted immediately, however. 158 * 159 * @param {object} filter 160 * If null, all records will be marked as deleted. Otherwise only records 161 * that match the filter will be marked as deleted. For a given record, each 162 * value in the filter object will be compared to the value with the same 163 * key in the record. If all values are the same, the record will be 164 * removed. Examples: 165 * 166 * To remove remove records whose `type` key has the value "data": 167 * `{ type: "data" } 168 * 169 * To remove remove records whose `type` key has the value "data" and whose 170 * `last_modified` key has the value 1234: 171 * `{ type: "data", last_modified: 1234 } 172 */ 173 removeRecords(filter = null) { 174 this.#log.debug("Removing records", { filter }); 175 176 this.#lastModified++; 177 178 for (let records of this.#records.values()) { 179 for (let record of records) { 180 if ( 181 !filter || 182 Object.entries(filter).every( 183 ([filterKey, filterValue]) => 184 record.hasOwnProperty(filterKey) && 185 record[filterKey] == filterValue 186 ) 187 ) { 188 record.deleted = true; 189 record.last_modified = this.#lastModified; 190 191 // If the record has an attachment, leave it. Sometimes the following 192 // sequence can happen: A test requests records, we send them, 193 // something else deletes the records, and then the test requests 194 // their attachments. The JS RS client throws an error in that case 195 // since the attachment hashes don't match the hashes in the records. 196 } 197 } 198 } 199 200 this.#log.debug("Done removing records. All records are now:", [ 201 ...this.#records.entries(), 202 ]); 203 } 204 205 /** 206 * Removes all existing records and adds the given records to the server. 207 * 208 * @param {object} options 209 * @param {string} options.bucket 210 * @param {string} options.collection 211 * @param {Array} options.records 212 * See `addRecords()`. 213 */ 214 async setRecords({ bucket = "main", collection = "test", records }) { 215 this.#log.debug("Setting records"); 216 217 this.removeRecords(); 218 await this.addRecords({ bucket, collection, records }); 219 220 this.#log.debug("Done setting records"); 221 } 222 223 /** 224 * `nsIHttpRequestHandler` callback from the backing server. Handles a 225 * request. 226 * 227 * @param {nsIHttpRequest} request 228 * @param {nsIHttpResponse} response 229 */ 230 handle(request, response) { 231 this.#logRequest(request); 232 233 // Get the route that matches the request path. 234 let { match, route } = this.#getRoute(request.path) || {}; 235 if (!route) { 236 this.#prepareError({ request, response, error: lazy.HTTP_404 }); 237 return; 238 } 239 240 let respInfo = route.response(match, request, response); 241 if (respInfo instanceof lazy.HttpError) { 242 this.#prepareError({ request, response, error: respInfo }); 243 } else { 244 this.#prepareResponse({ ...respInfo, request, response }); 245 } 246 } 247 248 /** 249 * @returns {Array} 250 * The routes handled by the server. Each item in this array is an object 251 * with the following properties that describes one or more paths and the 252 * response that should be sent when a request is made on those paths: 253 * 254 * {string} spec 255 * A path spec. This is required unless `specs` is defined. To determine 256 * which route should be used for a given request, the server will check 257 * each route's spec(s) until it finds the first that matches the 258 * request's path. A spec is just a path whose components can be variables 259 * that start with "$". When a spec with variables matches a request path, 260 * the `match` object passed to the route's `response` function will map 261 * from variable names to the corresponding components in the path. 262 * {Array} specs 263 * An array of path spec strings. Use this instead of `spec` if the route 264 * handles more than one. 265 * {function} response 266 * A function that will be called when the route matches a request. It is 267 * called as: `response(match, request, response)` 268 * 269 * {object} match 270 * An object mapping variable names in the spec to their matched 271 * components in the path. See `#match()` for details. 272 * {nsIHttpRequest} request 273 * {nsIHttpResponse} response 274 * 275 * The function must return one of the following: 276 * 277 * {object} 278 * An object that describes the response with the following properties: 279 * {object} body 280 * A plain JSON'able object. The server will convert this to JSON and 281 * set it to the response body. 282 * {HttpError} 283 * An `HttpError` instance defined in `httpd.sys.mjs`. 284 */ 285 get #routes() { 286 return [ 287 { 288 spec: "/v1", 289 response: () => ({ 290 body: { 291 capabilities: { 292 attachments: { 293 base_url: this.#url.toString(), 294 }, 295 }, 296 }, 297 }), 298 }, 299 300 { 301 spec: "/v1/buckets/monitor/collections/changes/changeset", 302 response: () => ({ 303 body: { 304 timestamp: this.#lastModified, 305 changes: [ 306 { 307 last_modified: this.#lastModified, 308 }, 309 ], 310 }, 311 }), 312 }, 313 314 { 315 spec: "/v1/buckets/$bucket/collections/$collection/changeset", 316 response: ({ bucket, collection }, request) => { 317 let records = this.#getRecords(bucket, collection, request); 318 return !records 319 ? lazy.HTTP_404 320 : { 321 body: { 322 metadata: { 323 bucket, 324 signature: { 325 signature: "", 326 x5u: "", 327 }, 328 }, 329 timestamp: this.#lastModified, 330 changes: records, 331 }, 332 }; 333 }, 334 }, 335 336 { 337 spec: "/v1/buckets/$bucket/collections/$collection/records", 338 response: ({ bucket, collection }, request) => { 339 let records = this.#getRecords(bucket, collection, request); 340 return !records 341 ? lazy.HTTP_404 342 : { 343 body: { 344 data: records, 345 }, 346 }; 347 }, 348 }, 349 350 { 351 specs: [ 352 // The Rust remote settings client doesn't include "v1" in attachment 353 // URLs, but the JS client does. 354 "/attachments/$bucket/$collection/$filename", 355 "/v1/attachments/$bucket/$collection/$filename", 356 ], 357 response: ({ bucket, collection, filename }) => { 358 return { 359 body: this.#getAttachment(bucket, collection, filename), 360 }; 361 }, 362 }, 363 ]; 364 } 365 366 /** 367 * @returns {object} 368 * Default response headers. 369 */ 370 get #responseHeaders() { 371 return { 372 "Access-Control-Allow-Origin": "*", 373 "Access-Control-Expose-Headers": 374 "Retry-After, Content-Length, Alert, Backoff", 375 Server: "waitress", 376 Etag: `"${this.#lastModified}"`, 377 }; 378 } 379 380 /** 381 * Returns the route that matches a request path. 382 * 383 * @param {string} path 384 * A request path. 385 * @returns {object} 386 * If no route matches the path, returns an empty object. Otherwise returns 387 * an object with the following properties: 388 * 389 * {object} match 390 * An object describing the matched variables in the route spec. See 391 * `#match()` for details. 392 * {object} route 393 * The matched route. See `#routes` for details. 394 */ 395 #getRoute(path) { 396 for (let route of this.#routes) { 397 let specs = route.specs || [route.spec]; 398 for (let spec of specs) { 399 let match = this.#match(path, spec); 400 if (match) { 401 return { match, route }; 402 } 403 } 404 } 405 return {}; 406 } 407 408 /** 409 * Matches a request path to a route spec. 410 * 411 * @param {string} path 412 * A request path. 413 * @param {string} spec 414 * A route spec. See `#routes` for details. 415 * @returns {object|null} 416 * If the spec doesn't match the path, returns null. Otherwise returns an 417 * object mapping variable names in the spec to their matched components in 418 * the path. Example: 419 * 420 * path : "/main/myfeature/foo" 421 * spec : "/$bucket/$collection/foo" 422 * returns: `{ bucket: "main", collection: "myfeature" }` 423 */ 424 #match(path, spec) { 425 let pathParts = path.split("/"); 426 let specParts = spec.split("/"); 427 428 if (pathParts.length != specParts.length) { 429 // If the path has only one more part than the spec and its last part is 430 // empty, then the path ends in a trailing slash but the spec does not. 431 // Consider that a match. Otherwise return null for no match. 432 if ( 433 pathParts[pathParts.length - 1] || 434 pathParts.length != specParts.length + 1 435 ) { 436 return null; 437 } 438 pathParts.pop(); 439 } 440 441 let match = {}; 442 for (let i = 0; i < pathParts.length; i++) { 443 let pathPart = pathParts[i]; 444 let specPart = specParts[i]; 445 if (specPart.startsWith("$")) { 446 match[specPart.substring(1)] = pathPart; 447 } else if (pathPart != specPart) { 448 return null; 449 } 450 } 451 452 return match; 453 } 454 455 #getRecords(bucket, collection, request) { 456 let records = this.#records.get(this.#recordsKey(bucket, collection)); 457 let params = new URLSearchParams(request.queryString); 458 459 let type = params.get("type"); 460 if (type) { 461 records = records.filter(r => r.type == type); 462 } 463 464 let gtLastModified = params.get("gt_last_modified"); 465 if (gtLastModified) { 466 records = records.filter(r => r.last_modified > gtLastModified); 467 } 468 469 let since = params.get("_since"); 470 if (since) { 471 // Example value: "%221368273600004%22" 472 let match = /^"([0-9]+)"$/.exec(decodeURIComponent(since)); 473 if (match) { 474 let sinceTime = parseInt(match[1]); 475 records = records.filter(r => r.last_modified > sinceTime); 476 } 477 } 478 479 let sort = params.get("_sort"); 480 if (sort == "last_modified") { 481 records = records.toSorted((a, b) => a.last_modified - b.last_modified); 482 } 483 484 return records; 485 } 486 487 #recordsKey(bucket, collection) { 488 return `${bucket}/${collection}`; 489 } 490 491 /** 492 * Registers an attachment for a record. 493 * 494 * @param {object} options 495 * @param {string} options.bucket 496 * @param {string} options.collection 497 * @param {object} options.record 498 * The record should have an `attachment` property as described in 499 * `addRecords()`. 500 */ 501 async #addAttachment({ bucket, collection, record }) { 502 let { attachment } = record; 503 504 let mimetype = 505 record.attachmentMimetype ?? "application/json; charset=UTF-8"; 506 if (!mimetype.startsWith("application/json")) { 507 throw new Error( 508 "Mimetype not handled, please add code for it! " + mimetype 509 ); 510 } 511 512 let encoder = new TextEncoder(); 513 let bytes = encoder.encode(JSON.stringify(attachment)); 514 515 let hashBuffer = await crypto.subtle.digest("SHA-256", bytes); 516 let hashBytes = new Uint8Array(hashBuffer); 517 let toHex = b => b.toString(16).padStart(2, "0"); 518 let hash = Array.from(hashBytes, toHex).join(""); 519 520 let filename = record.id; 521 this.#attachments.set( 522 this.#attachmentsKey(bucket, collection, filename), 523 attachment 524 ); 525 526 // Replace `record.attachment` with appropriate metadata in order to conform 527 // with the remote settings API. 528 record.attachment = { 529 hash, 530 filename, 531 mimetype, 532 size: bytes.length, 533 location: `attachments/${bucket}/${collection}/${filename}`, 534 }; 535 536 delete record.attachmentMimetype; 537 } 538 539 #attachmentsKey(bucket, collection, filename) { 540 return `${bucket}/${collection}/${filename}`; 541 } 542 543 #getAttachment(bucket, collection, filename) { 544 return this.#attachments.get( 545 this.#attachmentsKey(bucket, collection, filename) 546 ); 547 } 548 549 /** 550 * Prepares an HTTP response. 551 * 552 * @param {object} options 553 * @param {nsIHttpRequest} options.request 554 * @param {nsIHttpResponse} options.response 555 * @param {object|null} [options.body] 556 * Currently only JSON'able objects are supported. They will be converted to 557 * JSON in the response. 558 * @param {number} [options.status] 559 * @param {string} [options.statusText] 560 */ 561 #prepareResponse({ 562 request, 563 response, 564 body = null, 565 status = 200, 566 statusText = "OK", 567 }) { 568 let headers = { ...this.#responseHeaders }; 569 if (body) { 570 headers["Content-Type"] = "application/json; charset=UTF-8"; 571 } 572 573 this.#logResponse({ request, status, body }); 574 575 for (let [name, value] of Object.entries(headers)) { 576 response.setHeader(name, value, false); 577 } 578 if (body) { 579 response.write(JSON.stringify(body)); 580 } 581 response.setStatusLine(request.httpVersion, status, statusText); 582 } 583 584 /** 585 * Prepares an HTTP error response. 586 * 587 * @param {object} options 588 * @param {nsIHttpRequest} options.request 589 * @param {nsIHttpResponse} options.response 590 * @param {HttpError} options.error 591 * An `HttpError` instance defined in `httpd.sys.mjs`. 592 */ 593 #prepareError({ request, response, error }) { 594 this.#prepareResponse({ 595 request, 596 response, 597 status: error.code, 598 statusText: error.description, 599 }); 600 } 601 602 /** 603 * Logs a request. 604 * 605 * @param {nsIHttpRequest} request 606 */ 607 #logRequest(request) { 608 let pathAndQuery = request.path; 609 if (request.queryString) { 610 pathAndQuery += "?" + request.queryString; 611 } 612 this.#log.debug( 613 `< HTTP ${request.httpVersion} ${request.method} ${pathAndQuery}` 614 ); 615 } 616 617 /** 618 * Logs a response. 619 * 620 * @param {object} options 621 * @param {nsIHttpRequest} options.request 622 * The associated request. 623 * @param {number} options.status 624 * The HTTP status code of the response. 625 * @param {object} options.body 626 * The response body, if any. 627 */ 628 #logResponse({ request, status, body }) { 629 this.#log.debug(`> ${status} ${request.path}`); 630 if (body) { 631 this.#log.debug("Response body:", body); 632 } 633 } 634 635 // records key (see `#recordsKey()`) -> array of record objects 636 #records = new Map(); 637 638 // attachments key (see `#attachmentsKey()`) -> attachment object 639 #attachments = new Map(); 640 641 #log; 642 #server; 643 #originalServerPrefValue; 644 #url = null; 645 #lastModified = 1368273600000; 646 #nextRecordId = 1; 647 }