1306 lines
42 KiB
JavaScript
1306 lines
42 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/. */
|
|
|
|
/**
|
|
* Firefox Accounts Web Channel.
|
|
*
|
|
* Uses the WebChannel component to receive messages
|
|
* about account state changes.
|
|
*/
|
|
|
|
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
|
|
|
|
import {
|
|
COMMAND_PROFILE_CHANGE,
|
|
COMMAND_LOGIN,
|
|
COMMAND_LOGOUT,
|
|
COMMAND_OAUTH,
|
|
COMMAND_DELETE,
|
|
COMMAND_CAN_LINK_ACCOUNT,
|
|
COMMAND_SYNC_PREFERENCES,
|
|
COMMAND_CHANGE_PASSWORD,
|
|
COMMAND_FXA_STATUS,
|
|
COMMAND_PAIR_HEARTBEAT,
|
|
COMMAND_PAIR_SUPP_METADATA,
|
|
COMMAND_PAIR_AUTHORIZE,
|
|
COMMAND_PAIR_DECLINE,
|
|
COMMAND_PAIR_COMPLETE,
|
|
COMMAND_PAIR_PREFERENCES,
|
|
COMMAND_FIREFOX_VIEW,
|
|
OAUTH_CLIENT_ID,
|
|
ON_PROFILE_CHANGE_NOTIFICATION,
|
|
PREF_LAST_FXA_USER,
|
|
WEBCHANNEL_ID,
|
|
log,
|
|
logPII,
|
|
} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
|
|
import { SyncDisconnect } from "resource://services-sync/SyncDisconnect.sys.mjs";
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
CryptoUtils: "resource://services-crypto/utils.sys.mjs",
|
|
FxAccountsPairingFlow: "resource://gre/modules/FxAccountsPairing.sys.mjs",
|
|
FxAccountsStorageManagerCanStoreField:
|
|
"resource://gre/modules/FxAccountsStorage.sys.mjs",
|
|
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
|
|
Weave: "resource://services-sync/main.sys.mjs",
|
|
WebChannel: "resource://gre/modules/WebChannel.sys.mjs",
|
|
});
|
|
ChromeUtils.defineLazyGetter(lazy, "SelectableProfileService", () => {
|
|
try {
|
|
// Only available in Firefox.
|
|
return ChromeUtils.importESModule(
|
|
"resource:///modules/profiles/SelectableProfileService.sys.mjs"
|
|
).SelectableProfileService;
|
|
} catch (ex) {
|
|
return null;
|
|
}
|
|
});
|
|
ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
|
|
return ChromeUtils.importESModule(
|
|
"resource://gre/modules/FxAccounts.sys.mjs"
|
|
).getFxAccountsSingleton();
|
|
});
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"pairingEnabled",
|
|
"identity.fxaccounts.pairing.enabled"
|
|
);
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"separatePrivilegedMozillaWebContentProcess",
|
|
"browser.tabs.remote.separatePrivilegedMozillaWebContentProcess",
|
|
false
|
|
);
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"separatedMozillaDomains",
|
|
"browser.tabs.remote.separatedMozillaDomains",
|
|
"",
|
|
false,
|
|
val => val.split(",")
|
|
);
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"accountServer",
|
|
"identity.fxaccounts.remote.root",
|
|
null,
|
|
false,
|
|
val => Services.io.newURI(val)
|
|
);
|
|
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"oauthEnabled",
|
|
"identity.fxaccounts.oauth.enabled",
|
|
false
|
|
);
|
|
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"allowSyncMerge",
|
|
"browser.profiles.sync.allow-danger-merge",
|
|
false
|
|
);
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "l10n", function () {
|
|
return new Localization(["browser/sync.ftl", "branding/brand.ftl"], true);
|
|
});
|
|
|
|
// These engines will be displayed to the user to pick which they would like to
|
|
// use.
|
|
const CHOOSE_WHAT_TO_SYNC_ALWAYS_AVAILABLE = [
|
|
"addons",
|
|
"bookmarks",
|
|
"history",
|
|
"passwords",
|
|
"prefs",
|
|
"tabs",
|
|
];
|
|
|
|
// Engines which we need to inspect a pref to see if they are available, and
|
|
// possibly have their default preference value to disabled.
|
|
const CHOOSE_WHAT_TO_SYNC_OPTIONALLY_AVAILABLE = ["addresses", "creditcards"];
|
|
|
|
/**
|
|
* A helper function that extracts the message and stack from an error object.
|
|
* Returns a `{ message, stack }` tuple. `stack` will be null if the error
|
|
* doesn't have a stack trace.
|
|
*/
|
|
function getErrorDetails(error) {
|
|
// Replace anything that looks like it might be a filepath on Windows or Unix
|
|
let cleanMessage = String(error)
|
|
.replace(/\\.*\\/gm, "[REDACTED]")
|
|
.replace(/\/.*\//gm, "[REDACTED]");
|
|
let details = { message: cleanMessage, stack: null };
|
|
|
|
// Adapted from Console.sys.mjs.
|
|
if (error.stack) {
|
|
let frames = [];
|
|
for (let frame = error.stack; frame; frame = frame.caller) {
|
|
frames.push(String(frame).padStart(4));
|
|
}
|
|
details.stack = frames.join("\n");
|
|
}
|
|
|
|
return details;
|
|
}
|
|
|
|
/**
|
|
* Create a new FxAccountsWebChannel to listen for account updates
|
|
*
|
|
* @param {Object} options Options
|
|
* @param {Object} options
|
|
* @param {String} options.content_uri
|
|
* The FxA Content server uri
|
|
* @param {String} options.channel_id
|
|
* The ID of the WebChannel
|
|
* @param {String} options.helpers
|
|
* Helpers functions. Should only be passed in for testing.
|
|
* @constructor
|
|
*/
|
|
export function FxAccountsWebChannel(options) {
|
|
if (!options) {
|
|
throw new Error("Missing configuration options");
|
|
}
|
|
if (!options.content_uri) {
|
|
throw new Error("Missing 'content_uri' option");
|
|
}
|
|
this._contentUri = options.content_uri;
|
|
|
|
if (!options.channel_id) {
|
|
throw new Error("Missing 'channel_id' option");
|
|
}
|
|
this._webChannelId = options.channel_id;
|
|
|
|
// options.helpers is only specified by tests.
|
|
ChromeUtils.defineLazyGetter(this, "_helpers", () => {
|
|
return options.helpers || new FxAccountsWebChannelHelpers(options);
|
|
});
|
|
|
|
this._setupChannel();
|
|
}
|
|
|
|
FxAccountsWebChannel.prototype = {
|
|
/**
|
|
* WebChannel that is used to communicate with content page
|
|
*/
|
|
_channel: null,
|
|
|
|
/**
|
|
* Helpers interface that does the heavy lifting.
|
|
*/
|
|
_helpers: null,
|
|
|
|
/**
|
|
* WebChannel ID.
|
|
*/
|
|
_webChannelId: null,
|
|
/**
|
|
* WebChannel origin, used to validate origin of messages
|
|
*/
|
|
_webChannelOrigin: null,
|
|
|
|
/**
|
|
* The promise which is handling the most recent webchannel message we received.
|
|
* Used to avoid us handling multiple messages concurrently.
|
|
*/
|
|
_lastPromise: null,
|
|
|
|
/**
|
|
* Release all resources that are in use.
|
|
*/
|
|
tearDown() {
|
|
this._channel.stopListening();
|
|
this._channel = null;
|
|
this._channelCallback = null;
|
|
},
|
|
|
|
/**
|
|
* Configures and registers a new WebChannel
|
|
*
|
|
* @private
|
|
*/
|
|
_setupChannel() {
|
|
// if this.contentUri is present but not a valid URI, then this will throw an error.
|
|
try {
|
|
this._webChannelOrigin = Services.io.newURI(this._contentUri);
|
|
this._registerChannel();
|
|
} catch (e) {
|
|
log.error(e);
|
|
throw e;
|
|
}
|
|
},
|
|
|
|
_receiveMessage(message, sendingContext) {
|
|
log.trace(`_receiveMessage for command ${message.command}`);
|
|
let shouldCheckRemoteType =
|
|
lazy.separatePrivilegedMozillaWebContentProcess &&
|
|
lazy.separatedMozillaDomains.some(function (val) {
|
|
return (
|
|
lazy.accountServer.asciiHost == val ||
|
|
lazy.accountServer.asciiHost.endsWith("." + val)
|
|
);
|
|
});
|
|
let { currentRemoteType } = sendingContext.browsingContext;
|
|
if (shouldCheckRemoteType && currentRemoteType != "privilegedmozilla") {
|
|
log.error(
|
|
`Rejected FxA webchannel message from remoteType = ${currentRemoteType}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Here we do some promise dances to ensure we are never handling multiple messages
|
|
// concurrently, which can happen for async message handlers.
|
|
// Not all handlers are async, which is something we should clean up to make this simpler.
|
|
// Start with ensuring the last promise we saw is complete.
|
|
let lastPromise = this._lastPromise || Promise.resolve();
|
|
this._lastPromise = lastPromise
|
|
.then(() => {
|
|
return this._promiseMessage(message, sendingContext);
|
|
})
|
|
.catch(e => {
|
|
log.error("Handling webchannel message failed", e);
|
|
this._sendError(e, message, sendingContext);
|
|
})
|
|
.finally(() => {
|
|
this._lastPromise = null;
|
|
});
|
|
},
|
|
|
|
async _promiseMessage(message, sendingContext) {
|
|
const { command, data } = message;
|
|
let browser = sendingContext.browsingContext.top.embedderElement;
|
|
switch (command) {
|
|
case COMMAND_PROFILE_CHANGE:
|
|
Services.obs.notifyObservers(
|
|
null,
|
|
ON_PROFILE_CHANGE_NOTIFICATION,
|
|
data.uid
|
|
);
|
|
break;
|
|
case COMMAND_LOGIN:
|
|
await this._helpers.login(data);
|
|
await this._channel.send(
|
|
{ command, messageId: message.messageId, data: { ok: true } },
|
|
sendingContext
|
|
);
|
|
break;
|
|
case COMMAND_OAUTH:
|
|
await this._helpers.oauthLogin(data);
|
|
await this._channel.send(
|
|
{ command, messageId: message.messageId, data: { ok: true } },
|
|
sendingContext
|
|
);
|
|
break;
|
|
case COMMAND_LOGOUT:
|
|
case COMMAND_DELETE:
|
|
await this._helpers.logout(data.uid);
|
|
await this._channel.send(
|
|
{ command, messageId: message.messageId, data: { ok: true } },
|
|
sendingContext
|
|
);
|
|
break;
|
|
case COMMAND_CAN_LINK_ACCOUNT:
|
|
{
|
|
let response = { command, messageId: message.messageId };
|
|
// If browser profiles are not enabled, then we use the old merge sync dialog
|
|
if (!lazy.SelectableProfileService?.isEnabled) {
|
|
response.data = { ok: this._helpers.shouldAllowRelink(data.email) };
|
|
this._channel.send(response, sendingContext);
|
|
break;
|
|
}
|
|
// In the new sync warning, we give users a few more options to
|
|
// control what they want to do with their sync data
|
|
let result = await this._helpers.promptProfileSyncWarningIfNeeded(
|
|
data.email
|
|
);
|
|
switch (result.action) {
|
|
case "create-profile":
|
|
lazy.SelectableProfileService.createNewProfile();
|
|
response.data = { ok: false };
|
|
break;
|
|
case "switch-profile":
|
|
lazy.SelectableProfileService.launchInstance(result.data);
|
|
response.data = { ok: false };
|
|
break;
|
|
// Either no warning was shown, or user selected the continue option
|
|
// to link the account
|
|
case "continue":
|
|
response.data = { ok: true };
|
|
break;
|
|
case "cancel":
|
|
response.data = { ok: false };
|
|
break;
|
|
default:
|
|
log.error(
|
|
"Invalid FxAccountsWebChannel dialog response: ",
|
|
result.action
|
|
);
|
|
response.data = { ok: false };
|
|
break;
|
|
}
|
|
log.debug("FxAccountsWebChannel response", response);
|
|
// Send the response based on what the user selected above
|
|
this._channel.send(response, sendingContext);
|
|
}
|
|
break;
|
|
case COMMAND_SYNC_PREFERENCES:
|
|
this._helpers.openSyncPreferences(browser, data.entryPoint);
|
|
this._channel.send(
|
|
{ command, messageId: message.messageId, data: { ok: true } },
|
|
sendingContext
|
|
);
|
|
break;
|
|
case COMMAND_PAIR_PREFERENCES:
|
|
if (lazy.pairingEnabled) {
|
|
let win = browser.ownerGlobal;
|
|
this._channel.send(
|
|
{ command, messageId: message.messageId, data: { ok: true } },
|
|
sendingContext
|
|
);
|
|
win.openTrustedLinkIn(
|
|
"about:preferences?action=pair#sync",
|
|
"current"
|
|
);
|
|
}
|
|
break;
|
|
case COMMAND_FIREFOX_VIEW:
|
|
this._helpers.openFirefoxView(browser, data.entryPoint);
|
|
this._channel.send(
|
|
{ command, messageId: message.messageId, data: { ok: true } },
|
|
sendingContext
|
|
);
|
|
break;
|
|
case COMMAND_CHANGE_PASSWORD:
|
|
await this._helpers.changePassword(data);
|
|
await this._channel.send(
|
|
{ command, messageId: message.messageId, data: { ok: true } },
|
|
sendingContext
|
|
);
|
|
break;
|
|
case COMMAND_FXA_STATUS:
|
|
log.debug("fxa_status received");
|
|
const service = data && data.service;
|
|
const isPairing = data && data.isPairing;
|
|
const context = data && data.context;
|
|
await this._helpers
|
|
.getFxaStatus(service, sendingContext, isPairing, context)
|
|
.then(fxaStatus => {
|
|
let response = {
|
|
command,
|
|
messageId: message.messageId,
|
|
data: fxaStatus,
|
|
};
|
|
this._channel.send(response, sendingContext);
|
|
});
|
|
break;
|
|
case COMMAND_PAIR_HEARTBEAT:
|
|
case COMMAND_PAIR_SUPP_METADATA:
|
|
case COMMAND_PAIR_AUTHORIZE:
|
|
case COMMAND_PAIR_DECLINE:
|
|
case COMMAND_PAIR_COMPLETE:
|
|
log.debug(`Pairing command ${command} received`);
|
|
const { channel_id: channelId } = data;
|
|
delete data.channel_id;
|
|
const flow = lazy.FxAccountsPairingFlow.get(channelId);
|
|
if (!flow) {
|
|
log.warn(`Could not find a pairing flow for ${channelId}`);
|
|
return;
|
|
}
|
|
flow.onWebChannelMessage(command, data).then(replyData => {
|
|
this._channel.send(
|
|
{
|
|
command,
|
|
messageId: message.messageId,
|
|
data: replyData,
|
|
},
|
|
sendingContext
|
|
);
|
|
});
|
|
break;
|
|
default: {
|
|
let errorMessage = "Unrecognized FxAccountsWebChannel command";
|
|
log.warn(errorMessage, command);
|
|
this._channel.send({
|
|
command,
|
|
messageId: message.messageId,
|
|
data: { error: errorMessage },
|
|
});
|
|
// As a safety measure we also terminate any pending FxA pairing flow.
|
|
lazy.FxAccountsPairingFlow.finalizeAll();
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
|
|
_sendError(error, incomingMessage, sendingContext) {
|
|
log.error("Failed to handle FxAccountsWebChannel message", error);
|
|
this._channel.send(
|
|
{
|
|
command: incomingMessage.command,
|
|
messageId: incomingMessage.messageId,
|
|
data: {
|
|
error: getErrorDetails(error),
|
|
},
|
|
},
|
|
sendingContext
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Create a new channel with the WebChannelBroker, setup a callback listener
|
|
* @private
|
|
*/
|
|
_registerChannel() {
|
|
/**
|
|
* Processes messages that are called back from the FxAccountsChannel
|
|
*
|
|
* @param webChannelId {String}
|
|
* Command webChannelId
|
|
* @param message {Object}
|
|
* Command message
|
|
* @param sendingContext {Object}
|
|
* Message sending context.
|
|
* @param sendingContext.browsingContext {BrowsingContext}
|
|
* The browsingcontext from which the
|
|
* WebChannelMessageToChrome was sent.
|
|
* @param sendingContext.eventTarget {EventTarget}
|
|
* The <EventTarget> where the message was sent.
|
|
* @param sendingContext.principal {Principal}
|
|
* The <Principal> of the EventTarget where the message was sent.
|
|
* @private
|
|
*
|
|
*/
|
|
let listener = (webChannelId, message, sendingContext) => {
|
|
if (message) {
|
|
log.debug("FxAccountsWebChannel message received", message.command);
|
|
if (logPII()) {
|
|
log.debug("FxAccountsWebChannel message details", message);
|
|
}
|
|
try {
|
|
this._receiveMessage(message, sendingContext);
|
|
} catch (error) {
|
|
// this should be impossible - _receiveMessage will do this, but better safe than sorry.
|
|
log.error(
|
|
"Unexpected webchannel error escaped from promise error handlers"
|
|
);
|
|
this._sendError(error, message, sendingContext);
|
|
}
|
|
}
|
|
};
|
|
|
|
this._channelCallback = listener;
|
|
this._channel = new lazy.WebChannel(
|
|
this._webChannelId,
|
|
this._webChannelOrigin
|
|
);
|
|
this._channel.listen(listener);
|
|
log.debug(
|
|
"FxAccountsWebChannel registered: " +
|
|
this._webChannelId +
|
|
" with origin " +
|
|
this._webChannelOrigin.prePath
|
|
);
|
|
},
|
|
};
|
|
|
|
export function FxAccountsWebChannelHelpers(options) {
|
|
options = options || {};
|
|
|
|
this._fxAccounts = options.fxAccounts || lazy.fxAccounts;
|
|
this._weaveXPCOM = options.weaveXPCOM || null;
|
|
this._privateBrowsingUtils =
|
|
options.privateBrowsingUtils || lazy.PrivateBrowsingUtils;
|
|
}
|
|
|
|
FxAccountsWebChannelHelpers.prototype = {
|
|
// If the last fxa account used for sync isn't this account, we display
|
|
// a modal dialog checking they really really want to do this...
|
|
// (This is sync-specific, so ideally would be in sync's identity module,
|
|
// but it's a little more seamless to do here, and sync is currently the
|
|
// only fxa consumer, so...
|
|
shouldAllowRelink(acctName) {
|
|
return (
|
|
!this._needRelinkWarning(acctName) || this._promptForRelink(acctName)
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Checks if the user is potentially hitting an issue with the current
|
|
* account they're logging into. Returns the choice of the user if shown
|
|
* @returns {string} - The corresponding option the user pressed. Can be either:
|
|
* cancel, continue, switch-profile, or create-profile
|
|
*/
|
|
async promptProfileSyncWarningIfNeeded(acctEmail) {
|
|
// Was a previous account signed into this profile or is there another profile currently signed in
|
|
// to the account we're signing into
|
|
let profileLinkedWithAcct =
|
|
await this._getProfileAssociatedWithAcct(acctEmail);
|
|
if (this._needRelinkWarning(acctEmail) || profileLinkedWithAcct) {
|
|
return this._promptForProfileSyncWarning(
|
|
acctEmail,
|
|
profileLinkedWithAcct
|
|
);
|
|
}
|
|
// The user has no warnings needed and can continue signing in
|
|
return { action: "continue" };
|
|
},
|
|
|
|
async _initializeSync() {
|
|
// A sync-specific hack - we want to ensure sync has been initialized
|
|
// before we set the signed-in user.
|
|
// XXX - probably not true any more, especially now we have observerPreloads
|
|
// in FxAccounts.sys.mjs?
|
|
let xps =
|
|
this._weaveXPCOM ||
|
|
Cc["@mozilla.org/weave/service;1"].getService(Ci.nsISupports)
|
|
.wrappedJSObject;
|
|
await xps.whenLoaded();
|
|
return xps;
|
|
},
|
|
|
|
_setEnabledEngines(offeredEngines, declinedEngines) {
|
|
if (offeredEngines && declinedEngines) {
|
|
log.debug("Received offered engines", offeredEngines);
|
|
CHOOSE_WHAT_TO_SYNC_OPTIONALLY_AVAILABLE.forEach(engine => {
|
|
if (
|
|
offeredEngines.includes(engine) &&
|
|
!declinedEngines.includes(engine)
|
|
) {
|
|
// These extra engines are disabled by default.
|
|
log.debug(`Enabling optional engine '${engine}'`);
|
|
Services.prefs.setBoolPref(`services.sync.engine.${engine}`, true);
|
|
}
|
|
});
|
|
log.debug("Received declined engines", declinedEngines);
|
|
lazy.Weave.Service.engineManager.setDeclined(declinedEngines);
|
|
declinedEngines.forEach(engine => {
|
|
Services.prefs.setBoolPref(`services.sync.engine.${engine}`, false);
|
|
});
|
|
} else {
|
|
log.debug("Did not receive any engine selection information");
|
|
}
|
|
},
|
|
|
|
/** Internal function used to configure the requested services.
|
|
*
|
|
* The "services" param is an object as received from the FxA server.
|
|
*/
|
|
async _enableRequestedServices(requestedServices) {
|
|
if (!requestedServices) {
|
|
log.warn(
|
|
"fxa login completed but we don't have a record of which services were enabled."
|
|
);
|
|
return;
|
|
}
|
|
log.debug(`services requested are ${Object.keys(requestedServices)}`);
|
|
if (requestedServices.sync) {
|
|
const xps = await this._initializeSync();
|
|
const { offeredEngines, declinedEngines } = requestedServices.sync;
|
|
this._setEnabledEngines(offeredEngines, declinedEngines);
|
|
log.debug("Webchannel is enabling sync");
|
|
await xps.Weave.Service.configure();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* The login message is sent when the user user has initially logged in but may not be fully connected.
|
|
* * In the non-oauth flows, if the user is verified, then the browser itself is able to transition the
|
|
* user to fully connected.
|
|
* * In the oauth flows, we will need an `oauth_login` message with our scoped keys to be fully connected.
|
|
* @param accountData the user's account data and credentials
|
|
*/
|
|
async login(accountData) {
|
|
// This is delicate for oauth flows and edge-cases. Consider (a) user logs in but does not verify,
|
|
// (b) browser restarts, (c) user select "finish setup", at which point they are again prompted for their password.
|
|
// In that scenario, we've been sent this `login` message *both* at (a) and at (c).
|
|
// Importantly, the message from (a) is the one that actually has the service information we care about
|
|
// (eg, the sync engine selections) - (c) *will* have `services.sync` but it will be an empty object.
|
|
// This means we need to take care to not lose the services from (a) when processing (c).
|
|
const signedInUser = await this._fxAccounts.getSignedInUser([
|
|
"requestedServices",
|
|
]);
|
|
let existingServices;
|
|
if (signedInUser) {
|
|
if (signedInUser.uid != accountData.uid) {
|
|
log.warn(
|
|
"the webchannel found a different user signed in - signing them out."
|
|
);
|
|
await this._disconnect();
|
|
} else {
|
|
existingServices = signedInUser.requestedServices
|
|
? JSON.parse(signedInUser.requestedServices)
|
|
: {};
|
|
log.debug(
|
|
"Webchannel is updating the info for an already logged in user."
|
|
);
|
|
}
|
|
} else {
|
|
log.debug("Webchannel is logging new a user in.");
|
|
}
|
|
// There are (or were) extra fields here we don't want to actually store.
|
|
delete accountData.customizeSync;
|
|
delete accountData.verifiedCanLinkAccount;
|
|
if (lazy.oauthEnabled) {
|
|
// We once accidentally saw these from the server and got confused about who owned the key fetching.
|
|
delete accountData.keyFetchToken;
|
|
delete accountData.unwrapBKey;
|
|
}
|
|
|
|
// The "services" being connected - see above re our careful handling of existing data.
|
|
// Note that we don't attempt to merge any data - we keep the first value we see for a service
|
|
// and ignore that service subsequently (as it will be common for subsequent messages to
|
|
// name a service but not supply any data for it)
|
|
const requestedServices = {
|
|
...(accountData.services ?? {}),
|
|
...existingServices,
|
|
};
|
|
delete accountData.services;
|
|
|
|
// This `verified` check is really just for our tests and pre-oauth flows.
|
|
// However, in all cases it's misplaced - we should set it as soon as *sync*
|
|
// starts or is configured, as it's the merging done by sync it protects against.
|
|
// We should clean up handling of this pref in a followup.
|
|
if (accountData.verified) {
|
|
this.setPreviousAccountNameHashPref(accountData.email);
|
|
}
|
|
|
|
await this._fxAccounts.telemetry.recordConnection(
|
|
Object.keys(requestedServices),
|
|
"webchannel"
|
|
);
|
|
|
|
if (lazy.oauthEnabled) {
|
|
// We need to remember the requested services because we can't act on them until we get the `oauth_login` message.
|
|
// And because we might not get that message in this browser session (eg, the browser might restart before the
|
|
// user enters their verification code), they are persisted with the account state.
|
|
log.debug(`storing info for services ${Object.keys(requestedServices)}`);
|
|
accountData.requestedServices = JSON.stringify(requestedServices);
|
|
await this._fxAccounts._internal.setSignedInUser(accountData);
|
|
} else {
|
|
// Note we don't persist anything in requestedServices for non oauth flows because we act on them now.
|
|
await this._fxAccounts._internal.setSignedInUser(accountData);
|
|
await this._enableRequestedServices(requestedServices);
|
|
}
|
|
log.debug("Webchannel finished logging a user in.");
|
|
},
|
|
|
|
/**
|
|
* Logins in to sync by completing an OAuth flow
|
|
* @param { Object } oauthData: The oauth code and state as returned by the server
|
|
*/
|
|
async oauthLogin(oauthData) {
|
|
log.debug("Webchannel is completing the oauth flow");
|
|
const { uid, sessionToken, email, requestedServices } =
|
|
await this._fxAccounts._internal.getUserAccountData([
|
|
"uid",
|
|
"sessionToken",
|
|
"email",
|
|
"requestedServices",
|
|
]);
|
|
// First we finish the ongoing oauth flow
|
|
const { scopedKeys, refreshToken } =
|
|
await this._fxAccounts._internal.completeOAuthFlow(
|
|
sessionToken,
|
|
oauthData.code,
|
|
oauthData.state
|
|
);
|
|
|
|
// We don't currently use the refresh token in Firefox Desktop, lets be good citizens and revoke it.
|
|
await this._fxAccounts._internal.destroyOAuthToken({ token: refreshToken });
|
|
|
|
// Remember the account for future merge warnings etc.
|
|
this.setPreviousAccountNameHashPref(email);
|
|
|
|
// Then, we persist the sync keys
|
|
await this._fxAccounts._internal.setScopedKeys(scopedKeys);
|
|
|
|
try {
|
|
let parsedRequestedServices;
|
|
if (requestedServices) {
|
|
parsedRequestedServices = JSON.parse(requestedServices);
|
|
}
|
|
await this._enableRequestedServices(parsedRequestedServices);
|
|
} finally {
|
|
// We don't want them hanging around in storage.
|
|
await this._fxAccounts._internal.updateUserAccountData({
|
|
uid,
|
|
requestedServices: null,
|
|
});
|
|
}
|
|
|
|
// Now that we have the scoped keys, we set our status to verified.
|
|
// This will kick off Sync or other services we configured.
|
|
await this._fxAccounts._internal.setUserVerified();
|
|
log.debug("Webchannel completed oauth flows");
|
|
},
|
|
|
|
/**
|
|
* Disconnects the user from Sync and FxA
|
|
*/
|
|
_disconnect() {
|
|
return SyncDisconnect.disconnect(false);
|
|
},
|
|
|
|
/**
|
|
* logout the fxaccounts service
|
|
*
|
|
* @param the uid of the account which have been logged out
|
|
*/
|
|
async logout(uid) {
|
|
let fxa = this._fxAccounts;
|
|
let userData = await fxa._internal.getUserAccountData(["uid"]);
|
|
if (userData && userData.uid === uid) {
|
|
await fxa.telemetry.recordDisconnection(null, "webchannel");
|
|
// true argument is `localOnly`, because server-side stuff
|
|
// has already been taken care of by the content server
|
|
await fxa.signOut(true);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Check if `sendingContext` is in private browsing mode.
|
|
*/
|
|
isPrivateBrowsingMode(sendingContext) {
|
|
if (!sendingContext) {
|
|
log.error("Unable to check for private browsing mode, assuming true");
|
|
return true;
|
|
}
|
|
|
|
let browser = sendingContext.browsingContext.top.embedderElement;
|
|
const isPrivateBrowsing =
|
|
this._privateBrowsingUtils.isBrowserPrivate(browser);
|
|
return isPrivateBrowsing;
|
|
},
|
|
|
|
/**
|
|
* Check whether sending fxa_status data should be allowed.
|
|
*/
|
|
shouldAllowFxaStatus(service, sendingContext, isPairing, context) {
|
|
// Return user data for any service in non-PB mode. In PB mode,
|
|
// only return user data if service==="sync" or is in pairing mode
|
|
// (as service will be equal to the OAuth client ID and not "sync").
|
|
//
|
|
// This behaviour allows users to click the "Manage Account"
|
|
// link from about:preferences#sync while in PB mode and things
|
|
// "just work". While in non-PB mode, users can sign into
|
|
// Pocket w/o entering their password a 2nd time, while in PB
|
|
// mode they *will* have to enter their email/password again.
|
|
//
|
|
// The difference in behaviour is to try to match user
|
|
// expectations as to what is and what isn't part of the browser.
|
|
// Sync is viewed as an integral part of the browser, interacting
|
|
// with FxA as part of a Sync flow should work all the time. If
|
|
// Sync is broken in PB mode, users will think Firefox is broken.
|
|
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1323853
|
|
let pb = this.isPrivateBrowsingMode(sendingContext);
|
|
let ok = !pb || service === "sync" || isPairing;
|
|
log.debug(
|
|
`fxa status ok=${ok} - private=${pb}, service=${service}, context=${context}, pairing=${isPairing}`
|
|
);
|
|
return ok;
|
|
},
|
|
|
|
/**
|
|
* Get fxa_status information. Resolves to { signedInUser: <user_data> }.
|
|
* If returning status information is not allowed or no user is signed into
|
|
* Sync, `user_data` will be null.
|
|
*/
|
|
async getFxaStatus(service, sendingContext, isPairing, context) {
|
|
let signedInUser = null;
|
|
|
|
if (
|
|
this.shouldAllowFxaStatus(service, sendingContext, isPairing, context)
|
|
) {
|
|
const userData = await this._fxAccounts._internal.getUserAccountData([
|
|
"email",
|
|
"sessionToken",
|
|
"uid",
|
|
"verified",
|
|
]);
|
|
if (userData) {
|
|
signedInUser = {
|
|
email: userData.email,
|
|
sessionToken: userData.sessionToken,
|
|
uid: userData.uid,
|
|
verified: userData.verified,
|
|
};
|
|
}
|
|
}
|
|
|
|
const capabilities = this._getCapabilities();
|
|
|
|
return {
|
|
signedInUser,
|
|
clientId: OAUTH_CLIENT_ID,
|
|
capabilities,
|
|
};
|
|
},
|
|
|
|
_getCapabilities() {
|
|
// pre-oauth flows there we a strange setup where we just supplied the "extra" engines,
|
|
// whereas oauth flows want them all.
|
|
let engines = lazy.oauthEnabled
|
|
? Array.from(CHOOSE_WHAT_TO_SYNC_ALWAYS_AVAILABLE)
|
|
: [];
|
|
for (let optionalEngine of CHOOSE_WHAT_TO_SYNC_OPTIONALLY_AVAILABLE) {
|
|
if (
|
|
Services.prefs.getBoolPref(
|
|
`services.sync.engine.${optionalEngine}.available`,
|
|
false
|
|
)
|
|
) {
|
|
engines.push(optionalEngine);
|
|
}
|
|
}
|
|
return {
|
|
multiService: true,
|
|
pairing: lazy.pairingEnabled,
|
|
choose_what_to_sync: true,
|
|
engines,
|
|
};
|
|
},
|
|
|
|
async changePassword(credentials) {
|
|
// If |credentials| has fields that aren't handled by accounts storage,
|
|
// updateUserAccountData will throw - mainly to prevent errors in code
|
|
// that hard-codes field names.
|
|
// However, in this case the field names aren't really in our control.
|
|
// We *could* still insist the server know what fields names are valid,
|
|
// but that makes life difficult for the server when Firefox adds new
|
|
// features (ie, new fields) - forcing the server to track a map of
|
|
// versions to supported field names doesn't buy us much.
|
|
// So we just remove field names we know aren't handled.
|
|
let newCredentials = {
|
|
device: null, // Force a brand new device registration.
|
|
// We force the re-encryption of the send tab keys using the new sync key after the password change
|
|
encryptedSendTabKeys: null,
|
|
};
|
|
for (let name of Object.keys(credentials)) {
|
|
if (
|
|
name == "email" ||
|
|
name == "uid" ||
|
|
lazy.FxAccountsStorageManagerCanStoreField(name)
|
|
) {
|
|
newCredentials[name] = credentials[name];
|
|
} else {
|
|
log.info("changePassword ignoring unsupported field", name);
|
|
}
|
|
}
|
|
await this._fxAccounts._internal.updateUserAccountData(newCredentials);
|
|
await this._fxAccounts._internal.updateDeviceRegistration();
|
|
},
|
|
|
|
/**
|
|
* Get the hash of account name of the previously signed in account
|
|
*/
|
|
getPreviousAccountNameHashPref() {
|
|
try {
|
|
return Services.prefs.getStringPref(PREF_LAST_FXA_USER);
|
|
} catch (_) {
|
|
return "";
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Given an account name, set the hash of the previously signed in account
|
|
*
|
|
* @param acctName the account name of the user's account.
|
|
*/
|
|
setPreviousAccountNameHashPref(acctName) {
|
|
Services.prefs.setStringPref(
|
|
PREF_LAST_FXA_USER,
|
|
lazy.CryptoUtils.sha256Base64(acctName)
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Open Sync Preferences in the current tab of the browser
|
|
*
|
|
* @param {Object} browser the browser in which to open preferences
|
|
* @param {String} [entryPoint] entryPoint to use for logging
|
|
*/
|
|
openSyncPreferences(browser, entryPoint) {
|
|
let uri = "about:preferences";
|
|
if (entryPoint) {
|
|
uri += "?entrypoint=" + encodeURIComponent(entryPoint);
|
|
}
|
|
uri += "#sync";
|
|
|
|
browser.loadURI(Services.io.newURI(uri), {
|
|
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Open Firefox View in the browser's window
|
|
*
|
|
* @param {Object} browser the browser in whose window we'll open Firefox View
|
|
*/
|
|
openFirefoxView(browser) {
|
|
browser.ownerGlobal.FirefoxViewHandler.openTab("syncedtabs");
|
|
},
|
|
|
|
/**
|
|
* If a user signs in using a different account, the data from the
|
|
* previous account and the new account will be merged. Ask the user
|
|
* if they want to continue.
|
|
*
|
|
* @private
|
|
*/
|
|
_needRelinkWarning(acctName) {
|
|
let prevAcctHash = this.getPreviousAccountNameHashPref();
|
|
return (
|
|
prevAcctHash && prevAcctHash != lazy.CryptoUtils.sha256Base64(acctName)
|
|
);
|
|
},
|
|
|
|
// Get the current name of the profile the user is currently on
|
|
_getCurrentProfileName() {
|
|
return lazy.SelectableProfileService?.currentProfile?.name;
|
|
},
|
|
|
|
async _getAllProfiles() {
|
|
return await lazy.SelectableProfileService.getAllProfiles();
|
|
},
|
|
|
|
/**
|
|
* Checks if a profile is associated with the given account email.
|
|
*
|
|
* @param {string} acctEmail - The email of the account to check.
|
|
* @returns {Promise<SelectableProfile|null>} - The profile associated with the account, or null if none.
|
|
*/
|
|
async _getProfileAssociatedWithAcct(acctEmail) {
|
|
let profiles = await this._getAllProfiles();
|
|
let currentProfileName = await this._getCurrentProfileName();
|
|
for (let profile of profiles) {
|
|
if (profile.name === currentProfileName) {
|
|
continue; // Skip current profile
|
|
}
|
|
|
|
let profilePath = profile.path;
|
|
let signedInUserPath = PathUtils.join(profilePath, "signedInUser.json");
|
|
let signedInUser = await this._readJSONFileAsync(signedInUserPath);
|
|
if (
|
|
signedInUser?.accountData &&
|
|
signedInUser.accountData.email === acctEmail
|
|
) {
|
|
// The account is signed into another profile
|
|
return profile;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
async _readJSONFileAsync(filePath) {
|
|
try {
|
|
let data = await IOUtils.readJSON(filePath);
|
|
if (data && data.version !== 1) {
|
|
throw new Error(
|
|
`Unsupported signedInUser.json version: ${data.version}`
|
|
);
|
|
}
|
|
return data;
|
|
} catch (e) {
|
|
// File not found or error reading/parsing
|
|
return null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Show the user a warning dialog that the data from the previous account
|
|
* and the new account will be merged. _promptForSyncWarning should be
|
|
* used instead of this
|
|
*
|
|
* @private
|
|
*/
|
|
_promptForRelink(acctName) {
|
|
let [continueLabel, title, heading, description] =
|
|
lazy.l10n.formatValuesSync([
|
|
{ id: "sync-setup-verify-continue" },
|
|
{ id: "sync-setup-verify-title" },
|
|
{ id: "sync-setup-verify-heading" },
|
|
{
|
|
id: "sync-setup-verify-description",
|
|
args: {
|
|
email: acctName,
|
|
},
|
|
},
|
|
]);
|
|
let body = heading + "\n\n" + description;
|
|
let ps = Services.prompt;
|
|
let buttonFlags =
|
|
ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING +
|
|
ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL +
|
|
ps.BUTTON_POS_1_DEFAULT;
|
|
|
|
// If running in context of the browser chrome, window does not exist.
|
|
let pressed = Services.prompt.confirmEx(
|
|
null,
|
|
title,
|
|
body,
|
|
buttonFlags,
|
|
continueLabel,
|
|
null,
|
|
null,
|
|
null,
|
|
{}
|
|
);
|
|
this.emitSyncWarningDialogTelemetry(
|
|
{ 0: "continue", 1: "cancel" },
|
|
pressed,
|
|
false // old dialog doesn't have other profiles
|
|
);
|
|
return pressed === 0; // 0 is the "continue" button
|
|
},
|
|
|
|
/**
|
|
* Similar to _promptForRelink but more offers more contextual warnings
|
|
* to the user to support browser profiles.
|
|
* @returns {string} - The corresponding option the user pressed. Can be either:
|
|
* cancel, continue, switch-profile, or create-profile
|
|
*
|
|
*/
|
|
_promptForProfileSyncWarning(acctEmail, profileLinkedWithAcct) {
|
|
let currentProfile = this._getCurrentProfileName();
|
|
let title, heading, description, mergeLabel, switchLabel;
|
|
if (profileLinkedWithAcct) {
|
|
[title, heading, description, mergeLabel, switchLabel] =
|
|
lazy.l10n.formatValuesSync([
|
|
{ id: "sync-account-in-use-header" },
|
|
{
|
|
id: lazy.allowSyncMerge
|
|
? "sync-account-already-signed-in-header"
|
|
: "sync-account-in-use-header-merge",
|
|
args: {
|
|
acctEmail,
|
|
otherProfile: profileLinkedWithAcct.name,
|
|
},
|
|
},
|
|
{
|
|
id: lazy.allowSyncMerge
|
|
? "sync-account-in-use-description-merge"
|
|
: "sync-account-in-use-description",
|
|
args: {
|
|
acctEmail,
|
|
currentProfile,
|
|
otherProfile: profileLinkedWithAcct.name,
|
|
},
|
|
},
|
|
{
|
|
id: "sync-button-sync-profile",
|
|
args: { profileName: currentProfile },
|
|
},
|
|
{
|
|
id: "sync-button-switch-profile",
|
|
args: { profileName: profileLinkedWithAcct.name },
|
|
},
|
|
]);
|
|
} else {
|
|
// This current profile was previously associated with a different account
|
|
[title, heading, description, mergeLabel, switchLabel] =
|
|
lazy.l10n.formatValuesSync([
|
|
{
|
|
id: lazy.allowSyncMerge
|
|
? "sync-profile-different-account-title-merge"
|
|
: "sync-profile-different-account-title",
|
|
},
|
|
{
|
|
id: "sync-profile-different-account-header",
|
|
},
|
|
{
|
|
id: lazy.allowSyncMerge
|
|
? "sync-profile-different-account-description-merge"
|
|
: "sync-profile-different-account-description",
|
|
args: {
|
|
acctEmail,
|
|
profileName: currentProfile,
|
|
},
|
|
},
|
|
{ id: "sync-button-sync-and-merge" },
|
|
{ id: "sync-button-create-profile" },
|
|
]);
|
|
}
|
|
let result = this.showWarningPrompt({
|
|
title,
|
|
body: `${heading}\n\n${description}`,
|
|
btnLabel1: lazy.allowSyncMerge ? mergeLabel : switchLabel,
|
|
btnLabel2: lazy.allowSyncMerge ? switchLabel : null,
|
|
isAccountLoggedIntoAnotherProfile: !!profileLinkedWithAcct,
|
|
});
|
|
|
|
// If the user chose to switch profiles, return the associated profile as well.
|
|
if (result === "switch-profile") {
|
|
return { action: result, data: profileLinkedWithAcct };
|
|
}
|
|
|
|
// For all other actions, just return the action name.
|
|
return { action: result };
|
|
},
|
|
|
|
/**
|
|
* Shows the user a warning prompt.
|
|
* @returns {string} - The corresponding option the user pressed. Can be either:
|
|
* cancel, continue, switch-profile, or create-profile
|
|
*/
|
|
showWarningPrompt({
|
|
title,
|
|
body,
|
|
btnLabel1,
|
|
btnLabel2,
|
|
isAccountLoggedIntoAnotherProfile,
|
|
}) {
|
|
let ps = Services.prompt;
|
|
let buttonFlags;
|
|
let pressed;
|
|
let actionMap = {};
|
|
|
|
if (lazy.allowSyncMerge) {
|
|
// Merge allowed: two options + cancel
|
|
buttonFlags =
|
|
ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING +
|
|
ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING +
|
|
ps.BUTTON_POS_2 * ps.BUTTON_TITLE_CANCEL +
|
|
ps.BUTTON_POS_2_DEFAULT;
|
|
|
|
// Define action map based on context
|
|
if (isAccountLoggedIntoAnotherProfile) {
|
|
// Account is associated with another profile
|
|
actionMap = {
|
|
0: "continue", // merge option
|
|
1: "switch-profile",
|
|
2: "cancel",
|
|
};
|
|
} else {
|
|
// Profile was previously logged in with another account
|
|
actionMap = {
|
|
0: "continue", // merge option
|
|
1: "create-profile",
|
|
2: "cancel",
|
|
};
|
|
}
|
|
|
|
// Show the prompt
|
|
pressed = ps.confirmEx(
|
|
null,
|
|
title,
|
|
body,
|
|
buttonFlags,
|
|
btnLabel1,
|
|
btnLabel2,
|
|
null,
|
|
null,
|
|
{}
|
|
);
|
|
} else {
|
|
// Merge not allowed: one option + cancel
|
|
buttonFlags =
|
|
ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING +
|
|
ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL +
|
|
ps.BUTTON_POS_1_DEFAULT;
|
|
|
|
// Define action map based on context
|
|
if (isAccountLoggedIntoAnotherProfile) {
|
|
// Account is associated with another profile
|
|
actionMap = {
|
|
0: "switch-profile",
|
|
1: "cancel",
|
|
};
|
|
} else {
|
|
// Profile was previously logged in with another account
|
|
actionMap = {
|
|
0: "create-profile",
|
|
1: "cancel",
|
|
};
|
|
}
|
|
|
|
// Show the prompt
|
|
pressed = ps.confirmEx(
|
|
null,
|
|
title,
|
|
body,
|
|
buttonFlags,
|
|
btnLabel1,
|
|
null,
|
|
null,
|
|
null,
|
|
{}
|
|
);
|
|
}
|
|
|
|
this.emitSyncWarningDialogTelemetry(
|
|
actionMap,
|
|
pressed,
|
|
isAccountLoggedIntoAnotherProfile
|
|
);
|
|
return actionMap[pressed] || "unknown";
|
|
},
|
|
|
|
emitSyncWarningDialogTelemetry(
|
|
actionMap,
|
|
pressed,
|
|
isAccountLoggedIntoAnotherProfile
|
|
) {
|
|
let variant;
|
|
|
|
if (!lazy.SelectableProfileService?.isEnabled) {
|
|
// Old merge dialog
|
|
variant = "old-merge";
|
|
} else if (isAccountLoggedIntoAnotherProfile) {
|
|
// Sync warning dialog for profile already associated
|
|
variant = lazy.allowSyncMerge
|
|
? "sync-warning-allow-merge"
|
|
: "sync-warning";
|
|
} else {
|
|
// Sync warning dialog for a different account previously logged in
|
|
variant = lazy.allowSyncMerge
|
|
? "merge-warning-allow-merge"
|
|
: "merge-warning";
|
|
}
|
|
|
|
// Telemetry extra options
|
|
let extraOptions = {
|
|
variant_shown: variant,
|
|
option_clicked: actionMap[pressed] || "unknown",
|
|
};
|
|
|
|
// Record telemetry
|
|
Glean.syncMergeDialog?.clicked?.record(extraOptions);
|
|
},
|
|
};
|
|
|
|
var singleton;
|
|
|
|
// The entry-point for this module, which ensures only one of our channels is
|
|
// ever created - we require this because the WebChannel is global in scope
|
|
// (eg, it uses the observer service to tell interested parties of interesting
|
|
// things) and allowing multiple channels would cause such notifications to be
|
|
// sent multiple times.
|
|
export var EnsureFxAccountsWebChannel = () => {
|
|
let contentUri = Services.urlFormatter.formatURLPref(
|
|
"identity.fxaccounts.remote.root"
|
|
);
|
|
if (singleton && singleton._contentUri !== contentUri) {
|
|
singleton.tearDown();
|
|
singleton = null;
|
|
}
|
|
if (!singleton) {
|
|
try {
|
|
if (contentUri) {
|
|
// The FxAccountsWebChannel listens for events and updates
|
|
// the state machine accordingly.
|
|
singleton = new FxAccountsWebChannel({
|
|
content_uri: contentUri,
|
|
channel_id: WEBCHANNEL_ID,
|
|
});
|
|
} else {
|
|
log.warn("FxA WebChannel functionaly is disabled due to no URI pref.");
|
|
}
|
|
} catch (ex) {
|
|
log.error("Failed to create FxA WebChannel", ex);
|
|
}
|
|
}
|
|
};
|