mirror of
https://github.com/mozilla/gecko-dev.git
synced 2025-11-11 21:58:41 +02:00
1004 lines
34 KiB
JavaScript
1004 lines
34 KiB
JavaScript
/* 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/. */
|
|
"use strict";
|
|
|
|
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
|
|
|
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
|
Cu.import("resource://gre/modules/Services.jsm");
|
|
Cu.import("resource://gre/modules/Task.jsm");
|
|
Cu.import("resource://gre/modules/Timer.jsm");
|
|
|
|
const {MozLoopService, LOOP_SESSION_TYPE} = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
|
|
XPCOMUtils.defineLazyModuleGetter(this, "Promise",
|
|
"resource://gre/modules/Promise.jsm");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils",
|
|
"resource://services-common/utils.js");
|
|
XPCOMUtils.defineLazyGetter(this, "eventEmitter", function() {
|
|
const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
|
|
return new EventEmitter();
|
|
});
|
|
XPCOMUtils.defineLazyGetter(this, "gLoopBundle", function() {
|
|
return Services.strings.createBundle('chrome://browser/locale/loop/loop.properties');
|
|
});
|
|
|
|
XPCOMUtils.defineLazyModuleGetter(this, "LoopRoomsCache",
|
|
"resource:///modules/loop/LoopRoomsCache.jsm");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "loopUtils",
|
|
"resource:///modules/loop/utils.js", "utils");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "loopCrypto",
|
|
"resource:///modules/loop/crypto.js", "LoopCrypto");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "ObjectUtils",
|
|
"resource://gre/modules/ObjectUtils.jsm");
|
|
|
|
|
|
this.EXPORTED_SYMBOLS = ["LoopRooms", "roomsPushNotification"];
|
|
|
|
// The maximum number of clients that we support currently.
|
|
const CLIENT_MAX_SIZE = 2;
|
|
|
|
// Wait at least 5 seconds before doing opportunistic encryption.
|
|
const MIN_TIME_BEFORE_ENCRYPTION = 5 * 1000;
|
|
// Wait at maximum of 30 minutes before doing opportunistic encryption.
|
|
const MAX_TIME_BEFORE_ENCRYPTION = 30 * 60 * 1000;
|
|
// Wait time between individual re-encryption cycles (1 second).
|
|
const TIME_BETWEEN_ENCRYPTIONS = 1000;
|
|
|
|
const roomsPushNotification = function(version, channelID) {
|
|
return LoopRoomsInternal.onNotification(version, channelID);
|
|
};
|
|
|
|
// Since the LoopRoomsInternal.rooms map as defined below is a local cache of
|
|
// room objects that are retrieved from the server, this is list may become out
|
|
// of date. The Push server may notify us of this event, which will set the global
|
|
// 'dirty' flag to TRUE.
|
|
let gDirty = true;
|
|
// Global variable that keeps track of the currently used account.
|
|
let gCurrentUser = null;
|
|
// Global variable that keeps track of the room cache.
|
|
let gRoomsCache = null;
|
|
|
|
/**
|
|
* Extend a `target` object with the properties defined in `source`.
|
|
*
|
|
* @param {Object} target The target object to receive properties defined in `source`
|
|
* @param {Object} source The source object to copy properties from
|
|
*/
|
|
const extend = function(target, source) {
|
|
for (let key of Object.getOwnPropertyNames(source)) {
|
|
target[key] = source[key];
|
|
}
|
|
return target;
|
|
};
|
|
|
|
/**
|
|
* Checks whether a participant is already part of a room.
|
|
*
|
|
* @see https://wiki.mozilla.org/Loop/Architecture/Rooms#User_Identification_in_a_Room
|
|
*
|
|
* @param {Object} room A room object that contains a list of current participants
|
|
* @param {Object} participant Participant to check if it's already there
|
|
* @returns {Boolean} TRUE when the participant is already a member of the room,
|
|
* FALSE when it's not.
|
|
*/
|
|
const containsParticipant = function(room, participant) {
|
|
for (let user of room.participants) {
|
|
if (user.roomConnectionId == participant.roomConnectionId) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Compares the list of participants of the room currently in the cache and an
|
|
* updated version of that room. When a new participant is found, the 'joined'
|
|
* event is emitted. When a participant is not found in the update, it emits a
|
|
* 'left' event.
|
|
*
|
|
* @param {Object} room A room object to compare the participants list
|
|
* against
|
|
* @param {Object} updatedRoom A room object that contains the most up-to-date
|
|
* list of participants
|
|
*/
|
|
const checkForParticipantsUpdate = function(room, updatedRoom) {
|
|
// Partially fetched rooms don't contain the participants list yet. Skip the
|
|
// check for now.
|
|
if (!("participants" in room)) {
|
|
return;
|
|
}
|
|
|
|
let participant;
|
|
// Check for participants that joined.
|
|
for (participant of updatedRoom.participants) {
|
|
if (!containsParticipant(room, participant)) {
|
|
eventEmitter.emit("joined", room, participant);
|
|
eventEmitter.emit("joined:" + room.roomToken, participant);
|
|
}
|
|
}
|
|
|
|
// Check for participants that left.
|
|
for (participant of room.participants) {
|
|
if (!containsParticipant(updatedRoom, participant)) {
|
|
eventEmitter.emit("left", room, participant);
|
|
eventEmitter.emit("left:" + room.roomToken, participant);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* These are wrappers which can be overriden by tests to allow us to manually
|
|
* handle the timeouts.
|
|
*/
|
|
let timerHandlers = {
|
|
/**
|
|
* Wrapper for setTimeout.
|
|
*
|
|
* @param {Function} callback The callback function.
|
|
* @param {Number} delay The delay in milliseconds.
|
|
* @return {Number} The timer identifier.
|
|
*/
|
|
startTimer(callback, delay) {
|
|
return setTimeout(callback, delay);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* The Rooms class.
|
|
*
|
|
* Each method that is a member of this class requires the last argument to be a
|
|
* callback Function. MozLoopAPI will cause things to break if this invariant is
|
|
* violated. You'll notice this as well in the documentation for each method.
|
|
*/
|
|
let LoopRoomsInternal = {
|
|
/**
|
|
* @var {Map} rooms Collection of rooms currently in cache.
|
|
*/
|
|
rooms: new Map(),
|
|
|
|
get roomsCache() {
|
|
if (!gRoomsCache) {
|
|
gRoomsCache = new LoopRoomsCache();
|
|
}
|
|
return gRoomsCache;
|
|
},
|
|
|
|
/**
|
|
* @var {Object} encryptionQueue This stores the list of rooms awaiting
|
|
* encryption and associated timers.
|
|
*/
|
|
encryptionQueue: {
|
|
queue: [],
|
|
timer: null,
|
|
reset: function() {
|
|
this.queue = [];
|
|
this.timer = null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @var {String} sessionType The type of user session. May be 'FXA' or 'GUEST'.
|
|
*/
|
|
get sessionType() {
|
|
return MozLoopService.userProfile ? LOOP_SESSION_TYPE.FXA :
|
|
LOOP_SESSION_TYPE.GUEST;
|
|
},
|
|
|
|
/**
|
|
* @var {Number} participantsCount The total amount of participants currently
|
|
* inside all rooms.
|
|
*/
|
|
get participantsCount() {
|
|
let count = 0;
|
|
for (let room of this.rooms.values()) {
|
|
if (room.deleted || !("participants" in room)) {
|
|
continue;
|
|
}
|
|
count += room.participants.length;
|
|
}
|
|
return count;
|
|
},
|
|
|
|
/**
|
|
* Processes the encryption queue. Takes the next item off the queue,
|
|
* restarts the timer if necessary.
|
|
*
|
|
* Although this is only called from a timer callback, it is an async function
|
|
* so that tests can call it and be deterministic.
|
|
*/
|
|
processEncryptionQueue: Task.async(function* () {
|
|
let roomToken = this.encryptionQueue.queue.shift();
|
|
|
|
// Performed in sync fashion so that we don't queue a timer until it has
|
|
// completed, and to make it easier to run tests.
|
|
let roomData = this.rooms.get(roomToken);
|
|
|
|
if (roomData) {
|
|
try {
|
|
// Passing the empty object for roomData is enough for the room to be
|
|
// re-encrypted.
|
|
yield LoopRooms.promise("update", roomToken, {});
|
|
} catch (error) {
|
|
MozLoopService.log.error("Upgrade encryption of room failed", error);
|
|
// No need to remove the room from the list as that's done in the shift above.
|
|
}
|
|
}
|
|
|
|
if (this.encryptionQueue.queue.length) {
|
|
this.encryptionQueue.timer =
|
|
timerHandlers.startTimer(this.processEncryptionQueue.bind(this), TIME_BETWEEN_ENCRYPTIONS);
|
|
} else {
|
|
this.encryptionQueue.timer = null;
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Queues a room for encryption sometime in the future. This is done so as
|
|
* not to overload the server or the browser when we initially request the
|
|
* list of rooms.
|
|
*
|
|
* @param {String} roomToken The token for the room that needs encrypting.
|
|
*/
|
|
queueForEncryption: function(roomToken) {
|
|
if (!this.encryptionQueue.queue.includes(roomToken)) {
|
|
this.encryptionQueue.queue.push(roomToken);
|
|
}
|
|
|
|
// Set up encryption to happen at a random time later. There's a minimum
|
|
// wait time - we don't need to do this straight away, so no need if the user
|
|
// is starting up. We then add a random factor on top of that. This is to
|
|
// try and avoid any potential with a set of clients being restarted at the
|
|
// same time and flooding the server.
|
|
if (!this.encryptionQueue.timer) {
|
|
let waitTime = (MAX_TIME_BEFORE_ENCRYPTION - MIN_TIME_BEFORE_ENCRYPTION) *
|
|
Math.random() + MIN_TIME_BEFORE_ENCRYPTION;
|
|
this.encryptionQueue.timer =
|
|
timerHandlers.startTimer(this.processEncryptionQueue.bind(this), waitTime);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Gets or creates a room key for a room.
|
|
*
|
|
* It assumes that the room data is decrypted.
|
|
*
|
|
* @param {Object} roomData The roomData to get the key for.
|
|
* @return {Promise} A promise that is resolved whith the room key.
|
|
*/
|
|
promiseGetOrCreateRoomKey: Task.async(function* (roomData) {
|
|
if (roomData.roomKey) {
|
|
return roomData.roomKey;
|
|
}
|
|
|
|
return yield loopCrypto.generateKey();
|
|
}),
|
|
|
|
/**
|
|
* Encrypts a room key for sending to the server using the profile encryption
|
|
* key.
|
|
*
|
|
* @param {String} key The JSON web key to encrypt.
|
|
* @return {Promise} A promise that is resolved with the encrypted room key.
|
|
*/
|
|
promiseEncryptedRoomKey: Task.async(function* (key) {
|
|
let profileKey = yield MozLoopService.promiseProfileEncryptionKey();
|
|
|
|
let encryptedRoomKey = yield loopCrypto.encryptBytes(profileKey, key);
|
|
return encryptedRoomKey;
|
|
}),
|
|
|
|
/**
|
|
* Decryptes a room key from the server using the profile encryption key.
|
|
*
|
|
* @param {String} encryptedKey The room key to decrypt.
|
|
* @return {Promise} A promise that is resolved with the decrypted room key.
|
|
*/
|
|
promiseDecryptRoomKey: Task.async(function* (encryptedKey) {
|
|
let profileKey = yield MozLoopService.promiseProfileEncryptionKey();
|
|
|
|
let decryptedRoomKey = yield loopCrypto.decryptBytes(profileKey, encryptedKey);
|
|
return decryptedRoomKey;
|
|
}),
|
|
|
|
/**
|
|
* Encrypts room data in a format appropriate to sending to the loop
|
|
* server.
|
|
*
|
|
* @param {Object} roomData The room data to encrypt.
|
|
* @return {Promise} A promise that is resolved with an object containing
|
|
* two objects:
|
|
* - encrypted: The encrypted data to send. This excludes
|
|
* any decrypted data.
|
|
* - all: The roomData with both encrypted and decrypted
|
|
* information.
|
|
* If rejected, encryption has failed. This could be due to
|
|
* missing keys for FxA, which this process can't manage. It
|
|
* is generally expected the panel will prompt the user for
|
|
* re-auth if the FxA keys are missing.
|
|
*/
|
|
promiseEncryptRoomData: Task.async(function* (roomData) {
|
|
var newRoomData = extend({}, roomData);
|
|
|
|
if (!newRoomData.context) {
|
|
newRoomData.context = {};
|
|
}
|
|
|
|
// First get the room key.
|
|
let key = yield this.promiseGetOrCreateRoomKey(newRoomData);
|
|
|
|
newRoomData.context.wrappedKey = yield this.promiseEncryptedRoomKey(key);
|
|
|
|
// Now encrypt the actual data.
|
|
newRoomData.context.value = yield loopCrypto.encryptBytes(key,
|
|
JSON.stringify(newRoomData.decryptedContext));
|
|
|
|
// The algorithm is currently hard-coded as AES-GCM, in case of future
|
|
// changes.
|
|
newRoomData.context.alg = "AES-GCM";
|
|
newRoomData.roomKey = key;
|
|
|
|
var serverRoomData = extend({}, newRoomData);
|
|
|
|
// We must not send these items to the server.
|
|
delete serverRoomData.decryptedContext;
|
|
delete serverRoomData.roomKey;
|
|
|
|
return {
|
|
encrypted: serverRoomData,
|
|
all: newRoomData
|
|
};
|
|
}),
|
|
|
|
/**
|
|
* Decrypts room data recevied from the server.
|
|
*
|
|
* @param {Object} roomData The roomData with encrypted context.
|
|
* @return {Promise} A promise that is resolved with the decrypted room data.
|
|
*/
|
|
promiseDecryptRoomData: Task.async(function* (roomData) {
|
|
if (!roomData.context) {
|
|
return roomData;
|
|
}
|
|
|
|
if (!roomData.context.wrappedKey) {
|
|
throw new Error("Missing wrappedKey");
|
|
}
|
|
|
|
let savedRoomKey = yield this.roomsCache.getKey(this.sessionType, roomData.roomToken);
|
|
let fallback = false;
|
|
let key;
|
|
|
|
try {
|
|
key = yield this.promiseDecryptRoomKey(roomData.context.wrappedKey);
|
|
} catch (error) {
|
|
// If we don't have a key saved, then we can't do anything.
|
|
if (!savedRoomKey) {
|
|
throw error;
|
|
}
|
|
|
|
// We failed to decrypt the room key, so has our FxA key changed?
|
|
// If so, we fall-back to the saved room key.
|
|
key = savedRoomKey;
|
|
fallback = true;
|
|
}
|
|
|
|
let decryptedData = yield loopCrypto.decryptBytes(key, roomData.context.value);
|
|
|
|
if (fallback) {
|
|
// Fallback decryption succeeded, so we need to re-encrypt the room key and
|
|
// save the data back again.
|
|
MozLoopService.log.debug("Fell back to saved key, queuing for encryption",
|
|
roomData.roomToken);
|
|
this.queueForEncryption(roomData.roomToken);
|
|
} else if (!savedRoomKey || key != savedRoomKey) {
|
|
// Decryption succeeded, but we don't have the right key saved.
|
|
try {
|
|
yield this.roomsCache.setKey(this.sessionType, roomData.roomToken, key);
|
|
}
|
|
catch (error) {
|
|
MozLoopService.log.error("Failed to save room key:", error);
|
|
}
|
|
}
|
|
|
|
roomData.roomKey = key;
|
|
roomData.decryptedContext = JSON.parse(decryptedData);
|
|
|
|
// Strip any existing key from the url.
|
|
roomData.roomUrl = roomData.roomUrl.split("#")[0];
|
|
// Now add the key to the url.
|
|
roomData.roomUrl = roomData.roomUrl + "#" + roomData.roomKey;
|
|
|
|
return roomData;
|
|
}),
|
|
|
|
/**
|
|
* Saves room information and notifies updates to any listeners.
|
|
*
|
|
* @param {Object} roomData The new room data to save.
|
|
* @param {Boolean} isUpdate true if this is an update, false if its an add.
|
|
*/
|
|
saveAndNotifyUpdate: function(roomData, isUpdate) {
|
|
this.rooms.set(roomData.roomToken, roomData);
|
|
|
|
let eventName = isUpdate ? "update" : "add";
|
|
eventEmitter.emit(eventName, roomData);
|
|
eventEmitter.emit(eventName + ":" + roomData.roomToken, roomData);
|
|
},
|
|
|
|
/**
|
|
* Either adds or updates the room to the room store and notifies to any
|
|
* listeners.
|
|
*
|
|
* This will decrypt information if necessary, and adjust for the legacy
|
|
* "roomName" field.
|
|
*
|
|
* @param {Object} room The new room to add.
|
|
* @param {Boolean} isUpdate true if this is an update to an existing room.
|
|
*/
|
|
addOrUpdateRoom: Task.async(function* (room, isUpdate) {
|
|
if (!room.context) {
|
|
// We don't do anything with roomUrl here as it doesn't need a key
|
|
// string adding at this stage.
|
|
|
|
// No encrypted data yet, use the old roomName field.
|
|
room.decryptedContext = {
|
|
roomName: room.roomName
|
|
};
|
|
delete room.roomName;
|
|
|
|
// This room doesn't have context, so we'll save it for a later encryption
|
|
// cycle.
|
|
this.queueForEncryption(room.roomToken);
|
|
|
|
this.saveAndNotifyUpdate(room, isUpdate);
|
|
} else {
|
|
// We could potentially optimise this later by not decrypting if the
|
|
// encrypted context hasn't already changed. However perf doesn't seem
|
|
// to be too bigger an issue at the moment, so we just decrypt for now.
|
|
// If we do change this, then we need to make sure we get the new room
|
|
// data setup properly, as happens at the end of promiseDecryptRoomData.
|
|
try {
|
|
let roomData = yield this.promiseDecryptRoomData(room);
|
|
|
|
this.saveAndNotifyUpdate(roomData, isUpdate);
|
|
} catch (error) {
|
|
MozLoopService.log.error("Failed to decrypt room data: ", error);
|
|
// Do what we can to save the room data.
|
|
room.decryptedContext = {};
|
|
this.saveAndNotifyUpdate(room, isUpdate);
|
|
}
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Fetch a list of rooms that the currently registered user is a member of.
|
|
*
|
|
* @param {String} [version] If set, we will fetch a list of changed rooms since
|
|
* `version`. Optional.
|
|
* @param {Function} callback Function that will be invoked once the operation
|
|
* finished. The first argument passed will be an
|
|
* `Error` object or `null`. The second argument will
|
|
* be the list of rooms, if it was fetched successfully.
|
|
*/
|
|
getAll: function(version = null, callback = null) {
|
|
if (!callback) {
|
|
callback = version;
|
|
version = null;
|
|
}
|
|
|
|
Task.spawn(function* () {
|
|
if (!gDirty) {
|
|
callback(null, [...this.rooms.values()]);
|
|
return;
|
|
}
|
|
|
|
// Fetch the rooms from the server.
|
|
let url = "/rooms" + (version ? "?version=" + encodeURIComponent(version) : "");
|
|
let response = yield MozLoopService.hawkRequest(this.sessionType, url, "GET");
|
|
let roomsList = JSON.parse(response.body);
|
|
if (!Array.isArray(roomsList)) {
|
|
throw new Error("Missing array of rooms in response.");
|
|
}
|
|
|
|
for (let room of roomsList) {
|
|
// See if we already have this room in our cache.
|
|
let orig = this.rooms.get(room.roomToken);
|
|
|
|
if (room.deleted) {
|
|
// If this client deleted the room, then we'll already have
|
|
// deleted the room in the function below.
|
|
if (orig) {
|
|
this.rooms.delete(room.roomToken);
|
|
}
|
|
|
|
eventEmitter.emit("delete", room);
|
|
eventEmitter.emit("delete:" + room.roomToken, room);
|
|
} else {
|
|
if (orig) {
|
|
checkForParticipantsUpdate(orig, room);
|
|
}
|
|
|
|
yield this.addOrUpdateRoom(room, !!orig);
|
|
}
|
|
}
|
|
|
|
// If there's no rooms in the list, remove the guest created room flag, so that
|
|
// we don't keep registering for guest when we don't need to.
|
|
if (this.sessionType == LOOP_SESSION_TYPE.GUEST && !this.rooms.size) {
|
|
this.setGuestCreatedRoom(false);
|
|
}
|
|
|
|
// Set the 'dirty' flag back to FALSE, since the list is as fresh as can be now.
|
|
gDirty = false;
|
|
callback(null, [...this.rooms.values()]);
|
|
}.bind(this)).catch(error => {
|
|
callback(error);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Request information about a specific room from the server. It will be
|
|
* returned from the cache if it's already in it.
|
|
*
|
|
* @param {String} roomToken Room identifier
|
|
* @param {Function} callback Function that will be invoked once the operation
|
|
* finished. The first argument passed will be an
|
|
* `Error` object or `null`. The second argument will
|
|
* be the list of rooms, if it was fetched successfully.
|
|
*/
|
|
get: function(roomToken, callback) {
|
|
let room = this.rooms.has(roomToken) ? this.rooms.get(roomToken) : {};
|
|
// Check if we need to make a request to the server to collect more room data.
|
|
let needsUpdate = !("participants" in room);
|
|
if (!gDirty && !needsUpdate) {
|
|
// Dirty flag is not set AND the necessary data is available, so we can
|
|
// simply return the room.
|
|
callback(null, room);
|
|
return;
|
|
}
|
|
|
|
Task.spawn(function* () {
|
|
let response = yield MozLoopService.hawkRequest(this.sessionType,
|
|
"/rooms/" + encodeURIComponent(roomToken), "GET");
|
|
|
|
let data = JSON.parse(response.body);
|
|
|
|
room.roomToken = roomToken;
|
|
|
|
if (data.deleted) {
|
|
this.rooms.delete(room.roomToken);
|
|
|
|
extend(room, data);
|
|
eventEmitter.emit("delete", room);
|
|
eventEmitter.emit("delete:" + room.roomToken, room);
|
|
} else {
|
|
checkForParticipantsUpdate(room, data);
|
|
extend(room, data);
|
|
|
|
yield this.addOrUpdateRoom(room, !needsUpdate);
|
|
}
|
|
callback(null, room);
|
|
}.bind(this)).catch(callback);
|
|
},
|
|
|
|
/**
|
|
* Create a room.
|
|
*
|
|
* @param {Object} room Properties to be sent to the LoopServer
|
|
* @param {Function} callback Function that will be invoked once the operation
|
|
* finished. The first argument passed will be an
|
|
* `Error` object or `null`. The second argument will
|
|
* be the room, if it was created successfully.
|
|
*/
|
|
create: function(room, callback) {
|
|
if (!("decryptedContext" in room) || !("roomOwner" in room) ||
|
|
!("maxSize" in room)) {
|
|
callback(new Error("Missing required property to create a room"));
|
|
return;
|
|
}
|
|
|
|
Task.spawn(function* () {
|
|
let {all, encrypted} = yield this.promiseEncryptRoomData(room);
|
|
|
|
// Save both sets of data...
|
|
room = all;
|
|
// ...but only send the encrypted data.
|
|
let response = yield MozLoopService.hawkRequest(this.sessionType, "/rooms",
|
|
"POST", encrypted);
|
|
|
|
extend(room, JSON.parse(response.body));
|
|
// Do not keep this value - it is a request to the server.
|
|
delete room.expiresIn;
|
|
this.rooms.set(room.roomToken, room);
|
|
|
|
if (this.sessionType == LOOP_SESSION_TYPE.GUEST) {
|
|
this.setGuestCreatedRoom(true);
|
|
}
|
|
|
|
// Now we've got the room token, we can save the key to disk.
|
|
yield this.roomsCache.setKey(this.sessionType, room.roomToken, room.roomKey);
|
|
|
|
eventEmitter.emit("add", room);
|
|
callback(null, room);
|
|
}.bind(this)).catch(callback);
|
|
},
|
|
|
|
/**
|
|
* Sets whether or not the user has created a room in guest mode.
|
|
*
|
|
* @param {Boolean} created If the user has created the room.
|
|
*/
|
|
setGuestCreatedRoom: function(created) {
|
|
if (created) {
|
|
Services.prefs.setBoolPref("loop.createdRoom", created);
|
|
} else {
|
|
Services.prefs.clearUserPref("loop.createdRoom");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Returns true if the user has a created room in guest mode.
|
|
*/
|
|
getGuestCreatedRoom: function() {
|
|
try {
|
|
return Services.prefs.getBoolPref("loop.createdRoom");
|
|
} catch (x) {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
open: function(roomToken) {
|
|
let windowData = {
|
|
roomToken: roomToken,
|
|
type: "room"
|
|
};
|
|
|
|
MozLoopService.openChatWindow(windowData);
|
|
},
|
|
|
|
/**
|
|
* Deletes a room.
|
|
*
|
|
* @param {String} roomToken The room token.
|
|
* @param {Function} callback Function that will be invoked once the operation
|
|
* finished. The first argument passed will be an
|
|
* `Error` object or `null`.
|
|
*/
|
|
delete: function(roomToken, callback) {
|
|
// XXX bug 1092954: Before deleting a room, the client should check room
|
|
// membership and forceDisconnect() all current participants.
|
|
let room = this.rooms.get(roomToken);
|
|
let url = "/rooms/" + encodeURIComponent(roomToken);
|
|
MozLoopService.hawkRequest(this.sessionType, url, "DELETE")
|
|
.then(response => {
|
|
this.rooms.delete(roomToken);
|
|
eventEmitter.emit("delete", room);
|
|
eventEmitter.emit("delete:" + room.roomToken, room);
|
|
callback(null, room);
|
|
}, error => callback(error)).catch(error => callback(error));
|
|
},
|
|
|
|
/**
|
|
* Internal function to handle POSTs to a room.
|
|
*
|
|
* @param {String} roomToken The room token.
|
|
* @param {Object} postData The data to post to the room.
|
|
* @param {Function} callback Function that will be invoked once the operation
|
|
* finished. The first argument passed will be an
|
|
* `Error` object or `null`.
|
|
*/
|
|
_postToRoom(roomToken, postData, callback) {
|
|
let url = "/rooms/" + encodeURIComponent(roomToken);
|
|
MozLoopService.hawkRequest(this.sessionType, url, "POST", postData).then(response => {
|
|
// Delete doesn't have a body return.
|
|
var joinData = response.body ? JSON.parse(response.body) : {};
|
|
callback(null, joinData);
|
|
}, error => callback(error)).catch(error => callback(error));
|
|
},
|
|
|
|
/**
|
|
* Joins a room
|
|
*
|
|
* @param {String} roomToken The room token.
|
|
* @param {Function} callback Function that will be invoked once the operation
|
|
* finished. The first argument passed will be an
|
|
* `Error` object or `null`.
|
|
*/
|
|
join: function(roomToken, callback) {
|
|
let displayName;
|
|
if (MozLoopService.userProfile && MozLoopService.userProfile.email) {
|
|
displayName = MozLoopService.userProfile.email;
|
|
} else {
|
|
displayName = gLoopBundle.GetStringFromName("display_name_guest");
|
|
}
|
|
|
|
this._postToRoom(roomToken, {
|
|
action: "join",
|
|
displayName: displayName,
|
|
clientMaxSize: CLIENT_MAX_SIZE
|
|
}, callback);
|
|
},
|
|
|
|
/**
|
|
* Refreshes a room
|
|
*
|
|
* @param {String} roomToken The room token.
|
|
* @param {String} sessionToken The session token for the session that has been
|
|
* joined
|
|
* @param {Function} callback Function that will be invoked once the operation
|
|
* finished. The first argument passed will be an
|
|
* `Error` object or `null`.
|
|
*/
|
|
refreshMembership: function(roomToken, sessionToken, callback) {
|
|
this._postToRoom(roomToken, {
|
|
action: "refresh",
|
|
sessionToken: sessionToken
|
|
}, callback);
|
|
},
|
|
|
|
/**
|
|
* Leaves a room. Although this is an sync function, no data is returned
|
|
* from the server.
|
|
*
|
|
* @param {String} roomToken The room token.
|
|
* @param {String} sessionToken The session token for the session that has been
|
|
* joined
|
|
* @param {Function} callback Optional. Function that will be invoked once the operation
|
|
* finished. The first argument passed will be an
|
|
* `Error` object or `null`.
|
|
*/
|
|
leave: function(roomToken, sessionToken, callback) {
|
|
if (!callback) {
|
|
callback = function(error) {
|
|
if (error) {
|
|
MozLoopService.log.error(error);
|
|
}
|
|
};
|
|
}
|
|
this._postToRoom(roomToken, {
|
|
action: "leave",
|
|
sessionToken: sessionToken
|
|
}, callback);
|
|
},
|
|
|
|
/**
|
|
* Forwards connection status to the server.
|
|
*
|
|
* @param {String} roomToken The room token.
|
|
* @param {String} sessionToken The session token for the
|
|
* session that has been
|
|
* joined.
|
|
* @param {sharedActions.SdkStatus} status The connection status.
|
|
* @param {Function} callback Optional. Function that will be invoked once
|
|
* the operation finished. The first argument
|
|
* passed will be an `Error` object or `null`.
|
|
*/
|
|
sendConnectionStatus: function(roomToken, sessionToken, status, callback) {
|
|
if (!callback) {
|
|
callback = function(error) {
|
|
if (error) {
|
|
MozLoopService.log.error(error);
|
|
}
|
|
};
|
|
}
|
|
this._postToRoom(roomToken, {
|
|
action: "status",
|
|
event: status.event,
|
|
state: status.state,
|
|
connections: status.connections,
|
|
sendStreams: status.sendStreams,
|
|
recvStreams: status.recvStreams,
|
|
sessionToken: sessionToken
|
|
}, callback);
|
|
},
|
|
|
|
/**
|
|
* Updates a room.
|
|
*
|
|
* @param {String} roomToken The room token
|
|
* @param {Object} roomData Updated context data for the room. The following
|
|
* properties are expected: `roomName` and `urls`.
|
|
* IMPORTANT: Data in the `roomData::urls` array
|
|
* will be stored as-is, so any data omitted therein
|
|
* will be gone forever.
|
|
* @param {Function} callback Function that will be invoked once the operation
|
|
* finished. The first argument passed will be an
|
|
* `Error` object or `null`.
|
|
*/
|
|
update: function(roomToken, roomData, callback) {
|
|
let room = this.rooms.get(roomToken);
|
|
let url = "/rooms/" + encodeURIComponent(roomToken);
|
|
if (!room.decryptedContext) {
|
|
room.decryptedContext = {
|
|
roomName: roomData.roomName || room.roomName
|
|
};
|
|
} else {
|
|
// room.roomName is the final fallback as this is pre-encryption support.
|
|
// Bug 1166283 is tracking the removal of the fallback.
|
|
room.decryptedContext.roomName = roomData.roomName ||
|
|
room.decryptedContext.roomName ||
|
|
room.roomName;
|
|
}
|
|
if (roomData.urls && roomData.urls.length) {
|
|
// For now we only support adding one URL to the room context.
|
|
room.decryptedContext.urls = [roomData.urls[0]];
|
|
}
|
|
|
|
Task.spawn(function* () {
|
|
let {all, encrypted} = yield this.promiseEncryptRoomData(room);
|
|
|
|
// For patch, we only send the context data.
|
|
let sendData = {
|
|
context: encrypted.context
|
|
};
|
|
|
|
// This might be an upgrade to encrypted rename, so store the key
|
|
// just in case.
|
|
yield this.roomsCache.setKey(this.sessionType, all.roomToken, all.roomKey);
|
|
|
|
let response = yield MozLoopService.hawkRequest(this.sessionType,
|
|
url, "PATCH", sendData);
|
|
|
|
let newRoomData = all;
|
|
|
|
extend(newRoomData, JSON.parse(response.body));
|
|
this.rooms.set(roomToken, newRoomData);
|
|
callback(null, newRoomData);
|
|
}.bind(this)).catch(callback);
|
|
},
|
|
|
|
/**
|
|
* Callback used to indicate changes to rooms data on the LoopServer.
|
|
*
|
|
* @param {String} version Version number assigned to this change set.
|
|
* @param {String} channelID Notification channel identifier.
|
|
*/
|
|
onNotification: function(version, channelID) {
|
|
// See if we received a notification for the channel that's currently active:
|
|
let channelIDs = MozLoopService.channelIDs;
|
|
if ((this.sessionType == LOOP_SESSION_TYPE.GUEST && channelID != channelIDs.roomsGuest) ||
|
|
(this.sessionType == LOOP_SESSION_TYPE.FXA && channelID != channelIDs.roomsFxA)) {
|
|
return;
|
|
}
|
|
|
|
let oldDirty = gDirty;
|
|
gDirty = true;
|
|
// If we were already dirty, then get the full set of rooms. For example,
|
|
// we'd already be dirty if we had started up but not got the list of rooms
|
|
// yet.
|
|
this.getAll(oldDirty ? null : version, () => {});
|
|
},
|
|
|
|
/**
|
|
* When a user logs in or out, this method should be invoked to check whether
|
|
* the rooms cache needs to be refreshed.
|
|
*
|
|
* @param {String|null} user The FxA userID or NULL
|
|
*/
|
|
maybeRefresh: function(user = null) {
|
|
if (gCurrentUser == user) {
|
|
return;
|
|
}
|
|
|
|
gCurrentUser = user;
|
|
if (!gDirty) {
|
|
gDirty = true;
|
|
this.rooms.clear();
|
|
eventEmitter.emit("refresh");
|
|
this.getAll(null, () => {});
|
|
}
|
|
}
|
|
};
|
|
Object.freeze(LoopRoomsInternal);
|
|
|
|
/**
|
|
* Public Loop Rooms API.
|
|
*
|
|
* LoopRooms implements the EventEmitter interface by exposing three methods -
|
|
* `on`, `once` and `off` - to subscribe to events.
|
|
* At this point the following events may be subscribed to:
|
|
* - 'add[:{room-id}]': A new room object was successfully added to the data
|
|
* store.
|
|
* - 'delete[:{room-id}]': A room was successfully removed from the data store.
|
|
* - 'update[:{room-id}]': A room object was successfully updated with changed
|
|
* properties in the data store.
|
|
* - 'joined[:{room-id}]': A participant joined a room.
|
|
* - 'left[:{room-id}]': A participant left a room.
|
|
*
|
|
* See the internal code for the API documentation.
|
|
*/
|
|
this.LoopRooms = {
|
|
get participantsCount() {
|
|
return LoopRoomsInternal.participantsCount;
|
|
},
|
|
|
|
getAll: function(version, callback) {
|
|
return LoopRoomsInternal.getAll(version, callback);
|
|
},
|
|
|
|
get: function(roomToken, callback) {
|
|
return LoopRoomsInternal.get(roomToken, callback);
|
|
},
|
|
|
|
create: function(options, callback) {
|
|
return LoopRoomsInternal.create(options, callback);
|
|
},
|
|
|
|
open: function(roomToken) {
|
|
return LoopRoomsInternal.open(roomToken);
|
|
},
|
|
|
|
delete: function(roomToken, callback) {
|
|
return LoopRoomsInternal.delete(roomToken, callback);
|
|
},
|
|
|
|
join: function(roomToken, callback) {
|
|
return LoopRoomsInternal.join(roomToken, callback);
|
|
},
|
|
|
|
refreshMembership: function(roomToken, sessionToken, callback) {
|
|
return LoopRoomsInternal.refreshMembership(roomToken, sessionToken,
|
|
callback);
|
|
},
|
|
|
|
leave: function(roomToken, sessionToken, callback) {
|
|
return LoopRoomsInternal.leave(roomToken, sessionToken, callback);
|
|
},
|
|
|
|
sendConnectionStatus: function(roomToken, sessionToken, status, callback) {
|
|
return LoopRoomsInternal.sendConnectionStatus(roomToken, sessionToken, status, callback);
|
|
},
|
|
|
|
update: function(roomToken, roomData, callback) {
|
|
return LoopRoomsInternal.update(roomToken, roomData, callback);
|
|
},
|
|
|
|
getGuestCreatedRoom: function() {
|
|
return LoopRoomsInternal.getGuestCreatedRoom();
|
|
},
|
|
|
|
maybeRefresh: function(user) {
|
|
return LoopRoomsInternal.maybeRefresh(user);
|
|
},
|
|
|
|
/**
|
|
* This method is only useful for unit tests to set the rooms cache to contain
|
|
* a list of fake room data that can be asserted in tests.
|
|
*
|
|
* @param {Map} stub Stub cache containing fake rooms data
|
|
*/
|
|
stubCache: function(stub) {
|
|
LoopRoomsInternal.rooms.clear();
|
|
if (stub) {
|
|
// Fill up the rooms cache with room objects provided in the `stub` Map.
|
|
for (let [key, value] of stub.entries()) {
|
|
LoopRoomsInternal.rooms.set(key, value);
|
|
}
|
|
gDirty = false;
|
|
} else {
|
|
// Restore the cache to not be stubbed anymore, but it'll need a refresh
|
|
// from the server for sure.
|
|
gDirty = true;
|
|
}
|
|
},
|
|
|
|
promise: function(method, ...params) {
|
|
return new Promise((resolve, reject) => {
|
|
this[method](...params, (error, result) => {
|
|
if (error) {
|
|
reject(error);
|
|
} else {
|
|
resolve(result);
|
|
}
|
|
});
|
|
});
|
|
},
|
|
|
|
on: (...params) => eventEmitter.on(...params),
|
|
|
|
once: (...params) => eventEmitter.once(...params),
|
|
|
|
off: (...params) => eventEmitter.off(...params)
|
|
};
|
|
Object.freeze(this.LoopRooms);
|