michael@0: const Cm = Components.manager; michael@0: michael@0: // Shared logging for all HTTP server functions. michael@0: Cu.import("resource://gre/modules/Log.jsm"); michael@0: const SYNC_HTTP_LOGGER = "Sync.Test.Server"; michael@0: const SYNC_API_VERSION = "1.1"; michael@0: michael@0: // Use the same method that record.js does, which mirrors the server. michael@0: // The server returns timestamps with 1/100 sec granularity. Note that this is michael@0: // subject to change: see Bug 650435. michael@0: function new_timestamp() { michael@0: return Math.round(Date.now() / 10) / 100; michael@0: } michael@0: michael@0: function return_timestamp(request, response, timestamp) { michael@0: if (!timestamp) { michael@0: timestamp = new_timestamp(); michael@0: } michael@0: let body = "" + timestamp; michael@0: response.setHeader("X-Weave-Timestamp", body); michael@0: response.setStatusLine(request.httpVersion, 200, "OK"); michael@0: response.bodyOutputStream.write(body, body.length); michael@0: return timestamp; michael@0: } michael@0: michael@0: function basic_auth_header(user, password) { michael@0: return "Basic " + btoa(user + ":" + Utils.encodeUTF8(password)); michael@0: } michael@0: michael@0: function basic_auth_matches(req, user, password) { michael@0: if (!req.hasHeader("Authorization")) { michael@0: return false; michael@0: } michael@0: michael@0: let expected = basic_auth_header(user, Utils.encodeUTF8(password)); michael@0: return req.getHeader("Authorization") == expected; michael@0: } michael@0: michael@0: function httpd_basic_auth_handler(body, metadata, response) { michael@0: if (basic_auth_matches(metadata, "guest", "guest")) { michael@0: response.setStatusLine(metadata.httpVersion, 200, "OK, authorized"); michael@0: response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); michael@0: } else { michael@0: body = "This path exists and is protected - failed"; michael@0: response.setStatusLine(metadata.httpVersion, 401, "Unauthorized"); michael@0: response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); michael@0: } michael@0: response.bodyOutputStream.write(body, body.length); michael@0: } michael@0: michael@0: /* michael@0: * Represent a WBO on the server michael@0: */ michael@0: function ServerWBO(id, initialPayload, modified) { michael@0: if (!id) { michael@0: throw "No ID for ServerWBO!"; michael@0: } michael@0: this.id = id; michael@0: if (!initialPayload) { michael@0: return; michael@0: } michael@0: michael@0: if (typeof initialPayload == "object") { michael@0: initialPayload = JSON.stringify(initialPayload); michael@0: } michael@0: this.payload = initialPayload; michael@0: this.modified = modified || new_timestamp(); michael@0: } michael@0: ServerWBO.prototype = { michael@0: michael@0: get data() { michael@0: return JSON.parse(this.payload); michael@0: }, michael@0: michael@0: get: function() { michael@0: return JSON.stringify(this, ["id", "modified", "payload"]); michael@0: }, michael@0: michael@0: put: function(input) { michael@0: input = JSON.parse(input); michael@0: this.payload = input.payload; michael@0: this.modified = new_timestamp(); michael@0: }, michael@0: michael@0: delete: function() { michael@0: delete this.payload; michael@0: delete this.modified; michael@0: }, michael@0: michael@0: // This handler sets `newModified` on the response body if the collection michael@0: // timestamp has changed. This allows wrapper handlers to extract information michael@0: // that otherwise would exist only in the body stream. michael@0: handler: function() { michael@0: let self = this; michael@0: michael@0: return function(request, response) { michael@0: var statusCode = 200; michael@0: var status = "OK"; michael@0: var body; michael@0: michael@0: switch(request.method) { michael@0: case "GET": michael@0: if (self.payload) { michael@0: body = self.get(); michael@0: } else { michael@0: statusCode = 404; michael@0: status = "Not Found"; michael@0: body = "Not Found"; michael@0: } michael@0: break; michael@0: michael@0: case "PUT": michael@0: self.put(readBytesFromInputStream(request.bodyInputStream)); michael@0: body = JSON.stringify(self.modified); michael@0: response.setHeader("Content-Type", "application/json"); michael@0: response.newModified = self.modified; michael@0: break; michael@0: michael@0: case "DELETE": michael@0: self.delete(); michael@0: let ts = new_timestamp(); michael@0: body = JSON.stringify(ts); michael@0: response.setHeader("Content-Type", "application/json"); michael@0: response.newModified = ts; michael@0: break; michael@0: } michael@0: response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false); michael@0: response.setStatusLine(request.httpVersion, statusCode, status); michael@0: response.bodyOutputStream.write(body, body.length); michael@0: }; michael@0: } michael@0: michael@0: }; michael@0: michael@0: michael@0: /** michael@0: * Represent a collection on the server. The '_wbos' attribute is a michael@0: * mapping of id -> ServerWBO objects. michael@0: * michael@0: * Note that if you want these records to be accessible individually, michael@0: * you need to register their handlers with the server separately, or use a michael@0: * containing HTTP server that will do so on your behalf. michael@0: * michael@0: * @param wbos michael@0: * An object mapping WBO IDs to ServerWBOs. michael@0: * @param acceptNew michael@0: * If true, POSTs to this collection URI will result in new WBOs being michael@0: * created and wired in on the fly. michael@0: * @param timestamp michael@0: * An optional timestamp value to initialize the modified time of the michael@0: * collection. This should be in the format returned by new_timestamp(). michael@0: * michael@0: * @return the new ServerCollection instance. michael@0: * michael@0: */ michael@0: function ServerCollection(wbos, acceptNew, timestamp) { michael@0: this._wbos = wbos || {}; michael@0: this.acceptNew = acceptNew || false; michael@0: michael@0: /* michael@0: * Track modified timestamp. michael@0: * We can't just use the timestamps of contained WBOs: an empty collection michael@0: * has a modified time. michael@0: */ michael@0: this.timestamp = timestamp || new_timestamp(); michael@0: this._log = Log.repository.getLogger(SYNC_HTTP_LOGGER); michael@0: } michael@0: ServerCollection.prototype = { michael@0: michael@0: /** michael@0: * Convenience accessor for our WBO keys. michael@0: * Excludes deleted items, of course. michael@0: * michael@0: * @param filter michael@0: * A predicate function (applied to the ID and WBO) which dictates michael@0: * whether to include the WBO's ID in the output. michael@0: * michael@0: * @return an array of IDs. michael@0: */ michael@0: keys: function keys(filter) { michael@0: return [id for ([id, wbo] in Iterator(this._wbos)) michael@0: if (wbo.payload && michael@0: (!filter || filter(id, wbo)))]; michael@0: }, michael@0: michael@0: /** michael@0: * Convenience method to get an array of WBOs. michael@0: * Optionally provide a filter function. michael@0: * michael@0: * @param filter michael@0: * A predicate function, applied to the WBO, which dictates whether to michael@0: * include the WBO in the output. michael@0: * michael@0: * @return an array of ServerWBOs. michael@0: */ michael@0: wbos: function wbos(filter) { michael@0: let os = [wbo for ([id, wbo] in Iterator(this._wbos)) michael@0: if (wbo.payload)]; michael@0: if (filter) { michael@0: return os.filter(filter); michael@0: } michael@0: return os; michael@0: }, michael@0: michael@0: /** michael@0: * Convenience method to get an array of parsed ciphertexts. michael@0: * michael@0: * @return an array of the payloads of each stored WBO. michael@0: */ michael@0: payloads: function () { michael@0: return this.wbos().map(function (wbo) { michael@0: return JSON.parse(JSON.parse(wbo.payload).ciphertext); michael@0: }); michael@0: }, michael@0: michael@0: // Just for syntactic elegance. michael@0: wbo: function wbo(id) { michael@0: return this._wbos[id]; michael@0: }, michael@0: michael@0: payload: function payload(id) { michael@0: return this.wbo(id).payload; michael@0: }, michael@0: michael@0: /** michael@0: * Insert the provided WBO under its ID. michael@0: * michael@0: * @return the provided WBO. michael@0: */ michael@0: insertWBO: function insertWBO(wbo) { michael@0: return this._wbos[wbo.id] = wbo; michael@0: }, michael@0: michael@0: /** michael@0: * Insert the provided payload as part of a new ServerWBO with the provided michael@0: * ID. michael@0: * michael@0: * @param id michael@0: * The GUID for the WBO. michael@0: * @param payload michael@0: * The payload, as provided to the ServerWBO constructor. michael@0: * @param modified michael@0: * An optional modified time for the ServerWBO. michael@0: * michael@0: * @return the inserted WBO. michael@0: */ michael@0: insert: function insert(id, payload, modified) { michael@0: return this.insertWBO(new ServerWBO(id, payload, modified)); michael@0: }, michael@0: michael@0: /** michael@0: * Removes an object entirely from the collection. michael@0: * michael@0: * @param id michael@0: * (string) ID to remove. michael@0: */ michael@0: remove: function remove(id) { michael@0: delete this._wbos[id]; michael@0: }, michael@0: michael@0: _inResultSet: function(wbo, options) { michael@0: return wbo.payload michael@0: && (!options.ids || (options.ids.indexOf(wbo.id) != -1)) michael@0: && (!options.newer || (wbo.modified > options.newer)); michael@0: }, michael@0: michael@0: count: function(options) { michael@0: options = options || {}; michael@0: let c = 0; michael@0: for (let [id, wbo] in Iterator(this._wbos)) { michael@0: if (wbo.modified && this._inResultSet(wbo, options)) { michael@0: c++; michael@0: } michael@0: } michael@0: return c; michael@0: }, michael@0: michael@0: get: function(options) { michael@0: let result; michael@0: if (options.full) { michael@0: let data = [wbo.get() for ([id, wbo] in Iterator(this._wbos)) michael@0: // Drop deleted. michael@0: if (wbo.modified && michael@0: this._inResultSet(wbo, options))]; michael@0: if (options.limit) { michael@0: data = data.slice(0, options.limit); michael@0: } michael@0: // Our implementation of application/newlines. michael@0: result = data.join("\n") + "\n"; michael@0: michael@0: // Use options as a backchannel to report count. michael@0: options.recordCount = data.length; michael@0: } else { michael@0: let data = [id for ([id, wbo] in Iterator(this._wbos)) michael@0: if (this._inResultSet(wbo, options))]; michael@0: if (options.limit) { michael@0: data = data.slice(0, options.limit); michael@0: } michael@0: result = JSON.stringify(data); michael@0: options.recordCount = data.length; michael@0: } michael@0: return result; michael@0: }, michael@0: michael@0: post: function(input) { michael@0: input = JSON.parse(input); michael@0: let success = []; michael@0: let failed = {}; michael@0: michael@0: // This will count records where we have an existing ServerWBO michael@0: // registered with us as successful and all other records as failed. michael@0: for each (let record in input) { michael@0: let wbo = this.wbo(record.id); michael@0: if (!wbo && this.acceptNew) { michael@0: this._log.debug("Creating WBO " + JSON.stringify(record.id) + michael@0: " on the fly."); michael@0: wbo = new ServerWBO(record.id); michael@0: this.insertWBO(wbo); michael@0: } michael@0: if (wbo) { michael@0: wbo.payload = record.payload; michael@0: wbo.modified = new_timestamp(); michael@0: success.push(record.id); michael@0: } else { michael@0: failed[record.id] = "no wbo configured"; michael@0: } michael@0: } michael@0: return {modified: new_timestamp(), michael@0: success: success, michael@0: failed: failed}; michael@0: }, michael@0: michael@0: delete: function(options) { michael@0: let deleted = []; michael@0: for (let [id, wbo] in Iterator(this._wbos)) { michael@0: if (this._inResultSet(wbo, options)) { michael@0: this._log.debug("Deleting " + JSON.stringify(wbo)); michael@0: deleted.push(wbo.id); michael@0: wbo.delete(); michael@0: } michael@0: } michael@0: return deleted; michael@0: }, michael@0: michael@0: // This handler sets `newModified` on the response body if the collection michael@0: // timestamp has changed. michael@0: handler: function() { michael@0: let self = this; michael@0: michael@0: return function(request, response) { michael@0: var statusCode = 200; michael@0: var status = "OK"; michael@0: var body; michael@0: michael@0: // Parse queryString michael@0: let options = {}; michael@0: for each (let chunk in request.queryString.split("&")) { michael@0: if (!chunk) { michael@0: continue; michael@0: } michael@0: chunk = chunk.split("="); michael@0: if (chunk.length == 1) { michael@0: options[chunk[0]] = ""; michael@0: } else { michael@0: options[chunk[0]] = chunk[1]; michael@0: } michael@0: } michael@0: if (options.ids) { michael@0: options.ids = options.ids.split(","); michael@0: } michael@0: if (options.newer) { michael@0: options.newer = parseFloat(options.newer); michael@0: } michael@0: if (options.limit) { michael@0: options.limit = parseInt(options.limit, 10); michael@0: } michael@0: michael@0: switch(request.method) { michael@0: case "GET": michael@0: body = self.get(options); michael@0: // "If supported by the db, this header will return the number of michael@0: // records total in the request body of any multiple-record GET michael@0: // request." michael@0: let records = options.recordCount; michael@0: self._log.info("Records: " + records); michael@0: if (records != null) { michael@0: response.setHeader("X-Weave-Records", "" + records); michael@0: } michael@0: break; michael@0: michael@0: case "POST": michael@0: let res = self.post(readBytesFromInputStream(request.bodyInputStream)); michael@0: body = JSON.stringify(res); michael@0: response.newModified = res.modified; michael@0: break; michael@0: michael@0: case "DELETE": michael@0: self._log.debug("Invoking ServerCollection.DELETE."); michael@0: let deleted = self.delete(options); michael@0: let ts = new_timestamp(); michael@0: body = JSON.stringify(ts); michael@0: response.newModified = ts; michael@0: response.deleted = deleted; michael@0: break; michael@0: } michael@0: response.setHeader("X-Weave-Timestamp", michael@0: "" + new_timestamp(), michael@0: false); michael@0: response.setStatusLine(request.httpVersion, statusCode, status); michael@0: response.bodyOutputStream.write(body, body.length); michael@0: michael@0: // Update the collection timestamp to the appropriate modified time. michael@0: // This is either a value set by the handler, or the current time. michael@0: if (request.method != "GET") { michael@0: this.timestamp = (response.newModified >= 0) ? michael@0: response.newModified : michael@0: new_timestamp(); michael@0: } michael@0: }; michael@0: } michael@0: michael@0: }; michael@0: michael@0: /* michael@0: * Test setup helpers. michael@0: */ michael@0: function sync_httpd_setup(handlers) { michael@0: handlers["/1.1/foo/storage/meta/global"] michael@0: = (new ServerWBO("global", {})).handler(); michael@0: return httpd_setup(handlers); michael@0: } michael@0: michael@0: /* michael@0: * Track collection modified times. Return closures. michael@0: */ michael@0: function track_collections_helper() { michael@0: michael@0: /* michael@0: * Our tracking object. michael@0: */ michael@0: let collections = {}; michael@0: michael@0: /* michael@0: * Update the timestamp of a collection. michael@0: */ michael@0: function update_collection(coll, ts) { michael@0: _("Updating collection " + coll + " to " + ts); michael@0: let timestamp = ts || new_timestamp(); michael@0: collections[coll] = timestamp; michael@0: } michael@0: michael@0: /* michael@0: * Invoke a handler, updating the collection's modified timestamp unless michael@0: * it's a GET request. michael@0: */ michael@0: function with_updated_collection(coll, f) { michael@0: return function(request, response) { michael@0: f.call(this, request, response); michael@0: michael@0: // Update the collection timestamp to the appropriate modified time. michael@0: // This is either a value set by the handler, or the current time. michael@0: if (request.method != "GET") { michael@0: update_collection(coll, response.newModified) michael@0: } michael@0: }; michael@0: } michael@0: michael@0: /* michael@0: * Return the info/collections object. michael@0: */ michael@0: function info_collections(request, response) { michael@0: let body = "Error."; michael@0: switch(request.method) { michael@0: case "GET": michael@0: body = JSON.stringify(collections); michael@0: break; michael@0: default: michael@0: throw "Non-GET on info_collections."; michael@0: } michael@0: michael@0: response.setHeader("Content-Type", "application/json"); michael@0: response.setHeader("X-Weave-Timestamp", michael@0: "" + new_timestamp(), michael@0: false); michael@0: response.setStatusLine(request.httpVersion, 200, "OK"); michael@0: response.bodyOutputStream.write(body, body.length); michael@0: } michael@0: michael@0: return {"collections": collections, michael@0: "handler": info_collections, michael@0: "with_updated_collection": with_updated_collection, michael@0: "update_collection": update_collection}; michael@0: } michael@0: michael@0: //===========================================================================// michael@0: // httpd.js-based Sync server. // michael@0: //===========================================================================// michael@0: michael@0: /** michael@0: * In general, the preferred way of using SyncServer is to directly introspect michael@0: * it. Callbacks are available for operations which are hard to verify through michael@0: * introspection, such as deletions. michael@0: * michael@0: * One of the goals of this server is to provide enough hooks for test code to michael@0: * find out what it needs without monkeypatching. Use this object as your michael@0: * prototype, and override as appropriate. michael@0: */ michael@0: let SyncServerCallback = { michael@0: onCollectionDeleted: function onCollectionDeleted(user, collection) {}, michael@0: onItemDeleted: function onItemDeleted(user, collection, wboID) {}, michael@0: michael@0: /** michael@0: * Called at the top of every request. michael@0: * michael@0: * Allows the test to inspect the request. Hooks should be careful not to michael@0: * modify or change state of the request or they may impact future processing. michael@0: */ michael@0: onRequest: function onRequest(request) {}, michael@0: }; michael@0: michael@0: /** michael@0: * Construct a new test Sync server. Takes a callback object (e.g., michael@0: * SyncServerCallback) as input. michael@0: */ michael@0: function SyncServer(callback) { michael@0: this.callback = callback || {__proto__: SyncServerCallback}; michael@0: this.server = new HttpServer(); michael@0: this.started = false; michael@0: this.users = {}; michael@0: this._log = Log.repository.getLogger(SYNC_HTTP_LOGGER); michael@0: michael@0: // Install our own default handler. This allows us to mess around with the michael@0: // whole URL space. michael@0: let handler = this.server._handler; michael@0: handler._handleDefault = this.handleDefault.bind(this, handler); michael@0: } michael@0: SyncServer.prototype = { michael@0: server: null, // HttpServer. michael@0: users: null, // Map of username => {collections, password}. michael@0: michael@0: /** michael@0: * Start the SyncServer's underlying HTTP server. michael@0: * michael@0: * @param port michael@0: * The numeric port on which to start. A falsy value implies the michael@0: * default, a randomly chosen port. michael@0: * @param cb michael@0: * A callback function (of no arguments) which is invoked after michael@0: * startup. michael@0: */ michael@0: start: function start(port, cb) { michael@0: if (this.started) { michael@0: this._log.warn("Warning: server already started on " + this.port); michael@0: return; michael@0: } michael@0: try { michael@0: this.server.start(port); michael@0: let i = this.server.identity; michael@0: this.port = i.primaryPort; michael@0: this.baseURI = i.primaryScheme + "://" + i.primaryHost + ":" + michael@0: i.primaryPort + "/"; michael@0: this.started = true; michael@0: if (cb) { michael@0: cb(); michael@0: } michael@0: } catch (ex) { michael@0: _("=========================================="); michael@0: _("Got exception starting Sync HTTP server."); michael@0: _("Error: " + Utils.exceptionStr(ex)); michael@0: _("Is there a process already listening on port " + port + "?"); michael@0: _("=========================================="); michael@0: do_throw(ex); michael@0: } michael@0: michael@0: }, michael@0: michael@0: /** michael@0: * Stop the SyncServer's HTTP server. michael@0: * michael@0: * @param cb michael@0: * A callback function. Invoked after the server has been stopped. michael@0: * michael@0: */ michael@0: stop: function stop(cb) { michael@0: if (!this.started) { michael@0: this._log.warn("SyncServer: Warning: server not running. Can't stop me now!"); michael@0: return; michael@0: } michael@0: michael@0: this.server.stop(cb); michael@0: this.started = false; michael@0: }, michael@0: michael@0: /** michael@0: * Return a server timestamp for a record. michael@0: * The server returns timestamps with 1/100 sec granularity. Note that this is michael@0: * subject to change: see Bug 650435. michael@0: */ michael@0: timestamp: function timestamp() { michael@0: return new_timestamp(); michael@0: }, michael@0: michael@0: /** michael@0: * Create a new user, complete with an empty set of collections. michael@0: * michael@0: * @param username michael@0: * The username to use. An Error will be thrown if a user by that name michael@0: * already exists. michael@0: * @param password michael@0: * A password string. michael@0: * michael@0: * @return a user object, as would be returned by server.user(username). michael@0: */ michael@0: registerUser: function registerUser(username, password) { michael@0: if (username in this.users) { michael@0: throw new Error("User already exists."); michael@0: } michael@0: this.users[username] = { michael@0: password: password, michael@0: collections: {} michael@0: }; michael@0: return this.user(username); michael@0: }, michael@0: michael@0: userExists: function userExists(username) { michael@0: return username in this.users; michael@0: }, michael@0: michael@0: getCollection: function getCollection(username, collection) { michael@0: return this.users[username].collections[collection]; michael@0: }, michael@0: michael@0: _insertCollection: function _insertCollection(collections, collection, wbos) { michael@0: let coll = new ServerCollection(wbos, true); michael@0: coll.collectionHandler = coll.handler(); michael@0: collections[collection] = coll; michael@0: return coll; michael@0: }, michael@0: michael@0: createCollection: function createCollection(username, collection, wbos) { michael@0: if (!(username in this.users)) { michael@0: throw new Error("Unknown user."); michael@0: } michael@0: let collections = this.users[username].collections; michael@0: if (collection in collections) { michael@0: throw new Error("Collection already exists."); michael@0: } michael@0: return this._insertCollection(collections, collection, wbos); michael@0: }, michael@0: michael@0: /** michael@0: * Accept a map like the following: michael@0: * { michael@0: * meta: {global: {version: 1, ...}}, michael@0: * crypto: {"keys": {}, foo: {bar: 2}}, michael@0: * bookmarks: {} michael@0: * } michael@0: * to cause collections and WBOs to be created. michael@0: * If a collection already exists, no error is raised. michael@0: * If a WBO already exists, it will be updated to the new contents. michael@0: */ michael@0: createContents: function createContents(username, collections) { michael@0: if (!(username in this.users)) { michael@0: throw new Error("Unknown user."); michael@0: } michael@0: let userCollections = this.users[username].collections; michael@0: for (let [id, contents] in Iterator(collections)) { michael@0: let coll = userCollections[id] || michael@0: this._insertCollection(userCollections, id); michael@0: for (let [wboID, payload] in Iterator(contents)) { michael@0: coll.insert(wboID, payload); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Insert a WBO in an existing collection. michael@0: */ michael@0: insertWBO: function insertWBO(username, collection, wbo) { michael@0: if (!(username in this.users)) { michael@0: throw new Error("Unknown user."); michael@0: } michael@0: let userCollections = this.users[username].collections; michael@0: if (!(collection in userCollections)) { michael@0: throw new Error("Unknown collection."); michael@0: } michael@0: userCollections[collection].insertWBO(wbo); michael@0: return wbo; michael@0: }, michael@0: michael@0: /** michael@0: * Delete all of the collections for the named user. michael@0: * michael@0: * @param username michael@0: * The name of the affected user. michael@0: * michael@0: * @return a timestamp. michael@0: */ michael@0: deleteCollections: function deleteCollections(username) { michael@0: if (!(username in this.users)) { michael@0: throw new Error("Unknown user."); michael@0: } michael@0: let userCollections = this.users[username].collections; michael@0: for each (let [name, coll] in Iterator(userCollections)) { michael@0: this._log.trace("Bulk deleting " + name + " for " + username + "..."); michael@0: coll.delete({}); michael@0: } michael@0: this.users[username].collections = {}; michael@0: return this.timestamp(); michael@0: }, michael@0: michael@0: /** michael@0: * Simple accessor to allow collective binding and abbreviation of a bunch of michael@0: * methods. Yay! michael@0: * Use like this: michael@0: * michael@0: * let u = server.user("john"); michael@0: * u.collection("bookmarks").wbo("abcdefg").payload; // Etc. michael@0: * michael@0: * @return a proxy for the user data stored in this server. michael@0: */ michael@0: user: function user(username) { michael@0: let collection = this.getCollection.bind(this, username); michael@0: let createCollection = this.createCollection.bind(this, username); michael@0: let createContents = this.createContents.bind(this, username); michael@0: let modified = function (collectionName) { michael@0: return collection(collectionName).timestamp; michael@0: } michael@0: let deleteCollections = this.deleteCollections.bind(this, username); michael@0: return { michael@0: collection: collection, michael@0: createCollection: createCollection, michael@0: createContents: createContents, michael@0: deleteCollections: deleteCollections, michael@0: modified: modified michael@0: }; michael@0: }, michael@0: michael@0: /* michael@0: * Regular expressions for splitting up Sync request paths. michael@0: * Sync URLs are of the form: michael@0: * /$apipath/$version/$user/$further michael@0: * where $further is usually: michael@0: * storage/$collection/$wbo michael@0: * or michael@0: * storage/$collection michael@0: * or michael@0: * info/$op michael@0: * We assume for the sake of simplicity that $apipath is empty. michael@0: * michael@0: * N.B., we don't follow any kind of username spec here, because as far as I michael@0: * can tell there isn't one. See Bug 689671. Instead we follow the Python michael@0: * server code. michael@0: * michael@0: * Path: [all, version, username, first, rest] michael@0: * Storage: [all, collection?, id?] michael@0: */ michael@0: pathRE: /^\/([0-9]+(?:\.[0-9]+)?)\/([-._a-zA-Z0-9]+)(?:\/([^\/]+)(?:\/(.+))?)?$/, michael@0: storageRE: /^([-_a-zA-Z0-9]+)(?:\/([-_a-zA-Z0-9]+)\/?)?$/, michael@0: michael@0: defaultHeaders: {}, michael@0: michael@0: /** michael@0: * HTTP response utility. michael@0: */ michael@0: respond: function respond(req, resp, code, status, body, headers) { michael@0: resp.setStatusLine(req.httpVersion, code, status); michael@0: for each (let [header, value] in Iterator(headers || this.defaultHeaders)) { michael@0: resp.setHeader(header, value); michael@0: } michael@0: resp.setHeader("X-Weave-Timestamp", "" + this.timestamp(), false); michael@0: resp.bodyOutputStream.write(body, body.length); michael@0: }, michael@0: michael@0: /** michael@0: * This is invoked by the HttpServer. `this` is bound to the SyncServer; michael@0: * `handler` is the HttpServer's handler. michael@0: * michael@0: * TODO: need to use the correct Sync API response codes and errors here. michael@0: * TODO: Basic Auth. michael@0: * TODO: check username in path against username in BasicAuth. michael@0: */ michael@0: handleDefault: function handleDefault(handler, req, resp) { michael@0: try { michael@0: this._handleDefault(handler, req, resp); michael@0: } catch (e) { michael@0: if (e instanceof HttpError) { michael@0: this.respond(req, resp, e.code, e.description, "", {}); michael@0: } else { michael@0: throw e; michael@0: } michael@0: } michael@0: }, michael@0: michael@0: _handleDefault: function _handleDefault(handler, req, resp) { michael@0: this._log.debug("SyncServer: Handling request: " + req.method + " " + req.path); michael@0: michael@0: if (this.callback.onRequest) { michael@0: this.callback.onRequest(req); michael@0: } michael@0: michael@0: let parts = this.pathRE.exec(req.path); michael@0: if (!parts) { michael@0: this._log.debug("SyncServer: Unexpected request: bad URL " + req.path); michael@0: throw HTTP_404; michael@0: } michael@0: michael@0: let [all, version, username, first, rest] = parts; michael@0: // Doing a float compare of the version allows for us to pretend there was michael@0: // a node-reassignment - eg, we could re-assign from "1.1/user/" to michael@0: // "1.10/user" - this server will then still accept requests with the new michael@0: // URL while any code in sync itself which compares URLs will see a michael@0: // different URL. michael@0: if (parseFloat(version) != parseFloat(SYNC_API_VERSION)) { michael@0: this._log.debug("SyncServer: Unknown version."); michael@0: throw HTTP_404; michael@0: } michael@0: michael@0: if (!this.userExists(username)) { michael@0: this._log.debug("SyncServer: Unknown user."); michael@0: throw HTTP_401; michael@0: } michael@0: michael@0: // Hand off to the appropriate handler for this path component. michael@0: if (first in this.toplevelHandlers) { michael@0: let handler = this.toplevelHandlers[first]; michael@0: return handler.call(this, handler, req, resp, version, username, rest); michael@0: } michael@0: this._log.debug("SyncServer: Unknown top-level " + first); michael@0: throw HTTP_404; michael@0: }, michael@0: michael@0: /** michael@0: * Compute the object that is returned for an info/collections request. michael@0: */ michael@0: infoCollections: function infoCollections(username) { michael@0: let responseObject = {}; michael@0: let colls = this.users[username].collections; michael@0: for (let coll in colls) { michael@0: responseObject[coll] = colls[coll].timestamp; michael@0: } michael@0: this._log.trace("SyncServer: info/collections returning " + michael@0: JSON.stringify(responseObject)); michael@0: return responseObject; michael@0: }, michael@0: michael@0: /** michael@0: * Collection of the handler methods we use for top-level path components. michael@0: */ michael@0: toplevelHandlers: { michael@0: "storage": function handleStorage(handler, req, resp, version, username, rest) { michael@0: let respond = this.respond.bind(this, req, resp); michael@0: if (!rest || !rest.length) { michael@0: this._log.debug("SyncServer: top-level storage " + michael@0: req.method + " request."); michael@0: michael@0: // TODO: verify if this is spec-compliant. michael@0: if (req.method != "DELETE") { michael@0: respond(405, "Method Not Allowed", "[]", {"Allow": "DELETE"}); michael@0: return undefined; michael@0: } michael@0: michael@0: // Delete all collections and track the timestamp for the response. michael@0: let timestamp = this.user(username).deleteCollections(); michael@0: michael@0: // Return timestamp and OK for deletion. michael@0: respond(200, "OK", JSON.stringify(timestamp)); michael@0: return undefined; michael@0: } michael@0: michael@0: let match = this.storageRE.exec(rest); michael@0: if (!match) { michael@0: this._log.warn("SyncServer: Unknown storage operation " + rest); michael@0: throw HTTP_404; michael@0: } michael@0: let [all, collection, wboID] = match; michael@0: let coll = this.getCollection(username, collection); michael@0: switch (req.method) { michael@0: case "GET": michael@0: if (!coll) { michael@0: if (wboID) { michael@0: respond(404, "Not found", "Not found"); michael@0: return undefined; michael@0: } michael@0: // *cries inside*: Bug 687299. michael@0: respond(200, "OK", "[]"); michael@0: return undefined; michael@0: } michael@0: if (!wboID) { michael@0: return coll.collectionHandler(req, resp); michael@0: } michael@0: let wbo = coll.wbo(wboID); michael@0: if (!wbo) { michael@0: respond(404, "Not found", "Not found"); michael@0: return undefined; michael@0: } michael@0: return wbo.handler()(req, resp); michael@0: michael@0: // TODO: implement handling of X-If-Unmodified-Since for write verbs. michael@0: case "DELETE": michael@0: if (!coll) { michael@0: respond(200, "OK", "{}"); michael@0: return undefined; michael@0: } michael@0: if (wboID) { michael@0: let wbo = coll.wbo(wboID); michael@0: if (wbo) { michael@0: wbo.delete(); michael@0: this.callback.onItemDeleted(username, collection, wboID); michael@0: } michael@0: respond(200, "OK", "{}"); michael@0: return undefined; michael@0: } michael@0: coll.collectionHandler(req, resp); michael@0: michael@0: // Spot if this is a DELETE for some IDs, and don't blow away the michael@0: // whole collection! michael@0: // michael@0: // We already handled deleting the WBOs by invoking the deleted michael@0: // collection's handler. However, in the case of michael@0: // michael@0: // DELETE storage/foobar michael@0: // michael@0: // we also need to remove foobar from the collections map. This michael@0: // clause tries to differentiate the above request from michael@0: // michael@0: // DELETE storage/foobar?ids=foo,baz michael@0: // michael@0: // and do the right thing. michael@0: // TODO: less hacky method. michael@0: if (-1 == req.queryString.indexOf("ids=")) { michael@0: // When you delete the entire collection, we drop it. michael@0: this._log.debug("Deleting entire collection."); michael@0: delete this.users[username].collections[collection]; michael@0: this.callback.onCollectionDeleted(username, collection); michael@0: } michael@0: michael@0: // Notify of item deletion. michael@0: let deleted = resp.deleted || []; michael@0: for (let i = 0; i < deleted.length; ++i) { michael@0: this.callback.onItemDeleted(username, collection, deleted[i]); michael@0: } michael@0: return undefined; michael@0: case "POST": michael@0: case "PUT": michael@0: if (!coll) { michael@0: coll = this.createCollection(username, collection); michael@0: } michael@0: if (wboID) { michael@0: let wbo = coll.wbo(wboID); michael@0: if (!wbo) { michael@0: this._log.trace("SyncServer: creating WBO " + collection + "/" + wboID); michael@0: wbo = coll.insert(wboID); michael@0: } michael@0: // Rather than instantiate each WBO's handler function, do it once michael@0: // per request. They get hit far less often than do collections. michael@0: wbo.handler()(req, resp); michael@0: coll.timestamp = resp.newModified; michael@0: return resp; michael@0: } michael@0: return coll.collectionHandler(req, resp); michael@0: default: michael@0: throw "Request method " + req.method + " not implemented."; michael@0: } michael@0: }, michael@0: michael@0: "info": function handleInfo(handler, req, resp, version, username, rest) { michael@0: switch (rest) { michael@0: case "collections": michael@0: let body = JSON.stringify(this.infoCollections(username)); michael@0: this.respond(req, resp, 200, "OK", body, { michael@0: "Content-Type": "application/json" michael@0: }); michael@0: return; michael@0: case "collection_usage": michael@0: case "collection_counts": michael@0: case "quota": michael@0: // TODO: implement additional info methods. michael@0: this.respond(req, resp, 200, "OK", "TODO"); michael@0: return; michael@0: default: michael@0: // TODO michael@0: this._log.warn("SyncServer: Unknown info operation " + rest); michael@0: throw HTTP_404; michael@0: } michael@0: } michael@0: } michael@0: }; michael@0: michael@0: /** michael@0: * Test helper. michael@0: */ michael@0: function serverForUsers(users, contents, callback) { michael@0: let server = new SyncServer(callback); michael@0: for (let [user, pass] in Iterator(users)) { michael@0: server.registerUser(user, pass); michael@0: server.createContents(user, contents); michael@0: } michael@0: server.start(); michael@0: return server; michael@0: }