1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678 |
- /* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this file,
- * You can obtain one at http://mozilla.org/MPL/2.0/. */
- /**
- * This file contains an implementation of the Storage Server in JavaScript.
- *
- * The server should not be used for any production purposes.
- */
- var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
- this.EXPORTED_SYMBOLS = [
- "ServerBSO",
- "StorageServerCallback",
- "StorageServerCollection",
- "StorageServer",
- "storageServerForUsers",
- ];
- Cu.import("resource://testing-common/httpd.js");
- Cu.import("resource://services-common/async.js");
- Cu.import("resource://gre/modules/Log.jsm");
- Cu.import("resource://services-common/utils.js");
- const STORAGE_HTTP_LOGGER = "Services.Common.Test.Server";
- const STORAGE_API_VERSION = "2.0";
- // Use the same method that record.js does, which mirrors the server.
- function new_timestamp() {
- return Math.round(Date.now());
- }
- function isInteger(s) {
- let re = /^[0-9]+$/;
- return re.test(s);
- }
- function writeHttpBody(response, body) {
- if (!body) {
- return;
- }
- response.bodyOutputStream.write(body, body.length);
- }
- function sendMozSvcError(request, response, code) {
- response.setStatusLine(request.httpVersion, 400, "Bad Request");
- response.setHeader("Content-Type", "text/plain", false);
- response.bodyOutputStream.write(code, code.length);
- }
- /**
- * Represent a BSO on the server.
- *
- * A BSO is constructed from an ID, content, and a modified time.
- *
- * @param id
- * (string) ID of the BSO being created.
- * @param payload
- * (strong|object) Payload for the BSO. Should ideally be a string. If
- * an object is passed, it will be fed into JSON.stringify and that
- * output will be set as the payload.
- * @param modified
- * (number) Milliseconds since UNIX epoch that the BSO was last
- * modified. If not defined or null, the current time will be used.
- */
- this.ServerBSO = function ServerBSO(id, payload, modified) {
- if (!id) {
- throw new Error("No ID for ServerBSO!");
- }
- if (!id.match(/^[a-zA-Z0-9_-]{1,64}$/)) {
- throw new Error("BSO ID is invalid: " + id);
- }
- this._log = Log.repository.getLogger(STORAGE_HTTP_LOGGER);
- this.id = id;
- if (!payload) {
- return;
- }
- CommonUtils.ensureMillisecondsTimestamp(modified);
- if (typeof payload == "object") {
- payload = JSON.stringify(payload);
- }
- this.payload = payload;
- this.modified = modified || new_timestamp();
- }
- ServerBSO.prototype = {
- FIELDS: [
- "id",
- "modified",
- "payload",
- "ttl",
- "sortindex",
- ],
- toJSON: function toJSON() {
- let obj = {};
- for (let key of this.FIELDS) {
- if (this[key] !== undefined) {
- obj[key] = this[key];
- }
- }
- return obj;
- },
- delete: function delete_() {
- this.deleted = true;
- delete this.payload;
- delete this.modified;
- },
- /**
- * Handler for GET requests for this BSO.
- */
- getHandler: function getHandler(request, response) {
- let code = 200;
- let status = "OK";
- let body;
- function sendResponse() {
- response.setStatusLine(request.httpVersion, code, status);
- writeHttpBody(response, body);
- }
- if (request.hasHeader("x-if-modified-since")) {
- let headerModified = parseInt(request.getHeader("x-if-modified-since"),
- 10);
- CommonUtils.ensureMillisecondsTimestamp(headerModified);
- if (headerModified >= this.modified) {
- code = 304;
- status = "Not Modified";
- sendResponse();
- return;
- }
- } else if (request.hasHeader("x-if-unmodified-since")) {
- let requestModified = parseInt(request.getHeader("x-if-unmodified-since"),
- 10);
- let serverModified = this.modified;
- if (serverModified > requestModified) {
- code = 412;
- status = "Precondition Failed";
- sendResponse();
- return;
- }
- }
- if (!this.deleted) {
- body = JSON.stringify(this.toJSON());
- response.setHeader("Content-Type", "application/json", false);
- response.setHeader("X-Last-Modified", "" + this.modified, false);
- } else {
- code = 404;
- status = "Not Found";
- }
- sendResponse();
- },
- /**
- * Handler for PUT requests for this BSO.
- */
- putHandler: function putHandler(request, response) {
- if (request.hasHeader("Content-Type")) {
- let ct = request.getHeader("Content-Type");
- if (ct != "application/json") {
- throw HTTP_415;
- }
- }
- let input = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
- let parsed;
- try {
- parsed = JSON.parse(input);
- } catch (ex) {
- return sendMozSvcError(request, response, "8");
- }
- if (typeof(parsed) != "object") {
- return sendMozSvcError(request, response, "8");
- }
- // Don't update if a conditional request fails preconditions.
- if (request.hasHeader("x-if-unmodified-since")) {
- let reqModified = parseInt(request.getHeader("x-if-unmodified-since"));
- if (reqModified < this.modified) {
- response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
- return;
- }
- }
- let code, status;
- if (this.payload) {
- code = 204;
- status = "No Content";
- } else {
- code = 201;
- status = "Created";
- }
- // Alert when we see unrecognized fields.
- for (let [key, value] of Object.entries(parsed)) {
- switch (key) {
- case "payload":
- if (typeof(value) != "string") {
- sendMozSvcError(request, response, "8");
- return true;
- }
- this.payload = value;
- break;
- case "ttl":
- if (!isInteger(value)) {
- sendMozSvcError(request, response, "8");
- return true;
- }
- this.ttl = parseInt(value, 10);
- break;
- case "sortindex":
- if (!isInteger(value) || value.length > 9) {
- sendMozSvcError(request, response, "8");
- return true;
- }
- this.sortindex = parseInt(value, 10);
- break;
- case "id":
- break;
- default:
- this._log.warn("Unexpected field in BSO record: " + key);
- sendMozSvcError(request, response, "8");
- return true;
- }
- }
- this.modified = request.timestamp;
- this.deleted = false;
- response.setHeader("X-Last-Modified", "" + this.modified, false);
- response.setStatusLine(request.httpVersion, code, status);
- },
- };
- /**
- * Represent a collection on the server.
- *
- * The '_bsos' attribute is a mapping of id -> ServerBSO objects.
- *
- * Note that if you want these records to be accessible individually,
- * you need to register their handlers with the server separately, or use a
- * containing HTTP server that will do so on your behalf.
- *
- * @param bsos
- * An object mapping BSO IDs to ServerBSOs.
- * @param acceptNew
- * If true, POSTs to this collection URI will result in new BSOs being
- * created and wired in on the fly.
- * @param timestamp
- * An optional timestamp value to initialize the modified time of the
- * collection. This should be in the format returned by new_timestamp().
- */
- this.StorageServerCollection =
- function StorageServerCollection(bsos, acceptNew, timestamp=new_timestamp()) {
- this._bsos = bsos || {};
- this.acceptNew = acceptNew || false;
- /*
- * Track modified timestamp.
- * We can't just use the timestamps of contained BSOs: an empty collection
- * has a modified time.
- */
- CommonUtils.ensureMillisecondsTimestamp(timestamp);
- this._timestamp = timestamp;
- this._log = Log.repository.getLogger(STORAGE_HTTP_LOGGER);
- }
- StorageServerCollection.prototype = {
- BATCH_MAX_COUNT: 100, // # of records.
- BATCH_MAX_SIZE: 1024 * 1024, // # bytes.
- _timestamp: null,
- get timestamp() {
- return this._timestamp;
- },
- set timestamp(timestamp) {
- CommonUtils.ensureMillisecondsTimestamp(timestamp);
- this._timestamp = timestamp;
- },
- get totalPayloadSize() {
- let size = 0;
- for (let bso of this.bsos()) {
- size += bso.payload.length;
- }
- return size;
- },
- /**
- * Convenience accessor for our BSO keys.
- * Excludes deleted items, of course.
- *
- * @param filter
- * A predicate function (applied to the ID and BSO) which dictates
- * whether to include the BSO's ID in the output.
- *
- * @return an array of IDs.
- */
- keys: function keys(filter) {
- let ids = [];
- for (let [id, bso] of Object.entries(this._bsos)) {
- if (!bso.deleted && (!filter || filter(id, bso))) {
- ids.push(id);
- }
- }
- return ids;
- },
- /**
- * Convenience method to get an array of BSOs.
- * Optionally provide a filter function.
- *
- * @param filter
- * A predicate function, applied to the BSO, which dictates whether to
- * include the BSO in the output.
- *
- * @return an array of ServerBSOs.
- */
- bsos: function bsos(filter) {
- let os = [];
- for (let [id, bso] of Object.entries(this._bsos)) {
- if (!bso.deleted) {
- os.push(bso);
- }
- }
- if (!filter) {
- return os;
- }
- return os.filter(filter);
- },
- /**
- * Obtain a BSO by ID.
- */
- bso: function bso(id) {
- return this._bsos[id];
- },
- /**
- * Obtain the payload of a specific BSO.
- *
- * Raises if the specified BSO does not exist.
- */
- payload: function payload(id) {
- return this.bso(id).payload;
- },
- /**
- * Insert the provided BSO under its ID.
- *
- * @return the provided BSO.
- */
- insertBSO: function insertBSO(bso) {
- return this._bsos[bso.id] = bso;
- },
- /**
- * Insert the provided payload as part of a new ServerBSO with the provided
- * ID.
- *
- * @param id
- * The GUID for the BSO.
- * @param payload
- * The payload, as provided to the ServerBSO constructor.
- * @param modified
- * An optional modified time for the ServerBSO. If not specified, the
- * current time will be used.
- *
- * @return the inserted BSO.
- */
- insert: function insert(id, payload, modified) {
- return this.insertBSO(new ServerBSO(id, payload, modified));
- },
- /**
- * Removes an object entirely from the collection.
- *
- * @param id
- * (string) ID to remove.
- */
- remove: function remove(id) {
- delete this._bsos[id];
- },
- _inResultSet: function _inResultSet(bso, options) {
- if (!bso.payload) {
- return false;
- }
- if (options.ids) {
- if (options.ids.indexOf(bso.id) == -1) {
- return false;
- }
- }
- if (options.newer) {
- if (bso.modified <= options.newer) {
- return false;
- }
- }
- if (options.older) {
- if (bso.modified >= options.older) {
- return false;
- }
- }
- return true;
- },
- count: function count(options) {
- options = options || {};
- let c = 0;
- for (let [id, bso] of Object.entries(this._bsos)) {
- if (bso.modified && this._inResultSet(bso, options)) {
- c++;
- }
- }
- return c;
- },
- get: function get(options) {
- let data = [];
- for (let id in this._bsos) {
- let bso = this._bsos[id];
- if (!bso.modified) {
- continue;
- }
- if (!this._inResultSet(bso, options)) {
- continue;
- }
- data.push(bso);
- }
- if (options.sort) {
- if (options.sort == "oldest") {
- data.sort(function sortOldest(a, b) {
- if (a.modified == b.modified) {
- return 0;
- }
- return a.modified < b.modified ? -1 : 1;
- });
- } else if (options.sort == "newest") {
- data.sort(function sortNewest(a, b) {
- if (a.modified == b.modified) {
- return 0;
- }
- return a.modified > b.modified ? -1 : 1;
- });
- } else if (options.sort == "index") {
- data.sort(function sortIndex(a, b) {
- if (a.sortindex == b.sortindex) {
- return 0;
- }
- if (a.sortindex !== undefined && b.sortindex == undefined) {
- return 1;
- }
- if (a.sortindex === undefined && b.sortindex !== undefined) {
- return -1;
- }
- return a.sortindex > b.sortindex ? -1 : 1;
- });
- }
- }
- if (options.limit) {
- data = data.slice(0, options.limit);
- }
- return data;
- },
- post: function post(input, timestamp) {
- let success = [];
- let failed = {};
- let count = 0;
- let size = 0;
- // This will count records where we have an existing ServerBSO
- // registered with us as successful and all other records as failed.
- for (let record of input) {
- count += 1;
- if (count > this.BATCH_MAX_COUNT) {
- failed[record.id] = "Max record count exceeded.";
- continue;
- }
- if (typeof(record.payload) != "string") {
- failed[record.id] = "Payload is not a string!";
- continue;
- }
- size += record.payload.length;
- if (size > this.BATCH_MAX_SIZE) {
- failed[record.id] = "Payload max size exceeded!";
- continue;
- }
- if (record.sortindex) {
- if (!isInteger(record.sortindex)) {
- failed[record.id] = "sortindex is not an integer.";
- continue;
- }
- if (record.sortindex.length > 9) {
- failed[record.id] = "sortindex is too long.";
- continue;
- }
- }
- if ("ttl" in record) {
- if (!isInteger(record.ttl)) {
- failed[record.id] = "ttl is not an integer.";
- continue;
- }
- }
- try {
- let bso = this.bso(record.id);
- if (!bso && this.acceptNew) {
- this._log.debug("Creating BSO " + JSON.stringify(record.id) +
- " on the fly.");
- bso = new ServerBSO(record.id);
- this.insertBSO(bso);
- }
- if (bso) {
- bso.payload = record.payload;
- bso.modified = timestamp;
- bso.deleted = false;
- success.push(record.id);
- if (record.sortindex) {
- bso.sortindex = parseInt(record.sortindex, 10);
- }
- } else {
- failed[record.id] = "no bso configured";
- }
- } catch (ex) {
- this._log.info("Exception when processing BSO", ex);
- failed[record.id] = "Exception when processing.";
- }
- }
- return {success: success, failed: failed};
- },
- delete: function delete_(options) {
- options = options || {};
- // Protocol 2.0 only allows the "ids" query string argument.
- let keys = Object.keys(options).filter(function(k) {
- return k != "ids";
- });
- if (keys.length) {
- this._log.warn("Invalid query string parameter to collection delete: " +
- keys.join(", "));
- throw new Error("Malformed client request.");
- }
- if (options.ids && options.ids.length > this.BATCH_MAX_COUNT) {
- throw HTTP_400;
- }
- let deleted = [];
- for (let [id, bso] of Object.entries(this._bsos)) {
- if (this._inResultSet(bso, options)) {
- this._log.debug("Deleting " + JSON.stringify(bso));
- deleted.push(bso.id);
- bso.delete();
- }
- }
- return deleted;
- },
- parseOptions: function parseOptions(request) {
- let options = {};
- for (let chunk of request.queryString.split("&")) {
- if (!chunk) {
- continue;
- }
- chunk = chunk.split("=");
- let key = decodeURIComponent(chunk[0]);
- if (chunk.length == 1) {
- options[key] = "";
- } else {
- options[key] = decodeURIComponent(chunk[1]);
- }
- }
- if (options.ids) {
- options.ids = options.ids.split(",");
- }
- if (options.newer) {
- if (!isInteger(options.newer)) {
- throw HTTP_400;
- }
- CommonUtils.ensureMillisecondsTimestamp(options.newer);
- options.newer = parseInt(options.newer, 10);
- }
- if (options.older) {
- if (!isInteger(options.older)) {
- throw HTTP_400;
- }
- CommonUtils.ensureMillisecondsTimestamp(options.older);
- options.older = parseInt(options.older, 10);
- }
- if (options.limit) {
- if (!isInteger(options.limit)) {
- throw HTTP_400;
- }
- options.limit = parseInt(options.limit, 10);
- }
- return options;
- },
- getHandler: function getHandler(request, response) {
- let options = this.parseOptions(request);
- let data = this.get(options);
- if (request.hasHeader("x-if-modified-since")) {
- let requestModified = parseInt(request.getHeader("x-if-modified-since"),
- 10);
- let newestBSO = 0;
- for (let bso of data) {
- if (bso.modified > newestBSO) {
- newestBSO = bso.modified;
- }
- }
- if (requestModified >= newestBSO) {
- response.setHeader("X-Last-Modified", "" + newestBSO);
- response.setStatusLine(request.httpVersion, 304, "Not Modified");
- return;
- }
- } else if (request.hasHeader("x-if-unmodified-since")) {
- let requestModified = parseInt(request.getHeader("x-if-unmodified-since"),
- 10);
- let serverModified = this.timestamp;
- if (serverModified > requestModified) {
- response.setHeader("X-Last-Modified", "" + serverModified);
- response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
- return;
- }
- }
- if (options.full) {
- data = data.map(function map(bso) {
- return bso.toJSON();
- });
- } else {
- data = data.map(function map(bso) {
- return bso.id;
- });
- }
- // application/json is default media type.
- let newlines = false;
- if (request.hasHeader("accept")) {
- let accept = request.getHeader("accept");
- if (accept == "application/newlines") {
- newlines = true;
- } else if (accept != "application/json") {
- throw HTTP_406;
- }
- }
- let body;
- if (newlines) {
- response.setHeader("Content-Type", "application/newlines", false);
- let normalized = data.map(function map(d) {
- return JSON.stringify(d);
- });
- body = normalized.join("\n") + "\n";
- } else {
- response.setHeader("Content-Type", "application/json", false);
- body = JSON.stringify({items: data});
- }
- this._log.info("Records: " + data.length);
- response.setHeader("X-Num-Records", "" + data.length, false);
- response.setHeader("X-Last-Modified", "" + this.timestamp, false);
- response.setStatusLine(request.httpVersion, 200, "OK");
- response.bodyOutputStream.write(body, body.length);
- },
- postHandler: function postHandler(request, response) {
- let options = this.parseOptions(request);
- if (!request.hasHeader("content-type")) {
- this._log.info("No Content-Type request header!");
- throw HTTP_400;
- }
- let inputStream = request.bodyInputStream;
- let inputBody = CommonUtils.readBytesFromInputStream(inputStream);
- let input = [];
- let inputMediaType = request.getHeader("content-type");
- if (inputMediaType == "application/json") {
- try {
- input = JSON.parse(inputBody);
- } catch (ex) {
- this._log.info("JSON parse error on input body!");
- throw HTTP_400;
- }
- if (!Array.isArray(input)) {
- this._log.info("Input JSON type not an array!");
- return sendMozSvcError(request, response, "8");
- }
- } else if (inputMediaType == "application/newlines") {
- for (let line of inputBody.split("\n")) {
- let record;
- try {
- record = JSON.parse(line);
- } catch (ex) {
- this._log.info("JSON parse error on line!");
- return sendMozSvcError(request, response, "8");
- }
- input.push(record);
- }
- } else {
- this._log.info("Unknown media type: " + inputMediaType);
- throw HTTP_415;
- }
- if (this._ensureUnmodifiedSince(request, response)) {
- return;
- }
- let res = this.post(input, request.timestamp);
- let body = JSON.stringify(res);
- response.setHeader("Content-Type", "application/json", false);
- this.timestamp = request.timestamp;
- response.setHeader("X-Last-Modified", "" + this.timestamp, false);
- response.setStatusLine(request.httpVersion, "200", "OK");
- response.bodyOutputStream.write(body, body.length);
- },
- deleteHandler: function deleteHandler(request, response) {
- this._log.debug("Invoking StorageServerCollection.DELETE.");
- let options = this.parseOptions(request);
- if (this._ensureUnmodifiedSince(request, response)) {
- return;
- }
- let deleted = this.delete(options);
- response.deleted = deleted;
- this.timestamp = request.timestamp;
- response.setStatusLine(request.httpVersion, 204, "No Content");
- },
- handler: function handler() {
- let self = this;
- return function(request, response) {
- switch(request.method) {
- case "GET":
- return self.getHandler(request, response);
- case "POST":
- return self.postHandler(request, response);
- case "DELETE":
- return self.deleteHandler(request, response);
- }
- request.setHeader("Allow", "GET,POST,DELETE");
- response.setStatusLine(request.httpVersion, 405, "Method Not Allowed");
- };
- },
- _ensureUnmodifiedSince: function _ensureUnmodifiedSince(request, response) {
- if (!request.hasHeader("x-if-unmodified-since")) {
- return false;
- }
- let requestModified = parseInt(request.getHeader("x-if-unmodified-since"),
- 10);
- let serverModified = this.timestamp;
- this._log.debug("Request modified time: " + requestModified +
- "; Server modified time: " + serverModified);
- if (serverModified <= requestModified) {
- return false;
- }
- this._log.info("Conditional request rejected because client time older " +
- "than collection timestamp.");
- response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
- return true;
- },
- };
- //===========================================================================//
- // httpd.js-based Storage server. //
- //===========================================================================//
- /**
- * In general, the preferred way of using StorageServer is to directly
- * introspect it. Callbacks are available for operations which are hard to
- * verify through introspection, such as deletions.
- *
- * One of the goals of this server is to provide enough hooks for test code to
- * find out what it needs without monkeypatching. Use this object as your
- * prototype, and override as appropriate.
- */
- this.StorageServerCallback = {
- onCollectionDeleted: function onCollectionDeleted(user, collection) {},
- onItemDeleted: function onItemDeleted(user, collection, bsoID) {},
- /**
- * Called at the top of every request.
- *
- * Allows the test to inspect the request. Hooks should be careful not to
- * modify or change state of the request or they may impact future processing.
- */
- onRequest: function onRequest(request) {},
- };
- /**
- * Construct a new test Storage server. Takes a callback object (e.g.,
- * StorageServerCallback) as input.
- */
- this.StorageServer = function StorageServer(callback) {
- this.callback = callback || {__proto__: StorageServerCallback};
- this.server = new HttpServer();
- this.started = false;
- this.users = {};
- this.requestCount = 0;
- this._log = Log.repository.getLogger(STORAGE_HTTP_LOGGER);
- // Install our own default handler. This allows us to mess around with the
- // whole URL space.
- let handler = this.server._handler;
- handler._handleDefault = this.handleDefault.bind(this, handler);
- }
- StorageServer.prototype = {
- DEFAULT_QUOTA: 1024 * 1024, // # bytes.
- server: null, // HttpServer.
- users: null, // Map of username => {collections, password}.
- /**
- * If true, the server will allow any arbitrary user to be used.
- *
- * No authentication will be performed. Whatever user is detected from the
- * URL or auth headers will be created (if needed) and used.
- */
- allowAllUsers: false,
- /**
- * Start the StorageServer's underlying HTTP server.
- *
- * @param port
- * The numeric port on which to start. A falsy value implies to
- * select any available port.
- * @param cb
- * A callback function (of no arguments) which is invoked after
- * startup.
- */
- start: function start(port, cb) {
- if (this.started) {
- this._log.warn("Warning: server already started on " + this.port);
- return;
- }
- if (!port) {
- port = -1;
- }
- this.port = port;
- try {
- this.server.start(this.port);
- this.port = this.server.identity.primaryPort;
- this.started = true;
- if (cb) {
- cb();
- }
- } catch (ex) {
- _("==========================================");
- _("Got exception starting Storage HTTP server on port " + this.port);
- _("Error: " + Log.exceptionStr(ex));
- _("Is there a process already listening on port " + this.port + "?");
- _("==========================================");
- do_throw(ex);
- }
- },
- /**
- * Start the server synchronously.
- *
- * @param port
- * The numeric port on which to start. The default is to choose
- * any available port.
- */
- startSynchronous: function startSynchronous(port=-1) {
- let cb = Async.makeSpinningCallback();
- this.start(port, cb);
- cb.wait();
- },
- /**
- * Stop the StorageServer's HTTP server.
- *
- * @param cb
- * A callback function. Invoked after the server has been stopped.
- *
- */
- stop: function stop(cb) {
- if (!this.started) {
- this._log.warn("StorageServer: Warning: server not running. Can't stop " +
- "me now!");
- return;
- }
- this.server.stop(cb);
- this.started = false;
- },
- serverTime: function serverTime() {
- return new_timestamp();
- },
- /**
- * Create a new user, complete with an empty set of collections.
- *
- * @param username
- * The username to use. An Error will be thrown if a user by that name
- * already exists.
- * @param password
- * A password string.
- *
- * @return a user object, as would be returned by server.user(username).
- */
- registerUser: function registerUser(username, password) {
- if (username in this.users) {
- throw new Error("User already exists.");
- }
- if (!isFinite(parseInt(username))) {
- throw new Error("Usernames must be numeric: " + username);
- }
- this._log.info("Registering new user with server: " + username);
- this.users[username] = {
- password: password,
- collections: {},
- quota: this.DEFAULT_QUOTA,
- };
- return this.user(username);
- },
- userExists: function userExists(username) {
- return username in this.users;
- },
- getCollection: function getCollection(username, collection) {
- return this.users[username].collections[collection];
- },
- _insertCollection: function _insertCollection(collections, collection, bsos) {
- let coll = new StorageServerCollection(bsos, true);
- coll.collectionHandler = coll.handler();
- collections[collection] = coll;
- return coll;
- },
- createCollection: function createCollection(username, collection, bsos) {
- if (!(username in this.users)) {
- throw new Error("Unknown user.");
- }
- let collections = this.users[username].collections;
- if (collection in collections) {
- throw new Error("Collection already exists.");
- }
- return this._insertCollection(collections, collection, bsos);
- },
- deleteCollection: function deleteCollection(username, collection) {
- if (!(username in this.users)) {
- throw new Error("Unknown user.");
- }
- delete this.users[username].collections[collection];
- },
- /**
- * Accept a map like the following:
- * {
- * meta: {global: {version: 1, ...}},
- * crypto: {"keys": {}, foo: {bar: 2}},
- * bookmarks: {}
- * }
- * to cause collections and BSOs to be created.
- * If a collection already exists, no error is raised.
- * If a BSO already exists, it will be updated to the new contents.
- */
- createContents: function createContents(username, collections) {
- if (!(username in this.users)) {
- throw new Error("Unknown user.");
- }
- let userCollections = this.users[username].collections;
- for (let [id, contents] of Object.entries(collections)) {
- let coll = userCollections[id] ||
- this._insertCollection(userCollections, id);
- for (let [bsoID, payload] of Object.entries(contents)) {
- coll.insert(bsoID, payload);
- }
- }
- },
- /**
- * Insert a BSO in an existing collection.
- */
- insertBSO: function insertBSO(username, collection, bso) {
- if (!(username in this.users)) {
- throw new Error("Unknown user.");
- }
- let userCollections = this.users[username].collections;
- if (!(collection in userCollections)) {
- throw new Error("Unknown collection.");
- }
- userCollections[collection].insertBSO(bso);
- return bso;
- },
- /**
- * Delete all of the collections for the named user.
- *
- * @param username
- * The name of the affected user.
- */
- deleteCollections: function deleteCollections(username) {
- if (!(username in this.users)) {
- throw new Error("Unknown user.");
- }
- let userCollections = this.users[username].collections;
- for (let name in userCollections) {
- let coll = userCollections[name];
- this._log.trace("Bulk deleting " + name + " for " + username + "...");
- coll.delete({});
- }
- this.users[username].collections = {};
- },
- getQuota: function getQuota(username) {
- if (!(username in this.users)) {
- throw new Error("Unknown user.");
- }
- return this.users[username].quota;
- },
- /**
- * Obtain the newest timestamp of all collections for a user.
- */
- newestCollectionTimestamp: function newestCollectionTimestamp(username) {
- let collections = this.users[username].collections;
- let newest = 0;
- for (let name in collections) {
- let collection = collections[name];
- if (collection.timestamp > newest) {
- newest = collection.timestamp;
- }
- }
- return newest;
- },
- /**
- * Compute the object that is returned for an info/collections request.
- */
- infoCollections: function infoCollections(username) {
- let responseObject = {};
- let colls = this.users[username].collections;
- for (let coll in colls) {
- responseObject[coll] = colls[coll].timestamp;
- }
- this._log.trace("StorageServer: info/collections returning " +
- JSON.stringify(responseObject));
- return responseObject;
- },
- infoCounts: function infoCounts(username) {
- let data = {};
- let collections = this.users[username].collections;
- for (let [k, v] of Object.entries(collections)) {
- let count = v.count();
- if (!count) {
- continue;
- }
- data[k] = count;
- }
- return data;
- },
- infoUsage: function infoUsage(username) {
- let data = {};
- let collections = this.users[username].collections;
- for (let [k, v] of Object.entries(collections)) {
- data[k] = v.totalPayloadSize;
- }
- return data;
- },
- infoQuota: function infoQuota(username) {
- let total = 0;
- let usage = this.infoUsage(username);
- for (let key in usage) {
- let value = usage[key];
- total += value;
- }
- return {
- quota: this.getQuota(username),
- usage: total
- };
- },
- /**
- * Simple accessor to allow collective binding and abbreviation of a bunch of
- * methods. Yay!
- * Use like this:
- *
- * let u = server.user("john");
- * u.collection("bookmarks").bso("abcdefg").payload; // Etc.
- *
- * @return a proxy for the user data stored in this server.
- */
- user: function user(username) {
- let collection = this.getCollection.bind(this, username);
- let createCollection = this.createCollection.bind(this, username);
- let createContents = this.createContents.bind(this, username);
- let modified = function (collectionName) {
- return collection(collectionName).timestamp;
- }
- let deleteCollections = this.deleteCollections.bind(this, username);
- let quota = this.getQuota.bind(this, username);
- return {
- collection: collection,
- createCollection: createCollection,
- createContents: createContents,
- deleteCollections: deleteCollections,
- modified: modified,
- quota: quota,
- };
- },
- _pruneExpired: function _pruneExpired() {
- let now = Date.now();
- for (let username in this.users) {
- let user = this.users[username];
- for (let name in user.collections) {
- let collection = user.collections[name];
- for (let bso of collection.bsos()) {
- // ttl === 0 is a special case, so we can't simply !ttl.
- if (typeof(bso.ttl) != "number") {
- continue;
- }
- let ttlDate = bso.modified + (bso.ttl * 1000);
- if (ttlDate < now) {
- this._log.info("Deleting BSO because TTL expired: " + bso.id);
- bso.delete();
- }
- }
- }
- }
- },
- /*
- * Regular expressions for splitting up Storage request paths.
- * Storage URLs are of the form:
- * /$apipath/$version/$userid/$further
- * where $further is usually:
- * storage/$collection/$bso
- * or
- * storage/$collection
- * or
- * info/$op
- *
- * We assume for the sake of simplicity that $apipath is empty.
- *
- * N.B., we don't follow any kind of username spec here, because as far as I
- * can tell there isn't one. See Bug 689671. Instead we follow the Python
- * server code.
- *
- * Path: [all, version, first, rest]
- * Storage: [all, collection?, id?]
- */
- pathRE: /^\/([0-9]+(?:\.[0-9]+)?)(?:\/([0-9]+)\/([^\/]+)(?:\/(.+))?)?$/,
- storageRE: /^([-_a-zA-Z0-9]+)(?:\/([-_a-zA-Z0-9]+)\/?)?$/,
- defaultHeaders: {},
- /**
- * HTTP response utility.
- */
- respond: function respond(req, resp, code, status, body, headers, timestamp) {
- this._log.info("Response: " + code + " " + status);
- resp.setStatusLine(req.httpVersion, code, status);
- if (!headers) {
- headers = this.defaultHeaders;
- }
- for (let header in headers) {
- let value = headers[header];
- resp.setHeader(header, value, false);
- }
- if (timestamp) {
- resp.setHeader("X-Timestamp", "" + timestamp, false);
- }
- if (body) {
- resp.bodyOutputStream.write(body, body.length);
- }
- },
- /**
- * This is invoked by the HttpServer. `this` is bound to the StorageServer;
- * `handler` is the HttpServer's handler.
- *
- * TODO: need to use the correct Storage API response codes and errors here.
- */
- handleDefault: function handleDefault(handler, req, resp) {
- this.requestCount++;
- let timestamp = new_timestamp();
- try {
- this._handleDefault(handler, req, resp, timestamp);
- } catch (e) {
- if (e instanceof HttpError) {
- this.respond(req, resp, e.code, e.description, "", {}, timestamp);
- } else {
- this._log.warn("StorageServer: handleDefault caught an error", e);
- throw e;
- }
- }
- },
- _handleDefault: function _handleDefault(handler, req, resp, timestamp) {
- let path = req.path;
- if (req.queryString.length) {
- path += "?" + req.queryString;
- }
- this._log.debug("StorageServer: Handling request: " + req.method + " " +
- path);
- if (this.callback.onRequest) {
- this.callback.onRequest(req);
- }
- // Prune expired records for all users at top of request. This is the
- // easiest way to process TTLs since all requests go through here.
- this._pruneExpired();
- req.timestamp = timestamp;
- resp.setHeader("X-Timestamp", "" + timestamp, false);
- let parts = this.pathRE.exec(req.path);
- if (!parts) {
- this._log.debug("StorageServer: Unexpected request: bad URL " + req.path);
- throw HTTP_404;
- }
- let [all, version, userPath, first, rest] = parts;
- if (version != STORAGE_API_VERSION) {
- this._log.debug("StorageServer: Unknown version.");
- throw HTTP_404;
- }
- let username;
- // By default, the server requires users to be authenticated. When a
- // request arrives, the user must have been previously configured and
- // the request must have authentication. In "allow all users" mode, we
- // take the username from the URL, create the user on the fly, and don't
- // perform any authentication.
- if (!this.allowAllUsers) {
- // Enforce authentication.
- if (!req.hasHeader("authorization")) {
- this.respond(req, resp, 401, "Authorization Required", "{}", {
- "WWW-Authenticate": 'Basic realm="secret"'
- });
- return;
- }
- let ensureUserExists = function ensureUserExists(username) {
- if (this.userExists(username)) {
- return;
- }
- this._log.info("StorageServer: Unknown user: " + username);
- throw HTTP_401;
- }.bind(this);
- let auth = req.getHeader("authorization");
- this._log.debug("Authorization: " + auth);
- if (auth.indexOf("Basic ") == 0) {
- let decoded = CommonUtils.safeAtoB(auth.substr(6));
- this._log.debug("Decoded Basic Auth: " + decoded);
- let [user, password] = decoded.split(":", 2);
- if (!password) {
- this._log.debug("Malformed HTTP Basic Authorization header: " + auth);
- throw HTTP_400;
- }
- this._log.debug("Got HTTP Basic auth for user: " + user);
- ensureUserExists(user);
- username = user;
- if (this.users[user].password != password) {
- this._log.debug("StorageServer: Provided password is not correct.");
- throw HTTP_401;
- }
- // TODO support token auth.
- } else {
- this._log.debug("Unsupported HTTP authorization type: " + auth);
- throw HTTP_500;
- }
- // All users mode.
- } else {
- // Auto create user with dummy password.
- if (!this.userExists(userPath)) {
- this.registerUser(userPath, "DUMMY-PASSWORD-*&%#");
- }
- username = userPath;
- }
- // Hand off to the appropriate handler for this path component.
- if (first in this.toplevelHandlers) {
- let handler = this.toplevelHandlers[first];
- try {
- return handler.call(this, handler, req, resp, version, username, rest);
- } catch (ex) {
- this._log.warn("Got exception during request", ex);
- throw ex;
- }
- }
- this._log.debug("StorageServer: Unknown top-level " + first);
- throw HTTP_404;
- },
- /**
- * Collection of the handler methods we use for top-level path components.
- */
- toplevelHandlers: {
- "storage": function handleStorage(handler, req, resp, version, username,
- rest) {
- let respond = this.respond.bind(this, req, resp);
- if (!rest || !rest.length) {
- this._log.debug("StorageServer: top-level storage " +
- req.method + " request.");
- if (req.method != "DELETE") {
- respond(405, "Method Not Allowed", null, {"Allow": "DELETE"});
- return;
- }
- this.user(username).deleteCollections();
- respond(204, "No Content");
- return;
- }
- let match = this.storageRE.exec(rest);
- if (!match) {
- this._log.warn("StorageServer: Unknown storage operation " + rest);
- throw HTTP_404;
- }
- let [all, collection, bsoID] = match;
- let coll = this.getCollection(username, collection);
- let collectionExisted = !!coll;
- switch (req.method) {
- case "GET":
- // Tried to GET on a collection that doesn't exist.
- if (!coll) {
- respond(404, "Not Found");
- return;
- }
- // No BSO URL parameter goes to collection handler.
- if (!bsoID) {
- return coll.collectionHandler(req, resp);
- }
- // Handle non-existent BSO.
- let bso = coll.bso(bsoID);
- if (!bso) {
- respond(404, "Not Found");
- return;
- }
- // Proxy to BSO handler.
- return bso.getHandler(req, resp);
- case "DELETE":
- // Collection doesn't exist.
- if (!coll) {
- respond(404, "Not Found");
- return;
- }
- // Deleting a specific BSO.
- if (bsoID) {
- let bso = coll.bso(bsoID);
- // BSO does not exist on the server. Nothing to do.
- if (!bso) {
- respond(404, "Not Found");
- return;
- }
- if (req.hasHeader("x-if-unmodified-since")) {
- let modified = parseInt(req.getHeader("x-if-unmodified-since"));
- CommonUtils.ensureMillisecondsTimestamp(modified);
- if (bso.modified > modified) {
- respond(412, "Precondition Failed");
- return;
- }
- }
- bso.delete();
- coll.timestamp = req.timestamp;
- this.callback.onItemDeleted(username, collection, bsoID);
- respond(204, "No Content");
- return;
- }
- // Proxy to collection handler.
- coll.collectionHandler(req, resp);
- // Spot if this is a DELETE for some IDs, and don't blow away the
- // whole collection!
- //
- // We already handled deleting the BSOs by invoking the deleted
- // collection's handler. However, in the case of
- //
- // DELETE storage/foobar
- //
- // we also need to remove foobar from the collections map. This
- // clause tries to differentiate the above request from
- //
- // DELETE storage/foobar?ids=foo,baz
- //
- // and do the right thing.
- // TODO: less hacky method.
- if (-1 == req.queryString.indexOf("ids=")) {
- // When you delete the entire collection, we drop it.
- this._log.debug("Deleting entire collection.");
- delete this.users[username].collections[collection];
- this.callback.onCollectionDeleted(username, collection);
- }
- // Notify of item deletion.
- let deleted = resp.deleted || [];
- for (let i = 0; i < deleted.length; ++i) {
- this.callback.onItemDeleted(username, collection, deleted[i]);
- }
- return;
- case "POST":
- case "PUT":
- // Auto-create collection if it doesn't exist.
- if (!coll) {
- coll = this.createCollection(username, collection);
- }
- try {
- if (bsoID) {
- let bso = coll.bso(bsoID);
- if (!bso) {
- this._log.trace("StorageServer: creating BSO " + collection +
- "/" + bsoID);
- try {
- bso = coll.insert(bsoID);
- } catch (ex) {
- return sendMozSvcError(req, resp, "8");
- }
- }
- bso.putHandler(req, resp);
- coll.timestamp = req.timestamp;
- return resp;
- }
- return coll.collectionHandler(req, resp);
- } catch (ex) {
- if (ex instanceof HttpError) {
- if (!collectionExisted) {
- this.deleteCollection(username, collection);
- }
- }
- throw ex;
- }
- default:
- throw new Error("Request method " + req.method + " not implemented.");
- }
- },
- "info": function handleInfo(handler, req, resp, version, username, rest) {
- switch (rest) {
- case "collections":
- return this.handleInfoCollections(req, resp, username);
- case "collection_counts":
- return this.handleInfoCounts(req, resp, username);
- case "collection_usage":
- return this.handleInfoUsage(req, resp, username);
- case "quota":
- return this.handleInfoQuota(req, resp, username);
- default:
- this._log.warn("StorageServer: Unknown info operation " + rest);
- throw HTTP_404;
- }
- }
- },
- handleInfoConditional: function handleInfoConditional(request, response,
- user) {
- if (!request.hasHeader("x-if-modified-since")) {
- return false;
- }
- let requestModified = request.getHeader("x-if-modified-since");
- requestModified = parseInt(requestModified, 10);
- let serverModified = this.newestCollectionTimestamp(user);
- this._log.info("Server mtime: " + serverModified + "; Client modified: " +
- requestModified);
- if (serverModified > requestModified) {
- return false;
- }
- this.respond(request, response, 304, "Not Modified", null, {
- "X-Last-Modified": "" + serverModified
- });
- return true;
- },
- handleInfoCollections: function handleInfoCollections(request, response,
- user) {
- if (this.handleInfoConditional(request, response, user)) {
- return;
- }
- let info = this.infoCollections(user);
- let body = JSON.stringify(info);
- this.respond(request, response, 200, "OK", body, {
- "Content-Type": "application/json",
- "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
- });
- },
- handleInfoCounts: function handleInfoCounts(request, response, user) {
- if (this.handleInfoConditional(request, response, user)) {
- return;
- }
- let counts = this.infoCounts(user);
- let body = JSON.stringify(counts);
- this.respond(request, response, 200, "OK", body, {
- "Content-Type": "application/json",
- "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
- });
- },
- handleInfoUsage: function handleInfoUsage(request, response, user) {
- if (this.handleInfoConditional(request, response, user)) {
- return;
- }
- let body = JSON.stringify(this.infoUsage(user));
- this.respond(request, response, 200, "OK", body, {
- "Content-Type": "application/json",
- "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
- });
- },
- handleInfoQuota: function handleInfoQuota(request, response, user) {
- if (this.handleInfoConditional(request, response, user)) {
- return;
- }
- let body = JSON.stringify(this.infoQuota(user));
- this.respond(request, response, 200, "OK", body, {
- "Content-Type": "application/json",
- "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
- });
- },
- };
- /**
- * Helper to create a storage server for a set of users.
- *
- * Each user is specified by a map of username to password.
- */
- this.storageServerForUsers =
- function storageServerForUsers(users, contents, callback) {
- let server = new StorageServer(callback);
- for (let [user, pass] of Object.entries(users)) {
- server.registerUser(user, pass);
- server.createContents(user, contents);
- }
- server.start();
- return server;
- }
|