diff options
Diffstat (limited to 'comm/mail/components/extensions/parent/ext-cloudFile.js')
-rw-r--r-- | comm/mail/components/extensions/parent/ext-cloudFile.js | 804 |
1 files changed, 804 insertions, 0 deletions
diff --git a/comm/mail/components/extensions/parent/ext-cloudFile.js b/comm/mail/components/extensions/parent/ext-cloudFile.js new file mode 100644 index 0000000000..74193d8d14 --- /dev/null +++ b/comm/mail/components/extensions/parent/ext-cloudFile.js @@ -0,0 +1,804 @@ +/* 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"; + +var { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); +var { cloudFileAccounts } = ChromeUtils.import( + "resource:///modules/cloudFileAccounts.jsm" +); + +// eslint-disable-next-line mozilla/reject-importGlobalProperties +Cu.importGlobalProperties(["File", "FileReader"]); + +async function promiseFileRead(nsifile) { + let blob = await File.createFromNsIFile(nsifile); + + return new Promise((resolve, reject) => { + let reader = new FileReader(); + reader.addEventListener("loadend", event => { + if (event.target.error) { + reject(event.target.error); + } else { + resolve(event.target.result); + } + }); + + reader.readAsArrayBuffer(blob); + }); +} + +class CloudFileAccount { + constructor(accountKey, extension) { + this.accountKey = accountKey; + this.extension = extension; + this._configured = false; + this.lastError = ""; + this.managementURL = this.extension.manifest.cloud_file.management_url; + this.reuseUploads = this.extension.manifest.cloud_file.reuse_uploads; + this.browserStyle = this.extension.manifest.cloud_file.browser_style; + this.quota = { + uploadSizeLimit: -1, + spaceRemaining: -1, + spaceUsed: -1, + }; + + this._nextId = 1; + this._uploads = new Map(); + } + + get type() { + return `ext-${this.extension.id}`; + } + get displayName() { + return Services.prefs.getCharPref( + `mail.cloud_files.accounts.${this.accountKey}.displayName`, + this.extension.manifest.cloud_file.name + ); + } + get iconURL() { + if (this.extension.manifest.icons) { + let { icon } = ExtensionParent.IconDetails.getPreferredIcon( + this.extension.manifest.icons, + this.extension, + 32 + ); + return this.extension.baseURI.resolve(icon); + } + return "chrome://messenger/content/extension.svg"; + } + get fileUploadSizeLimit() { + return this.quota.uploadSizeLimit; + } + get remainingFileSpace() { + return this.quota.spaceRemaining; + } + get fileSpaceUsed() { + return this.quota.spaceUsed; + } + get configured() { + return this._configured; + } + set configured(value) { + value = !!value; + if (value != this._configured) { + this._configured = value; + cloudFileAccounts.emit("accountConfigured", this); + } + } + get createNewAccountUrl() { + return this.extension.manifest.cloud_file.new_account_url; + } + + /** + * @typedef CloudFileDate + * @property {integer} timestamp - milliseconds since epoch + * @property {DateTimeFormat} format - format object of Intl.DateTimeFormat + */ + + /** + * @typedef CloudFileUpload + * // Values used in the WebExtension CloudFile type. + * @property {string} id - uploadId of the file + * @property {string} name - name of the file + * @property {string} url - url of the uploaded file + * // Properties of the local file. + * @property {string} path - path of the local file + * @property {string} size - size of the local file + * // Template information. + * @property {string} serviceName - name of the upload service provider + * @property {string} serviceIcon - icon of the upload service provider + * @property {string} serviceUrl - web interface of the upload service provider + * @property {boolean} downloadPasswordProtected - link is password protected + * @property {integer} downloadLimit - download limit of the link + * @property {CloudFileDate} downloadExpiryDate - expiry date of the link + * // Usage tracking. + * @property {boolean} immutable - if the cloud file url may be changed + */ + + /** + * Marks the specified upload as immutable. + * + * @param {integer} id - id of the upload + */ + markAsImmutable(id) { + if (this._uploads.has(id)) { + let upload = this._uploads.get(id); + upload.immutable = true; + this._uploads.set(id, upload); + } + } + + /** + * Returns a new upload entry, based on the provided file and data. + * + * @param {nsIFile} file + * @param {CloudFileUpload} data + * @returns {CloudFileUpload} + */ + newUploadForFile(file, data = {}) { + let id = this._nextId++; + let upload = { + // Values used in the WebExtension CloudFile type. + id, + name: data.name ?? file.leafName, + url: data.url ?? null, + // Properties of the local file. + path: file.path, + size: file.exists() ? file.fileSize : data.size || 0, + // Template information. + serviceName: data.serviceName ?? this.displayName, + serviceIcon: data.serviceIcon ?? this.iconURL, + serviceUrl: data.serviceUrl ?? "", + downloadPasswordProtected: data.downloadPasswordProtected ?? false, + downloadLimit: data.downloadLimit ?? 0, + downloadExpiryDate: data.downloadExpiryDate ?? null, + // Usage tracking. + immutable: data.immutable ?? false, + }; + + this._uploads.set(id, upload); + return upload; + } + + /** + * Initiate a WebExtension cloudFile upload by preparing a CloudFile object & + * and triggering an onFileUpload event. + * + * @param {object} window Window object of the window, where the upload has + * been initiated. Must be null, if the window is not supported by the + * WebExtension windows/tabs API. Currently, this should only be set by the + * compose window. + * @param {nsIFile} file File to be uploaded. + * @param {string} [name] Name of the file after it has been uploaded. Defaults + * to the original filename of the uploaded file. + * @param {CloudFileUpload} relatedCloudFileUpload Information about an already + * uploaded file this upload is related to, e.g. renaming a repeatedly used + * cloud file or updating the content of a cloud file. + * @returns {CloudFileUpload} Information about the uploaded file. + */ + async uploadFile(window, file, name = file.leafName, relatedCloudFileUpload) { + let data = await File.createFromNsIFile(file); + + if ( + this.remainingFileSpace != -1 && + file.fileSize > this.remainingFileSpace + ) { + throw Components.Exception( + `Quota error: Can't upload file. Only ${this.remainingFileSpace}KB left of quota.`, + cloudFileAccounts.constants.uploadWouldExceedQuota + ); + } + + if ( + this.fileUploadSizeLimit != -1 && + file.fileSize > this.fileUploadSizeLimit + ) { + throw Components.Exception( + `Upload error: File size is ${file.fileSize}KB and exceeds the file size limit of ${this.fileUploadSizeLimit}KB`, + cloudFileAccounts.constants.uploadExceedsFileLimit + ); + } + + let upload = this.newUploadForFile(file, { name }); + let id = upload.id; + let relatedFileInfo; + if (relatedCloudFileUpload) { + relatedFileInfo = { + id: relatedCloudFileUpload.id, + name: relatedCloudFileUpload.name, + url: relatedCloudFileUpload.url, + templateInfo: relatedCloudFileUpload.templateInfo, + dataChanged: relatedCloudFileUpload.path != upload.path, + }; + } + + let results; + try { + results = await this.extension.emit( + "uploadFile", + this, + { id, name, data }, + window, + relatedFileInfo + ); + } catch (ex) { + this._uploads.delete(id); + if (ex.result == 0x80530014) { + // NS_ERROR_DOM_ABORT_ERR + throw Components.Exception( + "Upload cancelled.", + cloudFileAccounts.constants.uploadCancelled + ); + } else { + throw Components.Exception( + `Upload error: ${ex.message}`, + cloudFileAccounts.constants.uploadErr + ); + } + } + + if ( + results && + results.length > 0 && + results[0] && + (results[0].aborted || results[0].url || results[0].error) + ) { + if (results[0].error) { + this._uploads.delete(id); + if (typeof results[0].error == "boolean") { + throw Components.Exception( + "Upload error.", + cloudFileAccounts.constants.uploadErr + ); + } else { + throw Components.Exception( + results[0].error, + cloudFileAccounts.constants.uploadErrWithCustomMessage + ); + } + } + + if (results[0].aborted) { + this._uploads.delete(id); + throw Components.Exception( + "Upload cancelled.", + cloudFileAccounts.constants.uploadCancelled + ); + } + + if (results[0].templateInfo) { + upload.templateInfo = results[0].templateInfo; + + if (results[0].templateInfo.service_name) { + upload.serviceName = results[0].templateInfo.service_name; + } + if (results[0].templateInfo.service_icon) { + upload.serviceIcon = this.extension.baseURI.resolve( + results[0].templateInfo.service_icon + ); + } + if (results[0].templateInfo.service_url) { + upload.serviceUrl = results[0].templateInfo.service_url; + } + if (results[0].templateInfo.download_password_protected) { + upload.downloadPasswordProtected = + results[0].templateInfo.download_password_protected; + } + if (results[0].templateInfo.download_limit) { + upload.downloadLimit = results[0].templateInfo.download_limit; + } + if (results[0].templateInfo.download_expiry_date) { + // Event return value types are not checked by the WebExtension framework, + // manual verification is required. + if ( + results[0].templateInfo.download_expiry_date.timestamp && + Number.isInteger( + results[0].templateInfo.download_expiry_date.timestamp + ) + ) { + upload.downloadExpiryDate = + results[0].templateInfo.download_expiry_date; + } else { + console.warn( + "Invalid CloudFileTemplateInfo.download_expiry_date object, the timestamp property is required and it must be of type integer." + ); + } + } + } + + upload.url = results[0].url; + + return { ...upload }; + } + + this._uploads.delete(id); + throw Components.Exception( + `Upload error: Missing cloudFile.onFileUpload listener for ${this.extension.id} (or it is not returning url or aborted)`, + cloudFileAccounts.constants.uploadErr + ); + } + + /** + * Checks if the url of the given upload has been used already. + * + * @param {CloudFileUpload} cloudFileUpload + */ + isReusedUpload(cloudFileUpload) { + if (!cloudFileUpload) { + return false; + } + + // Find matching url in known uploads and check if it is immutable. + let isImmutableUrl = url => { + return [...this._uploads.values()].some(u => u.immutable && u.url == url); + }; + + // Check all open windows if the url is used elsewhere. + let isDuplicateUrl = url => { + let composeWindows = [...Services.wm.getEnumerator("msgcompose")]; + if (composeWindows.length == 0) { + return false; + } + let countsPerWindow = composeWindows.map(window => { + let bucket = window.document.getElementById("attachmentBucket"); + if (!bucket) { + return 0; + } + return [...bucket.childNodes].filter( + node => node.attachment.contentLocation == url + ).length; + }); + + return countsPerWindow.reduce((prev, curr) => prev + curr) > 1; + }; + + return ( + isImmutableUrl(cloudFileUpload.url) || isDuplicateUrl(cloudFileUpload.url) + ); + } + + /** + * Initiate a WebExtension cloudFile rename by triggering an onFileRename event. + * + * @param {object} window Window object of the window, where the upload has + * been initiated. Must be null, if the window is not supported by the + * WebExtension windows/tabs API. Currently, this should only be set by the + * compose window. + * @param {Integer} uploadId Id of the uploaded file. + * @param {string} newName The requested new name of the file. + * @returns {CloudFileUpload} Information about the renamed file. + */ + async renameFile(window, uploadId, newName) { + if (!this._uploads.has(uploadId)) { + throw Components.Exception( + "Rename error.", + cloudFileAccounts.constants.renameErr + ); + } + + let upload = this._uploads.get(uploadId); + let results; + try { + results = await this.extension.emit( + "renameFile", + this, + uploadId, + newName, + window + ); + } catch (ex) { + throw Components.Exception( + `Rename error: ${ex.message}`, + cloudFileAccounts.constants.renameErr + ); + } + + if (!results || results.length == 0) { + throw Components.Exception( + `Rename error: Missing cloudFile.onFileRename listener for ${this.extension.id}`, + cloudFileAccounts.constants.renameNotSupported + ); + } + + if (results[0]) { + if (results[0].error) { + if (typeof results[0].error == "boolean") { + throw Components.Exception( + "Rename error.", + cloudFileAccounts.constants.renameErr + ); + } else { + throw Components.Exception( + results[0].error, + cloudFileAccounts.constants.renameErrWithCustomMessage + ); + } + } + + if (results[0].url) { + upload.url = results[0].url; + } + } + + upload.name = newName; + return upload; + } + + urlForFile(uploadId) { + return this._uploads.get(uploadId).url; + } + + /** + * Cancel a WebExtension cloudFile upload by triggering an onFileUploadAbort + * event. + * + * @param {object} window Window object of the window, where the upload has + * been initiated. Must be null, if the window is not supported by the + * WebExtension windows/tabs API. Currently, this should only be set by the + * compose window. + * @param {nsIFile} file File to be uploaded. + */ + async cancelFileUpload(window, file) { + let path = file.path; + let uploadId = -1; + for (let upload of this._uploads.values()) { + if (!upload.url && upload.path == path) { + uploadId = upload.id; + break; + } + } + + if (uploadId == -1) { + console.error(`No upload in progress for file ${file.path}`); + return false; + } + + let result = await this.extension.emit( + "uploadAbort", + this, + uploadId, + window + ); + if (result && result.length > 0) { + return true; + } + + console.error( + `Missing cloudFile.onFileUploadAbort listener for ${this.extension.id}` + ); + return false; + } + + getPreviousUploads() { + return [...this._uploads.values()].map(u => { + return { ...u }; + }); + } + + /** + * Delete a WebExtension cloudFile upload by triggering an onFileDeleted event. + * + * @param {object} window Window object of the window, where the upload has + * been initiated. Must be null, if the window is not supported by the + * WebExtension windows/tabs API. Currently, this should only be set by the + * compose window. + * @param {Integer} uploadId Id of the uploaded file. + */ + async deleteFile(window, uploadId) { + if (!this.extension.emitter.has("deleteFile")) { + throw Components.Exception( + `Delete error: Missing cloudFile.onFileDeleted listener for ${this.extension.id}`, + cloudFileAccounts.constants.deleteErr + ); + } + + try { + if (this._uploads.has(uploadId)) { + let upload = this._uploads.get(uploadId); + if (!this.isReusedUpload(upload)) { + await this.extension.emit("deleteFile", this, uploadId, window); + this._uploads.delete(uploadId); + } + } + } catch (ex) { + throw Components.Exception( + `Delete error: ${ex.message}`, + cloudFileAccounts.constants.deleteErr + ); + } + } +} + +function convertCloudFileAccount(nativeAccount) { + return { + id: nativeAccount.accountKey, + name: nativeAccount.displayName, + configured: nativeAccount.configured, + uploadSizeLimit: nativeAccount.fileUploadSizeLimit, + spaceRemaining: nativeAccount.remainingFileSpace, + spaceUsed: nativeAccount.fileSpaceUsed, + managementUrl: nativeAccount.managementURL, + }; +} + +this.cloudFile = class extends ExtensionAPIPersistent { + get providerType() { + return `ext-${this.extension.id}`; + } + + onManifestEntry(entryName) { + if (entryName == "cloud_file") { + let { extension } = this; + cloudFileAccounts.registerProvider(this.providerType, { + type: this.providerType, + displayName: extension.manifest.cloud_file.name, + get iconURL() { + if (extension.manifest.icons) { + let { icon } = ExtensionParent.IconDetails.getPreferredIcon( + extension.manifest.icons, + extension, + 32 + ); + return extension.baseURI.resolve(icon); + } + return "chrome://messenger/content/extension.svg"; + }, + initAccount(accountKey) { + return new CloudFileAccount(accountKey, extension); + }, + }); + } + } + + onShutdown(isAppShutdown) { + if (isAppShutdown) { + return; + } + cloudFileAccounts.unregisterProvider(this.providerType); + } + + PERSISTENT_EVENTS = { + // For primed persistent events (deactivated background), the context is only + // available after fire.wakeup() has fulfilled (ensuring the convert() function + // has been called). + + onFileUpload({ context, fire }) { + const { extension } = this; + const { tabManager } = extension; + async function listener( + _event, + account, + { id, name, data }, + tab, + relatedFileInfo + ) { + if (fire.wakeup) { + await fire.wakeup(); + } + tab = tab ? tabManager.convert(tab) : null; + account = convertCloudFileAccount(account); + return fire.async(account, { id, name, data }, tab, relatedFileInfo); + } + extension.on("uploadFile", listener); + return { + unregister: () => { + extension.off("uploadFile", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + + onFileUploadAbort({ context, fire }) { + const { extension } = this; + const { tabManager } = extension; + async function listener(_event, account, id, tab) { + if (fire.wakeup) { + await fire.wakeup(); + } + tab = tab ? tabManager.convert(tab) : null; + account = convertCloudFileAccount(account); + return fire.async(account, id, tab); + } + extension.on("uploadAbort", listener); + return { + unregister: () => { + extension.off("uploadAbort", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + + onFileRename({ context, fire }) { + const { extension } = this; + const { tabManager } = extension; + async function listener(_event, account, id, newName, tab) { + if (fire.wakeup) { + await fire.wakeup(); + } + tab = tab ? tabManager.convert(tab) : null; + account = convertCloudFileAccount(account); + return fire.async(account, id, newName, tab); + } + extension.on("renameFile", listener); + return { + unregister: () => { + extension.off("renameFile", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + + onFileDeleted({ context, fire }) { + const { extension } = this; + const { tabManager } = extension; + async function listener(_event, account, id, tab) { + if (fire.wakeup) { + await fire.wakeup(); + } + tab = tab ? tabManager.convert(tab) : null; + account = convertCloudFileAccount(account); + return fire.async(account, id, tab); + } + extension.on("deleteFile", listener); + return { + unregister: () => { + extension.off("deleteFile", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + + onAccountAdded({ context, fire }) { + const self = this; + async function listener(_event, nativeAccount) { + if (nativeAccount.type != self.providerType) { + return null; + } + if (fire.wakeup) { + await fire.wakeup(); + } + return fire.async(convertCloudFileAccount(nativeAccount)); + } + cloudFileAccounts.on("accountAdded", listener); + return { + unregister: () => { + cloudFileAccounts.off("accountAdded", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + + onAccountDeleted({ context, fire }) { + const self = this; + async function listener(_event, key, type) { + if (self.providerType != type) { + return null; + } + if (fire.wakeup) { + await fire.wakeup(); + } + return fire.async(key); + } + cloudFileAccounts.on("accountDeleted", listener); + return { + unregister: () => { + cloudFileAccounts.off("accountDeleted", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + }; + + getAPI(context) { + let self = this; + + return { + cloudFile: { + onFileUpload: new EventManager({ + context, + module: "cloudFile", + event: "onFileUpload", + extensionApi: this, + }).api(), + + onFileUploadAbort: new EventManager({ + context, + module: "cloudFile", + event: "onFileUploadAbort", + extensionApi: this, + }).api(), + + onFileRename: new EventManager({ + context, + module: "cloudFile", + event: "onFileRename", + extensionApi: this, + }).api(), + + onFileDeleted: new EventManager({ + context, + module: "cloudFile", + event: "onFileDeleted", + extensionApi: this, + }).api(), + + onAccountAdded: new EventManager({ + context, + module: "cloudFile", + event: "onAccountAdded", + extensionApi: this, + }).api(), + + onAccountDeleted: new EventManager({ + context, + module: "cloudFile", + event: "onAccountDeleted", + extensionApi: this, + }).api(), + + async getAccount(accountId) { + let account = cloudFileAccounts.getAccount(accountId); + + if (!account || account.type != self.providerType) { + return undefined; + } + + return convertCloudFileAccount(account); + }, + + async getAllAccounts() { + return cloudFileAccounts + .getAccountsForType(self.providerType) + .map(convertCloudFileAccount); + }, + + async updateAccount(accountId, updateProperties) { + let account = cloudFileAccounts.getAccount(accountId); + + if (!account || account.type != self.providerType) { + return undefined; + } + if (updateProperties.configured !== null) { + account.configured = updateProperties.configured; + } + if (updateProperties.uploadSizeLimit !== null) { + account.quota.uploadSizeLimit = updateProperties.uploadSizeLimit; + } + if (updateProperties.spaceRemaining !== null) { + account.quota.spaceRemaining = updateProperties.spaceRemaining; + } + if (updateProperties.spaceUsed !== null) { + account.quota.spaceUsed = updateProperties.spaceUsed; + } + if (updateProperties.managementUrl !== null) { + account.managementURL = updateProperties.managementUrl; + } + + return convertCloudFileAccount(account); + }, + }, + }; + } +}; |