ext-downloads.js (8802B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 ChromeUtils.defineESModuleGetters(this, { 8 DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", 9 DownloadTracker: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", 10 }); 11 12 Cu.importGlobalProperties(["PathUtils"]); 13 14 var { ignoreEvent } = ExtensionCommon; 15 16 const REQUEST_DOWNLOAD_MESSAGE = "GeckoView:WebExtension:Download"; 17 18 const FORBIDDEN_HEADERS = [ 19 "ACCEPT-CHARSET", 20 "ACCEPT-ENCODING", 21 "ACCESS-CONTROL-REQUEST-HEADERS", 22 "ACCESS-CONTROL-REQUEST-METHOD", 23 "CONNECTION", 24 "CONTENT-LENGTH", 25 "COOKIE", 26 "COOKIE2", 27 "DATE", 28 "DNT", 29 "EXPECT", 30 "HOST", 31 "KEEP-ALIVE", 32 "ORIGIN", 33 "TE", 34 "TRAILER", 35 "TRANSFER-ENCODING", 36 "UPGRADE", 37 "VIA", 38 ]; 39 40 const FORBIDDEN_PREFIXES = /^PROXY-|^SEC-/i; 41 42 const State = { 43 IN_PROGRESS: "in_progress", 44 INTERRUPTED: "interrupted", 45 COMPLETE: "complete", 46 }; 47 48 const STATE_MAP = new Map([ 49 [0, State.IN_PROGRESS], 50 [1, State.INTERRUPTED], 51 [2, State.COMPLETE], 52 ]); 53 54 const INTERRUPT_REASON_MAP = new Map([ 55 [0, undefined], 56 [1, "FILE_FAILED"], 57 [2, "FILE_ACCESS_DENIED"], 58 [3, "FILE_NO_SPACE"], 59 [4, "FILE_NAME_TOO_LONG"], 60 [5, "FILE_TOO_LARGE"], 61 [6, "FILE_VIRUS_INFECTED"], 62 [7, "FILE_TRANSIENT_ERROR"], 63 [8, "FILE_BLOCKED"], 64 [9, "FILE_SECURITY_CHECK_FAILED"], 65 [10, "FILE_TOO_SHORT"], 66 [11, "NETWORK_FAILED"], 67 [12, "NETWORK_TIMEOUT"], 68 [13, "NETWORK_DISCONNECTED"], 69 [14, "NETWORK_SERVER_DOWN"], 70 [15, "NETWORK_INVALID_REQUEST"], 71 [16, "SERVER_FAILED"], 72 [17, "SERVER_NO_RANGE"], 73 [18, "SERVER_BAD_CONTENT"], 74 [19, "SERVER_UNAUTHORIZED"], 75 [20, "SERVER_CERT_PROBLEM"], 76 [21, "SERVER_FORBIDDEN"], 77 [22, "USER_CANCELED"], 78 [23, "USER_SHUTDOWN"], 79 [24, "CRASH"], 80 ]); 81 82 // TODO Bug 1247794: make id and extension info persistent 83 class DownloadItem { 84 /** 85 * Initializes an object that represents a download 86 * 87 * @param {object} downloadInfo - an object from Java when creating a download 88 * @param {object} options - an object passed in to download() function 89 * @param {Extension} extension - instance of an extension object 90 */ 91 constructor(downloadInfo, options, extension) { 92 this.id = downloadInfo.id; 93 this.url = options.url; 94 this.referrer = downloadInfo.referrer || ""; 95 this.filename = downloadInfo.filename || ""; 96 this.incognito = options.incognito; 97 this.danger = "safe"; // todo; not implemented in desktop either 98 this.mime = downloadInfo.mime || ""; 99 this.startTime = downloadInfo.startTime; 100 this.state = STATE_MAP.get(downloadInfo.state); 101 this.paused = downloadInfo.paused; 102 this.canResume = downloadInfo.canResume; 103 this.bytesReceived = downloadInfo.bytesReceived; 104 this.totalBytes = downloadInfo.totalBytes; 105 this.fileSize = downloadInfo.fileSize; 106 this.exists = downloadInfo.exists; 107 this.byExtensionId = extension?.id; 108 this.byExtensionName = extension?.name; 109 } 110 111 /** 112 * This function updates the download item it was called on. 113 * 114 * @param {object} data that arrived from the app (Java) 115 * @returns {object | null} an object of <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/onChanged#downloaddelta>downloadDelta type</a> 116 */ 117 update(data) { 118 const { downloadItemId } = data; 119 const delta = {}; 120 121 data.state = STATE_MAP.get(data.state); 122 data.error = INTERRUPT_REASON_MAP.get(data.error); 123 delete data.downloadItemId; 124 125 let changed = false; 126 for (const prop in data) { 127 const current = data[prop] ?? null; 128 const previous = this[prop] ?? null; 129 if (current !== previous) { 130 delta[prop] = { current, previous }; 131 this[prop] = current; 132 changed = true; 133 } 134 } 135 136 // Don't send empty onChange events 137 if (!changed) { 138 return null; 139 } 140 141 delta.id = downloadItemId; 142 143 return delta; 144 } 145 } 146 147 this.downloads = class extends ExtensionAPIPersistent { 148 PERSISTENT_EVENTS = { 149 onChanged({ fire }) { 150 const listener = (eventName, event) => { 151 const { delta, downloadItem } = event; 152 const { extension } = this; 153 if (extension.privateBrowsingAllowed || !downloadItem.incognito) { 154 fire.async(delta); 155 } 156 }; 157 DownloadTracker.on("download-changed", listener); 158 159 return { 160 unregister() { 161 DownloadTracker.off("download-changed", listener); 162 }, 163 convert(_fire) { 164 fire = _fire; 165 }, 166 }; 167 }, 168 }; 169 170 getAPI(context) { 171 const { extension } = context; 172 return { 173 downloads: { 174 download(options) { 175 // the validation checks should be kept in sync with the toolkit implementation 176 let { filename } = options; 177 if (filename != null) { 178 if (!filename.length) { 179 return Promise.reject({ message: "filename must not be empty" }); 180 } 181 182 if (PathUtils.isAbsolute(filename)) { 183 return Promise.reject({ 184 message: "filename must not be an absolute path", 185 }); 186 } 187 188 // % is not permitted but relatively common. 189 filename = filename.replaceAll("%", "_"); 190 191 const pathComponents = PathUtils.splitRelative(filename, { 192 allowEmpty: true, 193 allowCurrentDir: true, 194 allowParentDir: true, 195 }); 196 197 if (pathComponents.some(component => component == "..")) { 198 return Promise.reject({ 199 message: "filename must not contain back-references (..)", 200 }); 201 } 202 203 if ( 204 pathComponents.some((component, i) => { 205 const sanitized = DownloadPaths.sanitize(component, { 206 compressWhitespaces: false, 207 allowDirectoryNames: i < pathComponents.length - 1, 208 }); 209 return component != sanitized; 210 }) 211 ) { 212 return Promise.reject({ 213 message: "filename must not contain illegal characters", 214 }); 215 } 216 } 217 218 if (options.incognito && !context.privateBrowsingAllowed) { 219 return Promise.reject({ 220 message: "Private browsing access not allowed", 221 }); 222 } 223 224 if (options.cookieStoreId != null) { 225 // https://bugzilla.mozilla.org/show_bug.cgi?id=1721460 226 throw new ExtensionError("Not implemented"); 227 } 228 229 if (options.headers) { 230 for (const { name } of options.headers) { 231 if ( 232 FORBIDDEN_HEADERS.includes(name.toUpperCase()) || 233 name.match(FORBIDDEN_PREFIXES) 234 ) { 235 return Promise.reject({ 236 message: "Forbidden request header name", 237 }); 238 } 239 } 240 } 241 242 return EventDispatcher.instance 243 .sendRequestForResult({ 244 type: REQUEST_DOWNLOAD_MESSAGE, 245 options, 246 extensionId: extension.id, 247 }) 248 .then(value => { 249 const downloadItem = new DownloadItem(value, options, extension); 250 DownloadTracker.addDownloadItem(downloadItem); 251 return downloadItem.id; 252 }); 253 }, 254 255 removeFile() { 256 throw new ExtensionError("Not implemented"); 257 }, 258 259 search() { 260 throw new ExtensionError("Not implemented"); 261 }, 262 263 pause() { 264 throw new ExtensionError("Not implemented"); 265 }, 266 267 resume() { 268 throw new ExtensionError("Not implemented"); 269 }, 270 271 cancel() { 272 throw new ExtensionError("Not implemented"); 273 }, 274 275 showDefaultFolder() { 276 throw new ExtensionError("Not implemented"); 277 }, 278 279 erase() { 280 throw new ExtensionError("Not implemented"); 281 }, 282 283 open() { 284 throw new ExtensionError("Not implemented"); 285 }, 286 287 show() { 288 throw new ExtensionError("Not implemented"); 289 }, 290 291 getFileIcon() { 292 throw new ExtensionError("Not implemented"); 293 }, 294 295 onChanged: new EventManager({ 296 context, 297 module: "downloads", 298 event: "onChanged", 299 extensionApi: this, 300 }).api(), 301 302 onCreated: ignoreEvent(context, "downloads.onCreated"), 303 304 onErased: ignoreEvent(context, "downloads.onErased"), 305 306 onDeterminingFilename: ignoreEvent( 307 context, 308 "downloads.onDeterminingFilename" 309 ), 310 }, 311 }; 312 } 313 };