├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── background.js ├── cookie-manager-firefox.js ├── cookie-manager.css ├── cookie-manager.html ├── cookie-manager.js ├── datetime-local-polyfill.js ├── fake-api-snippet.js ├── icons ├── 16.png ├── 32.png ├── 48.png ├── 64.png ├── 96.png └── icon.svg ├── manifest.json ├── manifest_firefox.json ├── options.js ├── popup.css ├── popup.html ├── popup.js └── screenshots ├── cookie-manager-action-menu.png ├── cookie-manager-dark-theme.png ├── cookie-manager-light-theme.png └── open-cookie-manager-on-fennec.png /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | 3 | cookie-manager-chrome.zip 4 | cookie-manager-firefox.zip 5 | cookie-manager-firefox/ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SOURCES = $(wildcard cookie-manager.* cookie-manager-firefox.js popup.* icons/*.png) background.js datetime-local-polyfill.js options.js 2 | 3 | CHROME_ZIP = cookie-manager-chrome.zip 4 | FIREFOX_ZIP = cookie-manager-firefox.zip 5 | FIREFOX_DIR = cookie-manager-firefox 6 | 7 | .PHONY: all clean run-firefox 8 | 9 | all: $(CHROME_ZIP) $(FIREFOX_ZIP) 10 | 11 | $(CHROME_ZIP): $(SOURCES) manifest.json 12 | # TODO: Remove fake-api-snippet.js like Firefox. 13 | 7z u $@ $(SOURCES) manifest.json 14 | 15 | $(FIREFOX_DIR): $(SOURCES) manifest_firefox.json 16 | [ -d $(FIREFOX_DIR) ] || mkdir $(FIREFOX_DIR) 17 | rsync -Rt $(SOURCES) $(FIREFOX_DIR)/ 18 | sed 's/ 357 | 358 | 359 | 360 | 361 | 362 | 363 | -------------------------------------------------------------------------------- /cookie-manager.js: -------------------------------------------------------------------------------- 1 | /* globals chrome, alert */ 2 | /* globals Promise */ 3 | /* globals Set */ 4 | /* globals URLSearchParams */ 5 | /* globals browser */ 6 | /* globals console */ 7 | /* jshint browser: true */ 8 | /* jshint esversion: 6 */ // TODO: Make more use of ES6 for prettier code. 9 | 'use strict'; 10 | 11 | var ANY_COOKIE_STORE_ID = '(# of any cookie jar)'; 12 | var currentlyEditingCookieRow = null; 13 | var _visibleCookieRows = null; 14 | var gFirstPartyIsolationEnabled = false; // Whether privacy.firstparty.isolate is true. 15 | var gFirstPartyDomainSupported; // Whether the cookies API supports firstPartyDomain. 16 | var gPartitionKeySupported; // Whether the cookies API supports partitionKey. 17 | var showMoreResultsRow = document.getElementById('show-more-results-row'); 18 | 19 | document.getElementById('searchform').onsubmit = function(e) { 20 | e.preventDefault(); 21 | doSearch(); 22 | }; 23 | 24 | document.documentElement.addEventListener('selectstart', function(event) { 25 | document.documentElement.addEventListener('mouseup', onMouseUpAfterTextSelection); 26 | }); 27 | 28 | chrome.extension.isAllowedIncognitoAccess(function(isAllowedAccess) { 29 | if (!isAllowedAccess) { 30 | var introContainer = document.querySelector('.no-results td'); 31 | if (location.protocol === 'moz-extension:') { 32 | // Firefox. 33 | introContainer.insertAdjacentHTML( 34 | 'beforeend', 35 | '
To see cookies from Private Browsing, enable the extension to run in private windows.' 38 | ); 39 | return; 40 | } 41 | introContainer.insertAdjacentHTML( 42 | 'beforeend', 43 | '
To see incognito cookies, visit ' + 44 | ' and enable "Allow in incognito".'); 45 | var a = introContainer.querySelector('.ext-settings'); 46 | a.href = 'chrome://extensions/?id=' + chrome.runtime.id; 47 | a.textContent = a.href; 48 | a.onclick = function(e) { 49 | if (e.shiftKey) { 50 | chrome.windows.create({ 51 | url: a.href, 52 | }); 53 | } else { 54 | chrome.tabs.create({ 55 | url: a.href, 56 | }); 57 | } 58 | }; 59 | } 60 | }); 61 | // Note: some rows may represent multiple cookies, see 62 | // renderMultipleCookies and wrapMultipleCookieManagerApis. 63 | function getAllCookieRows() { 64 | if (document.querySelector('#result.no-results')) { 65 | return []; 66 | } 67 | return Array.from(document.getElementById('result').tBodies[0].rows); 68 | } 69 | function isRowSelected(row) { 70 | return row.cmApi.isHighlighted(); 71 | } 72 | 73 | document.getElementById('.session').onchange = function() { 74 | // Expiry is only meaningful for non-session cookies 75 | document.getElementById('.expiry.min').disabled = 76 | document.getElementById('.expiry.max').disabled = this.value == 'true'; 77 | }; 78 | document.getElementById('select-all').onclick = function() { 79 | getAllCookieRows().forEach(function(row) { 80 | row.cmApi.toggleHighlight(true); 81 | }); 82 | updateButtonView(); 83 | }; 84 | document.getElementById('select-none').onclick = function() { 85 | getAllCookieRows().forEach(function(row) { 86 | row.cmApi.toggleHighlight(false); 87 | }); 88 | updateButtonView(); 89 | }; 90 | document.getElementById('select-visible').onclick = function() { 91 | getVisibleCookieRows(true).forEach(function(row) { 92 | row.cmApi.toggleHighlight(true); 93 | }); 94 | updateButtonView(); 95 | }; 96 | document.getElementById('unselect-visible').onclick = function() { 97 | getVisibleCookieRows(true).forEach(function(row) { 98 | row.cmApi.toggleHighlight(false); 99 | }); 100 | updateButtonView(); 101 | }; 102 | document.getElementById('remove-selected').onclick = function() { 103 | modifyCookieRows(false); 104 | }; 105 | document.getElementById('restore-selected').onclick = function() { 106 | modifyCookieRows(true); 107 | }; 108 | document.getElementById('whitelist-selected').onclick = function() { 109 | whitelistCookieRows(true); 110 | }; 111 | document.getElementById('unwhitelist-selected').onclick = function() { 112 | whitelistCookieRows(false); 113 | }; 114 | 115 | function modifyCookieRows(shouldRestore) { 116 | var action = shouldRestore ? 'restore' : 'remove'; 117 | var rows = getAllCookieRows().filter(isRowSelected).filter(function(row) { 118 | if (shouldRestore) { 119 | return row.cmApi.isDeleted(); 120 | } 121 | return row.cmApi.getDeletionCount() < row.cmApi.getCookieCount(); 122 | }); 123 | var cookieCount = rows.reduce(function(c, row) { 124 | if (shouldRestore) { 125 | return c + row.cmApi.getDeletionCount(); 126 | } else { 127 | return c + row.cmApi.getCookieCount(); 128 | } 129 | }, 0); 130 | var messageId = shouldRestore ? 'BULK_RESTORE' : 'BULK_REMOVE'; 131 | var messageStr = 132 | 'Do you really want to ' + action + ' ' + ( 133 | rows.length === cookieCount ? 134 | rows.length + ' selected cookies?' : 135 | rows.length + ' selected rows with ' + cookieCount + ' cookies?' 136 | ); 137 | if (!confirmOnce(messageId, messageStr)) { 138 | return; 139 | } 140 | // Promises that always resolve. Upon success, a void value. Otherwise an error string. 141 | var promises = []; 142 | rows.forEach(function(row) { 143 | if (shouldRestore) { 144 | promises.push(row.cmApi.restoreCookie()); 145 | } else { 146 | promises.push(row.cmApi.deleteCookie()); 147 | } 148 | }); 149 | 150 | Promise.all(promises).then(function(errors) { 151 | updateButtonView(); 152 | errors = errors.filter(function(error) { return error; }); 153 | if (errors.length > 1) { 154 | // De-duplication of errors. 155 | errors = Array.from(new Set(errors)); 156 | } 157 | if (errors.length) { 158 | alert('Failed to ' + action + ' some cookies:\n' + errors.join('\n')); 159 | } 160 | }); 161 | } 162 | 163 | function whitelistCookieRows(shouldWhitelist) { 164 | var allCookieRows = getAllCookieRows(); 165 | allCookieRows.filter(isRowSelected).forEach(function(row) { 166 | row.cmApi.toggleWhitelist(shouldWhitelist); 167 | }); 168 | // The rows need to separately be updated, because there may be more than one cookie that 169 | // matches a (domain, name) pair. 170 | allCookieRows.forEach(function(row) { 171 | row.cmApi.renderListState(); 172 | }); 173 | updateButtonView(); 174 | } 175 | 176 | function setButtonCount(buttonId, count) { 177 | var button = document.getElementById(buttonId); 178 | button.disabled = count === 0; 179 | var countElem = button.querySelector('.count'); 180 | if (countElem) countElem.textContent = count; 181 | } 182 | function updateButtonView() { 183 | var allCookieRows = getAllCookieRows(); 184 | var allCookieCount = 0; 185 | var selectedCookieCount = 0; 186 | var deletedSelectionCount = 0; 187 | var whitelistedSelectionCount = 0; 188 | allCookieRows.forEach(function(row) { 189 | var count = row.cmApi.getCookieCount(); 190 | allCookieCount += count; 191 | if (isRowSelected(row)) { 192 | selectedCookieCount += count; 193 | deletedSelectionCount += row.cmApi.getDeletionCount(); 194 | whitelistedSelectionCount += row.cmApi.getWhitelistCount(); 195 | } 196 | }); 197 | 198 | updateVisibleButtonView(); 199 | 200 | setButtonCount('select-all', allCookieCount); 201 | setButtonCount('select-none', selectedCookieCount); 202 | setButtonCount('remove-selected', selectedCookieCount - deletedSelectionCount); 203 | setButtonCount('restore-selected', deletedSelectionCount); 204 | setButtonCount('whitelist-selected', selectedCookieCount - whitelistedSelectionCount); 205 | setButtonCount('unwhitelist-selected', whitelistedSelectionCount); 206 | } 207 | var _updateVisibleButtonViewRAFHandle; 208 | function updateVisibleButtonView() { 209 | if (_visibleCookieRows) { 210 | // List of visible cookies is already available without recalc. Use the data immediately. 211 | if (_updateVisibleButtonViewRAFHandle) { 212 | window.cancelAnimationFrame(_updateVisibleButtonViewRAFHandle); 213 | } 214 | updateVisibleButtonViewInternal(_visibleCookieRows); 215 | } else if (!_updateVisibleButtonViewRAFHandle) { 216 | // The list of visible cookies is not available yet. Wait for the next frame. 217 | _updateVisibleButtonViewRAFHandle = window.requestAnimationFrame(function() { 218 | updateVisibleButtonViewInternal(getVisibleCookieRows()); 219 | }); 220 | } 221 | 222 | function updateVisibleButtonViewInternal(visibleCookieRows) { 223 | _updateVisibleButtonViewRAFHandle = 0; 224 | 225 | _throttledVisIsThrottled = false; // can be set to true in updateVisibleButtonViewThrottled. 226 | 227 | var selectedVisibleCookieRows = visibleCookieRows.filter(isRowSelected); 228 | 229 | setButtonCount('select-visible', visibleCookieRows.length); 230 | setButtonCount('unselect-visible', selectedVisibleCookieRows.length); 231 | } 232 | } 233 | var _throttledVisTimer; 234 | var _throttledVisIsThrottled = false; 235 | function updateVisibleButtonViewThrottled() { 236 | // Throttle for use in scroll events, etc. 237 | if (_throttledVisTimer) { 238 | _throttledVisIsThrottled = true; // will be set to false in updateVisibleButtonView. 239 | } else { 240 | updateVisibleButtonView(); 241 | _throttledVisTimer = setTimeout(function() { 242 | _throttledVisTimer = null; 243 | if (_throttledVisIsThrottled) { 244 | updateVisibleButtonView(); 245 | } 246 | }, 500); 247 | // ^ Frequent updates is not that important, since the rows' heights are constrained, 248 | // so the number of visible items is more or less the same. 249 | } 250 | } 251 | 252 | function getVisibleCookieRows(forceRecalc = false) { 253 | if (!_visibleCookieRows || forceRecalc) { 254 | // Calculating the visible rows is relatively expensive. 255 | // To avoid layout trashing, the list of visible rows is cached. 256 | _visibleCookieRows = getVisibleCookieRowsWithRecalc_(); 257 | Promise.resolve().then(function() { 258 | _visibleCookieRows = null; 259 | }); 260 | } 261 | return _visibleCookieRows; 262 | } 263 | // Do not use getVisibleCookieRowsWithRecalc_. Use getVisibleCookieRows(true) instead. 264 | function getVisibleCookieRowsWithRecalc_() { 265 | var tableRect = document.getElementById('result').tBodies[0].getBoundingClientRect(); 266 | var bottomOffset = document.getElementById('footer-controls').getBoundingClientRect().top; 267 | var minimumVisibleRowHeight = document.querySelector('#result thead > tr').offsetHeight || 1; 268 | 269 | var visibleCenter = tableRect.left + tableRect.width / 2; 270 | var visibleTop = Math.max(0, tableRect.top) + minimumVisibleRowHeight; 271 | var visibleBottom = Math.min(bottomOffset, tableRect.bottom) - minimumVisibleRowHeight; 272 | if (visibleTop >= visibleBottom) { 273 | // That must be a very narrow screen, for the result table to not fit... 274 | return []; 275 | } 276 | 277 | function getRowAt(x, y) { 278 | var cell = document.elementsFromPoint(x, y).find(e => e.tagName === 'TD'); 279 | return cell && cell.parentNode; 280 | } 281 | var topRow = getRowAt(visibleCenter, visibleTop); 282 | var bottomRow = getRowAt(visibleCenter, visibleBottom); 283 | if (!topRow) { 284 | console.info('getVisibleCookieRows did not find a top row'); 285 | return []; 286 | } 287 | if (!bottomRow) { 288 | console.info('getVisibleCookieRows did not find a bottom row'); 289 | return []; 290 | } 291 | if (topRow.parentNode !== bottomRow.parentNode) { 292 | console.error('getVisibleCookieRows found rows from different parents!'); 293 | return []; 294 | } 295 | if (topRow.rowIndex > bottomRow.rowIndex) { 296 | console.error('getVisibleCookieRows found the top row after the bottom row!'); 297 | return []; 298 | } 299 | var visibleCookieRows = []; 300 | for (var row = topRow; row && row !== bottomRow; row = row.nextElementSibling) { 301 | visibleCookieRows.push(row); 302 | } 303 | if (topRow !== bottomRow) { 304 | visibleCookieRows.push(bottomRow); 305 | } 306 | return visibleCookieRows; 307 | } 308 | 309 | function setEditSaveEnabled(canSave) { 310 | var editSaveButton = document.getElementById('edit-save'); 311 | editSaveButton.disabled = !canSave; 312 | editSaveButton.textContent = canSave ? 'Save' : 'Saved'; 313 | 314 | // Reset validation messages so that the validation can happen again upon submission. 315 | document.getElementById('editform.name').setCustomValidity(''); 316 | document.getElementById('editform.value').setCustomValidity(''); 317 | document.getElementById('editform.domain').setCustomValidity(''); 318 | document.getElementById('editform.path').setCustomValidity(''); 319 | document.getElementById('editform.expiry').setCustomValidity(''); 320 | } 321 | 322 | updateButtonView(); 323 | updateCookieStoreIds().then(function() { 324 | var params = new URLSearchParams(location.search); 325 | var inputs = document.getElementById('searchform') 326 | .querySelectorAll('select[id^="."],input[id^="."]'); 327 | var any = false; 328 | Array.from(inputs).forEach(function(input) { 329 | var value = params.get(input.id.slice(1)); 330 | if (value) { 331 | input.value = value; 332 | any = true; 333 | } 334 | }); 335 | if (any) { 336 | doSearch(); 337 | } 338 | }); 339 | window.addEventListener('focus', updateCookieStoreIds); 340 | 341 | document.getElementById('other-action').onchange = function() { 342 | var option = this.options[this.selectedIndex]; 343 | // We always select the first option again. 344 | this.selectedIndex = 0; 345 | 346 | var FILLED_DOT = '\u25C9'; 347 | var HOLLOW_DOT = '\u25CC'; 348 | if (option.textContent.startsWith(FILLED_DOT)) { 349 | // Radio choice not changed - nothing to do. 350 | return; 351 | } 352 | if (option.textContent.startsWith(HOLLOW_DOT)) { 353 | // If the current selection is a hollow dot, then we have 354 | // changed the selection. 355 | option.textContent = option.textContent.replace(HOLLOW_DOT, FILLED_DOT); 356 | Array.from(option.parentNode.children).filter(function(opt) { 357 | return opt !== option; 358 | }).forEach(function(opt) { 359 | opt.textContent = opt.textContent.replace(FILLED_DOT, HOLLOW_DOT); 360 | }); 361 | } 362 | 363 | // Will throw if you add a new option in the HTML but forget to implement it below. 364 | OtherActionsController[option.value](); 365 | }; 366 | 367 | var OtherActionsController = { 368 | new_cookie() { 369 | document.getElementById('show-new-form').click(); 370 | }, 371 | 372 | bulk_export() { 373 | var selectionCount = getAllCookieRows().filter(isRowSelected).reduce(function(c, row) { 374 | return c + row.cmApi.getCookieCount(); 375 | }, 0); 376 | if (!selectionCount) { 377 | alert('You have not selected any cookies to export.\n' + 378 | 'Please search for cookies and select some cookies before trying to export them.'); 379 | return; 380 | } 381 | document.getElementById('export-cookie-count').textContent = 382 | selectionCount + (selectionCount === 1 ? ' cookie' : ' cookies'); 383 | document.body.classList.add('exporting-cookies'); 384 | }, 385 | 386 | bulk_import() { 387 | document.body.classList.add('importing-cookies'); 388 | }, 389 | 390 | workflow_remove() { 391 | document.getElementById('remove-selected').hidden = false; 392 | document.getElementById('restore-selected').hidden = false; 393 | document.getElementById('whitelist-selected').hidden = true; 394 | document.getElementById('unwhitelist-selected').hidden = true; 395 | }, 396 | 397 | workflow_whitelist() { 398 | document.getElementById('remove-selected').hidden = true; 399 | document.getElementById('restore-selected').hidden = true; 400 | document.getElementById('whitelist-selected').hidden = false; 401 | document.getElementById('unwhitelist-selected').hidden = false; 402 | }, 403 | 404 | bulk_select_all() { 405 | document.getElementById('select-all').hidden = false; 406 | document.getElementById('select-none').hidden = false; 407 | document.getElementById('select-visible').hidden = true; 408 | document.getElementById('unselect-visible').hidden = true; 409 | window.removeEventListener('scroll', updateVisibleButtonViewThrottled); 410 | window.removeEventListener('resize', updateVisibleButtonViewThrottled); 411 | }, 412 | 413 | bulk_select_some() { 414 | document.getElementById('select-all').hidden = true; 415 | document.getElementById('select-none').hidden = true; 416 | document.getElementById('select-visible').hidden = false; 417 | document.getElementById('unselect-visible').hidden = false; 418 | window.addEventListener('scroll', updateVisibleButtonViewThrottled); 419 | window.addEventListener('resize', updateVisibleButtonViewThrottled); 420 | }, 421 | }; 422 | 423 | var WhitelistManager = { 424 | _cached: new Map(), 425 | _storageLastChanged: 0, 426 | _initialized: false, 427 | _initPromise: null, 428 | _throttledSync: null, 429 | _dirty: false, 430 | _locked: true, 431 | 432 | _getDomain(cookie) { 433 | if ('domain' in cookie) { 434 | // Assuming that domain is already normalized to lower case. 435 | var domain = cookie.domain; 436 | return domain.startsWith('.') ? domain.slice(1) : domain; 437 | } 438 | return new URL(cookie.url).hostname; 439 | }, 440 | 441 | _serialize() { 442 | var serializable = Object.create(null); 443 | WhitelistManager._cached.forEach(function(list, domain) { 444 | serializable[domain] = Array.from(list); 445 | }); 446 | return JSON.stringify(serializable); 447 | }, 448 | 449 | _load(serialized) { 450 | var serializable = JSON.parse(serialized); 451 | WhitelistManager._cached.clear(); 452 | Object.keys(serializable).forEach(function(domain) { 453 | WhitelistManager._cached.set(domain, new Set(serializable[domain])); 454 | }); 455 | }, 456 | 457 | _sync() { 458 | WhitelistManager._dirty = true; 459 | clearTimeout(WhitelistManager._throttledSync); 460 | WhitelistManager._throttledSync = setTimeout(WhitelistManager._syncImmediate, 1000); 461 | }, 462 | 463 | _syncImmediate() { 464 | if (!WhitelistManager._dirty) return; 465 | 466 | var serialized = WhitelistManager._serialize(); 467 | WhitelistManager._dirty = false; 468 | WhitelistManager._storageLastChanged = Date.now(); 469 | chrome.storage.local.set({ 470 | lastChanged: WhitelistManager._storageLastChanged, 471 | cookieWhitelist: serialized, 472 | }); 473 | }, 474 | 475 | // Initialize the storage. The promise resolves with whether _cached was changed. 476 | initialize(force = false) { 477 | if (!WhitelistManager._initPromise || force && WhitelistManager._initialized) { 478 | var shouldSkipDataLookup = WhitelistManager._storageLastChanged !== 0; 479 | WhitelistManager._initialized = false; 480 | WhitelistManager._initPromise = new Promise(function doLookup(resolve) { 481 | // If we have already looked up "cookieWhitelist" before, avoid unnecessarily 482 | // reading the data again by first looking up "lastChanged" to determine whether 483 | // there is any updated data to look up. 484 | if (shouldSkipDataLookup) { 485 | shouldSkipDataLookup = false; 486 | chrome.storage.local.get({lastChanged: 0}, function(items) { 487 | var lastChanged = items && items.lastChanged || 0; 488 | if (lastChanged) { 489 | doLookup(resolve); 490 | } else { 491 | resolve(false); 492 | } 493 | }); 494 | return; 495 | } 496 | 497 | chrome.storage.local.get({ 498 | lastChanged: 0, 499 | cookieWhitelist: '', 500 | }, function(items) { 501 | var serialized = items && items.cookieWhitelist; 502 | var lastChanged = items && items.lastChanged || 0; 503 | var didChange = lastChanged !== WhitelistManager._storageLastChanged; 504 | WhitelistManager._storageLastChanged = lastChanged; 505 | 506 | try { 507 | if (serialized && didChange) { 508 | WhitelistManager._load(serialized); 509 | } 510 | } finally { 511 | WhitelistManager._initialized = true; 512 | resolve(didChange); 513 | } 514 | }); 515 | }); 516 | } 517 | return WhitelistManager._initPromise; 518 | }, 519 | 520 | addToList(cookie) { 521 | var domain = WhitelistManager._getDomain(cookie); 522 | var list = WhitelistManager._cached.get(domain); 523 | if (!list) { 524 | list = new Set(); 525 | WhitelistManager._cached.set(domain, list); 526 | } 527 | if (list.has(cookie.name)) return; 528 | list.add(cookie.name); 529 | WhitelistManager._sync(); 530 | }, 531 | 532 | removeFromList(cookie) { 533 | var domain = WhitelistManager._getDomain(cookie); 534 | var list = WhitelistManager._cached.get(domain); 535 | if (list) { 536 | if (!list.delete(cookie.name)) return; 537 | if (list.size === 0) { 538 | WhitelistManager._cached.delete(domain); 539 | } 540 | WhitelistManager._sync(); 541 | } 542 | }, 543 | 544 | isWhitelisted(cookie) { 545 | var domain = WhitelistManager._getDomain(cookie); 546 | var list = WhitelistManager._cached.get(domain); 547 | if (list) { 548 | return list.has(cookie.name); 549 | } 550 | return false; 551 | }, 552 | 553 | isModificationAllowed(cookie) { 554 | return !WhitelistManager._locked || !WhitelistManager.isWhitelisted(cookie); 555 | }, 556 | 557 | requestModification() { 558 | var unlockPrompt = document.getElementById('whitelist-unlock-prompt'); 559 | if (unlockPrompt.hidden) { 560 | unlockPrompt.hidden = false; 561 | document.getElementById('whitelist-unlock-yes').disabled = false; 562 | document.getElementById('whitelist-unlock-confirm').disabled = true; 563 | document.getElementById('whitelist-unlock-no').focus(); 564 | } 565 | }, 566 | 567 | setLocked(locked = true) { 568 | WhitelistManager._locked = locked; 569 | document.getElementById('whitelist-unlock-prompt').hidden = true; 570 | document.getElementById('whitelist-lock-again').hidden = locked; 571 | // To discourage the use of unlocked whitelisted cookies, disallow creation 572 | // of new cookies. This also results in more space in the default button layout. 573 | document.getElementById('show-new-form').hidden = !locked; 574 | } 575 | }; 576 | 577 | // Synchronize the storage upon changing tabs, in case we use multiple storage managers and 578 | // modify the map from different pages. In theory this can have race conditions, where the whitelist 579 | // is modified while the tab is inactive. In practice this should not happen because we only modify 580 | // the whitelist in response to a user action. 581 | window.addEventListener('focus', function() { 582 | WhitelistManager.initialize(true).then(function(didChange) { 583 | if (didChange) { 584 | updateButtonView(); 585 | getAllCookieRows().forEach(function(row) { 586 | row.cmApi.renderListState(); 587 | }); 588 | } 589 | }); 590 | }); 591 | window.addEventListener('blur', function() { 592 | WhitelistManager._syncImmediate(); 593 | }); 594 | 595 | 596 | document.getElementById('whitelist-unlock-yes').onclick = function() { 597 | document.getElementById('whitelist-unlock-yes').disabled = true; 598 | document.getElementById('whitelist-unlock-confirm').disabled = false; 599 | }; 600 | document.getElementById('whitelist-unlock-confirm').onclick = function() { 601 | WhitelistManager.setLocked(false); 602 | }; 603 | document.getElementById('whitelist-unlock-no').onclick = function() { 604 | WhitelistManager.setLocked(true); 605 | }; 606 | document.getElementById('whitelist-lock-again').onclick = function() { 607 | WhitelistManager.setLocked(true); 608 | }; 609 | 610 | 611 | // Add/edit cookie functionality 612 | document.getElementById('show-new-form').onclick = function() { 613 | document.getElementById('editform').reset(); 614 | setEditSaveEnabled(true); 615 | currentlyEditingCookieRow = null; 616 | document.body.classList.add('editing-cookie'); 617 | }; 618 | document.getElementById('editform').onsubmit = function(event) { 619 | event.preventDefault(); 620 | var cookie = {}; 621 | cookie.url = urlWithoutPort(document.getElementById('editform.url').value); 622 | cookie.name = document.getElementById('editform.name').value; 623 | cookie.value = document.getElementById('editform.value').value; 624 | 625 | if (reportValidity('editform.name', cookieValidators.name(cookie.name)) || 626 | reportValidity('editform.value', cookieValidators.value(cookie.value))) { 627 | return; 628 | } 629 | 630 | var parsedUrl = new URL(cookie.url); 631 | if (document.getElementById('editform.hostOnlyFalseDefault').checked) { 632 | cookie.domain = parsedUrl.hostname; 633 | } else if (document.getElementById('editform.hostOnlyFalseCustom').checked) { 634 | cookie.domain = document.getElementById('editform.domain').value.trim(); 635 | if (reportValidity('editform.domain', cookieValidators.domain(cookie.domain, parsedUrl.hostname))) { 636 | return; 637 | } 638 | } 639 | // Else (hostOnlyTrue): the cookie becomes a host-only cookie. 640 | 641 | if (document.getElementById('editform.pathIsSlash').checked) { 642 | cookie.path = '/'; 643 | } else if (document.getElementById('editform.pathIsCustom').checked) { 644 | cookie.path = document.getElementById('editform.path').value; 645 | if (reportValidity('editform.path', cookieValidators.path(cookie.path))) { 646 | return; 647 | } 648 | } 649 | // Else (pathIsDefault): Defaults to the path portion of the url parameter. 650 | 651 | cookie.secure = document.getElementById('editform.secure').checked; 652 | cookie.httpOnly = document.getElementById('editform.httpOnly').checked; 653 | if (!document.getElementById('editform.sameSiteBox').hidden) { 654 | cookie.sameSite = document.getElementById('editform.sameSite').value; 655 | if ( 656 | cookie.sameSite === 'unspecified' && 657 | // Note: chrome.cookies.SameSiteStatus is non-void because 658 | // otherwise the sameSiteBox would have been hidden. 659 | !chrome.cookies.SameSiteStatus.UNSPECIFIED && 660 | chrome.cookies.SameSiteStatus.NO_RESTRICTION 661 | ) { 662 | // Firefox doesn't support SameSite=unspecified in the cookies API. 663 | // https://bugzilla.mozilla.org/show_bug.cgi?id=1550032 664 | // It defaults to no_restriction. 665 | cookie.sameSite = "no_restriction"; 666 | } 667 | } 668 | if (document.getElementById('editform.sessionFalse').checked) { 669 | cookie.expirationDate = dateToExpiryCompatibleTimestamp(document.getElementById('editform.expiry')); 670 | if (reportValidity('editform.expiry', cookieValidators.expirationDate(cookie.expirationDate))) { 671 | return; 672 | } 673 | } else if (document.getElementById('editform.sessionFalseExpired').checked) { 674 | cookie.expirationDate = 0; 675 | } 676 | cookie.storeId = document.getElementById('editform.storeId').value; 677 | 678 | // The edit form is only visible after doSearch() is called, which calls 679 | // checkFirstPartyDomainSupport() via checkFirstPartyIsolationStatus() that 680 | // initializes gFirstPartyDomainSupported. 681 | if (gFirstPartyDomainSupported) { 682 | cookie.firstPartyDomain = document.getElementById('editform.firstPartyDomain').value; 683 | } 684 | if (checkPartitionKeySupport()) { 685 | cookie.partitionKey = partitionKeyFromString(document.getElementById('editform.partitionKey').value); 686 | } 687 | 688 | // Format cookie to the cookies.Cookie type. 689 | var newCookie = Object.assign({}, cookie); 690 | newCookie.hostOnly = !('domain' in newCookie); 691 | if (newCookie.hostOnly) { 692 | newCookie.domain = parsedUrl.hostname; 693 | } else if (!newCookie.domain.startsWith('.')) { 694 | newCookie.domain = '.' + newCookie.domain; 695 | } 696 | if (!('path' in newCookie)) { 697 | newCookie.path = '/'; 698 | } 699 | if (!('expirationDate' in newCookie)) { 700 | newCookie.session = true; 701 | } 702 | 703 | if (!WhitelistManager.isModificationAllowed(cookie)) { 704 | WhitelistManager.requestModification(); 705 | return; 706 | } 707 | 708 | var rowToEdit = currentlyEditingCookieRow; 709 | if (rowToEdit && !isSameCookieKey(newCookie, rowToEdit.cmApi.rawCookie)) { 710 | rowToEdit.cmApi.deleteCookie().then(function(error) { 711 | if (rowToEdit !== currentlyEditingCookieRow) { 712 | console.warn('Closed edit form while deleting the old cookie.'); 713 | } 714 | if (error) { 715 | alert('Failed to replace cookie:\n' + error); 716 | return; 717 | } 718 | if (!newCookie.session && cookie.expirationDate < Date.now() / 1000) { 719 | addOrReplaceCookie(true, true); 720 | return; 721 | } 722 | addOrReplaceCookie(false, true); 723 | }); 724 | } else { 725 | addOrReplaceCookie(); 726 | } 727 | 728 | function addOrReplaceCookie(skipSetCookie, restoreOnError) { 729 | if (skipSetCookie) { 730 | onCookieReplaced(); 731 | return; 732 | } 733 | chrome.cookies.set(cookie, function() { 734 | if (rowToEdit !== currentlyEditingCookieRow) { 735 | console.warn('Closed edit form while saving the cookie.'); 736 | } 737 | 738 | var errorMessage = chrome.runtime.lastError && chrome.runtime.lastError.message; 739 | if (errorMessage) { 740 | // Run the error handler asynchronously, so that the presence of 741 | // runtime.lastError does not interfere with the restoreCookie 742 | // logic (the alert() call can block the callback, and then 743 | // restoreCookie may mis-interpret the error). 744 | Promise.resolve().then(function() { 745 | if (restoreOnError) { 746 | rowToEdit.cmApi.restoreCookie(); 747 | } 748 | alert('Failed to save cookie because of:\n' + errorMessage); 749 | }); 750 | return; 751 | } 752 | if (!rowToEdit) { 753 | setEditSaveEnabled(false); 754 | return; 755 | } 756 | onCookieReplaced(); 757 | }); 758 | 759 | function onCookieReplaced() { 760 | // Replace the cookie row. 761 | var row = document.createElement('tr'); 762 | row.classList.add('cookie-edited'); 763 | renderCookie(row, createBaseCookieManagerAPI(newCookie)); 764 | var restoreButton = document.createElement('button'); 765 | restoreButton.className = 'restore-single-cookie'; 766 | restoreButton.textContent = 'Restore'; 767 | restoreButton.onclick = function(event) { 768 | event.stopPropagation(); 769 | if (!confirmOnce('UNDO_EDIT', 'Do you want to undo the edit and restore the previous cookie?')) { 770 | return; 771 | } 772 | restoreButton.disabled = true; 773 | 774 | if (isSameCookieKey(newCookie, rowToEdit.cmApi.rawCookie)) { 775 | rowToEdit.cmApi.restoreCookie().then(onCookieRestored); 776 | } else { 777 | row.cmApi.deleteCookie().then(function(error) { 778 | if (error) { 779 | restoreButton.disabled = false; 780 | alert('Failed to delete new cookie because of:\n' + error); 781 | return; 782 | } 783 | rowToEdit.cmApi.restoreCookie().then(onCookieRestored); 784 | }); 785 | } 786 | function onCookieRestored(error) { 787 | if (error) { 788 | restoreButton.disabled = false; 789 | alert('Failed to restore cookie because of:\n' + error); 790 | return; 791 | } 792 | rowToEdit.cmApi.toggleHighlight(row.cmApi.isHighlighted()); 793 | row.replaceWith(rowToEdit); 794 | rowToEdit.focus(); 795 | // updateButtonView() not needed because we have copied the 'highlighted' state. 796 | } 797 | }; 798 | row.querySelector('.action-buttons').appendChild(restoreButton); 799 | rowToEdit.replaceWith(row); 800 | if (rowToEdit === currentlyEditingCookieRow) { 801 | currentlyEditingCookieRow = null; 802 | document.body.classList.remove('editing-cookie'); 803 | row.querySelector('button.edit-single-cookie').focus(); 804 | // updateButtonView() not needed because we have copied the 'highlighted' state. 805 | } 806 | } 807 | } 808 | 809 | function reportValidity(elementId, validationMessage) { 810 | if (!validationMessage) { 811 | return false; // Should not abort. 812 | } 813 | document.getElementById(elementId).setCustomValidity(validationMessage); 814 | document.getElementById('editform').reportValidity(); 815 | return true; // Validation error; Abort. 816 | } 817 | }; 818 | 819 | // Only show sameSite controls if supported by the API. 820 | document.getElementById('.sameSite').hidden = 821 | document.getElementById('editform.sameSiteBox').hidden = !chrome.cookies.SameSiteStatus; 822 | 823 | document.getElementById('editform').oninput = 824 | document.getElementById('editform').onchange = function() { 825 | setEditSaveEnabled(true); 826 | }; 827 | document.getElementById('editform').onkeydown = function(event) { 828 | if (event.charCode) { 829 | setEditSaveEnabled(true); 830 | } 831 | }; 832 | 833 | function renderEditCookieForm(cookie, rowToEdit) { 834 | document.getElementById('editform.url').value = cookie.url; 835 | document.getElementById('editform.name').value = cookie.name; 836 | document.getElementById('editform.value').value = cookie.value; 837 | 838 | var parsedUrl = new URL(cookie.url); 839 | 840 | if (cookie.hostOnly) { 841 | document.getElementById('editform.hostOnlyTrue').checked = true; 842 | } else if (cookie.domain === '.' + parsedUrl.hostname) { 843 | document.getElementById('editform.hostOnlyFalseDefault').checked = true; 844 | } else { 845 | document.getElementById('editform.hostOnlyFalseCustom').checked = true; 846 | } 847 | document.getElementById('editform.domain').value = cookie.domain; 848 | 849 | if (cookie.path === '/') { 850 | document.getElementById('editform.pathIsSlash').checked = true; 851 | } else if (cookie.path === parsedUrl.pathname) { 852 | document.getElementById('editform.pathIsDefault').checked = true; 853 | } else { 854 | document.getElementById('editform.pathIsCustom').checked = true; 855 | } 856 | document.getElementById('editform.path').value = cookie.path; 857 | if (cookie.session) { 858 | document.getElementById('editform.sessionTrue').checked = true; 859 | } else { 860 | document.getElementById('editform.sessionFalse').checked = true; 861 | setExpiryTimestamp(document.getElementById('editform.expiry'), cookie.expirationDate); 862 | if (cookie.expirationDate < Date.now() / 1000) { 863 | document.getElementById('editform.sessionFalseExpired').checked = true; 864 | } 865 | } 866 | 867 | document.getElementById('editform.secure').checked = cookie.secure; 868 | document.getElementById('editform.httpOnly').checked = cookie.httpOnly; 869 | if (cookie.sameSite) { 870 | document.getElementById('editform.sameSite').value = cookie.sameSite; 871 | } 872 | document.getElementById('editform.storeId').value = cookie.storeId; 873 | // The edit form is only visible after doSearch() is called, which calls 874 | // checkFirstPartyDomainSupport() via checkFirstPartyIsolationStatus() that 875 | // initializes gFirstPartyDomainSupported. 876 | if (gFirstPartyDomainSupported) { 877 | document.getElementById('editform.firstPartyDomain').value = cookie.firstPartyDomain; 878 | } else { 879 | document.getElementById('editform.firstPartyDomain.container').hidden = true; 880 | } 881 | if (checkPartitionKeySupport()) { 882 | document.getElementById('editform.partitionKey').value = cookieToPartitionKeyString(cookie); 883 | } else { 884 | document.getElementById('editform.partitionKey.container').hidden = true; 885 | } 886 | 887 | setEditSaveEnabled(true); 888 | currentlyEditingCookieRow = rowToEdit; 889 | document.body.classList.add('editing-cookie'); 890 | 891 | } 892 | document.getElementById('edit-cancel').onclick = function() { 893 | currentlyEditingCookieRow = null; 894 | document.body.classList.remove('editing-cookie'); 895 | }; 896 | 897 | Array.from(document.querySelectorAll('#editform label[for]')).forEach(function(radioOtherBox) { 898 | var radioInput = radioOtherBox.querySelector('input[type=radio]'); 899 | var otherInput = radioOtherBox.querySelector('input:not([type=radio])'); 900 | radioInput.onchange = function() { 901 | if (radioInput.checked) { 902 | otherInput.focus(); 903 | } 904 | }; 905 | otherInput.onfocus = function() { 906 | if (radioInput.checked) return; 907 | radioInput.checked = true; 908 | setEditSaveEnabled(true); 909 | }; 910 | }); 911 | 912 | 913 | // Import / export functionality. 914 | var CookieExporter = { 915 | KEY_TYPES: { 916 | name: ['string'], 917 | value: ['string'], 918 | domain: ['string'], 919 | hostOnly: ['boolean'], 920 | path: ['string'], 921 | secure: ['boolean'], 922 | httpOnly: ['boolean'], 923 | // Optional if expirationDate is set: 924 | session: ['boolean', 'undefined'], 925 | // Optional if session is true: 926 | expirationDate: ['number', 'undefined'], 927 | storeId: ['string'], 928 | // Chrome 51+, Firefox 63+: 929 | sameSite: ['string', 'undefined'], 930 | // Firefox 59+: 931 | firstPartyDomain: ['string', 'undefined'], 932 | // Firefox 94+, Chrome 119+: 933 | partitionKey: ['object', 'undefined'], 934 | }, 935 | get KEYS() { 936 | var KEYS = Object.keys(CookieExporter.KEY_TYPES); 937 | Object.defineProperty(CookieExporter, 'KEYS', { 938 | configurable: true, 939 | enumerable: true, 940 | value: KEYS, 941 | }); 942 | return KEYS; 943 | }, 944 | // returns true if this exporter might recognize the format. 945 | probe(text) { 946 | // Does it look like a JSON-serialized format? 947 | return /^\s*\{[\s\S]*\}\s*$/.test(text); 948 | }, 949 | // cookies is a list of chrome.cookie.Cookie objects. 950 | serialize(cookies) { 951 | // serialize() is called internally, so it should never fail. Still, perform some validation 952 | // as a smoke test to help in debugging, if for some reason we ever export invalid data. 953 | // To recover, we can apply a patch in deserialize to fixup the data if needed. 954 | cookies.forEach(function(cookie, i) { 955 | var validationMessage = CookieExporter.validateCookieObject(cookie); 956 | if (validationMessage) { 957 | console.warn('serialize: Invalid cookie at index ' + i + ': ' + validationMessage); 958 | } 959 | }); 960 | // Note: exported cookie may include unrecognized properties. 961 | // If this happens, the extension should be updated to support the new fields. 962 | // The alternative is to drop unrecognized keys, but the downside to that is that 963 | // the keys may be relevant to describing the cookie accurately. 964 | var serializedCookies = JSON.stringify(cookies, null, 1); 965 | var exported = { 966 | // Include extension and browser versions to allow old data to be migrated in the 967 | // deserialize method, if needed in the future. 968 | cookieManagerVersion: chrome.runtime.getManifest().version, 969 | userAgent: navigator.userAgent, 970 | cookies: 'placeholder', 971 | }; 972 | return JSON.stringify(exported, null, 1).replace('"placeholder"', serializedCookies); 973 | }, 974 | deserialize(serialized, overrideStore) { 975 | var imported; 976 | try { 977 | imported = JSON.parse(serialized); 978 | } catch (e) { 979 | throw new Error('Invalid JSON: ' + e.message.replace(/^JSON\.parse: /, '')); 980 | } 981 | var cookies = imported.cookies; 982 | if (!Array.isArray(cookies)) { 983 | throw new Error('Invalid data: "cookies" array not found!'); 984 | } 985 | for (var i = 0; i < cookies.length; ++i) { 986 | var cookie = cookies[i]; 987 | var validationMessage = CookieExporter.validateCookieObject(cookie); 988 | if (validationMessage) { 989 | throw new Error('Invalid cookie at index ' + i + ': ' + validationMessage); 990 | } 991 | cookie.url = cookieToUrl(cookie); 992 | if (overrideStore) { 993 | cookie.storeId = overrideStore; 994 | } 995 | } 996 | return cookies; 997 | }, 998 | 999 | // Do a basic validation of the cookie. 1000 | validateCookieObject(cookie) { 1001 | if (typeof cookie !== 'object') 1002 | return 'cookie has an invalid type. Expected object, got ' + typeof cookie; 1003 | if (cookie === null) 1004 | return 'cookie has an invalid type. Expected object, got null'; 1005 | 1006 | function typeofProp(key) { 1007 | return key in cookie ? typeof cookie[key] : 'undefined'; 1008 | } 1009 | 1010 | for (var key of CookieExporter.KEYS) { 1011 | var allowedTypes = CookieExporter.KEY_TYPES[key]; 1012 | if (!allowedTypes.includes(typeofProp(key))) { 1013 | return 'cookie.' + key + ' has an invalid type. Expected ' + allowedTypes + 1014 | ', got ' + typeofProp(key); 1015 | } 1016 | } 1017 | if ('session' in cookie && cookie.session) { 1018 | if (typeofProp('expirationDate') !== 'undefined') { 1019 | return 'cookie.expirationDate cannot be set if cookie.session is true.'; 1020 | } 1021 | } else if (typeofProp('expirationDate') !== 'number') { 1022 | return 'cookie.expirationDate has an invalid type. Expected number , got ' + 1023 | typeofProp(key); 1024 | } 1025 | // This is a very shallow validator. If the format is really that terrible, then 1026 | // cookies.set will reject with an error. 1027 | }, 1028 | }; 1029 | var HTTP_ONLY = '#HttpOnly_'; 1030 | var NetscapeCookieExporter = { 1031 | // Format: tab-separated fields: 1032 | // - domain: e.g. ".example.com" or "www.example.net" 1033 | // - flag (TRUE/FALSE): whether it is a domain cookie 1034 | // - path 1035 | // - secure (TRUE/FALSE): https 1036 | // - expiration: UNIX epoch 1037 | // - name 1038 | // - value 1039 | // Refs: https://unix.stackexchange.com/a/210282 and curl lib/cookie.c 1040 | // Lines starting with "#HttpOnly_" are httpOnly. 1041 | // Lines starting with '#' are ignored. 1042 | // This format ignores: session, storeId, sameSite, firstPartyDomain, partitionKey. 1043 | probe(text) { 1044 | // Tries to find one line that matches the format. 1045 | return /^\S+\t(TRUE|FALSE)\t[^\t\n]+\t(TRUE|FALSE)\t\d+\t[^\t\n]*\t/m.test(text); 1046 | }, 1047 | serialize(cookies) { 1048 | var output = '# Netscape HTTP Cookie File\n\n'; 1049 | cookies.forEach((cookie) => { 1050 | if (cookie.httpOnly) { 1051 | output += HTTP_ONLY; 1052 | } 1053 | output += [ 1054 | cookie.domain, 1055 | cookie.hostOnly ? 'FALSE' : 'TRUE', 1056 | cookie.path, 1057 | cookie.secure ? 'TRUE' : 'FALSE', 1058 | cookie.expirationDate || 0, 1059 | cookie.name, 1060 | cookie.value, 1061 | ].join('\t') + '\n'; 1062 | }); 1063 | return output; 1064 | }, 1065 | deserialize(serialized, overrideStore) { 1066 | if (!overrideStore) { 1067 | throw new Error('A destination cookie jar must be explicitly selected'); 1068 | } 1069 | var cookies = []; 1070 | serialized.split('\n').forEach((line, i) => { 1071 | line = line.replace('\r', ''); 1072 | var throwInvalidCookie = (msg) => { 1073 | throw new Error(`Invalid cookie at line ${i + 1}: ${msg}. Line: ${line}`); 1074 | }; 1075 | var toBool = (str) => { 1076 | if (str === 'TRUE') { 1077 | return true; 1078 | } else if (str === 'FALSE') { 1079 | return false; 1080 | } else { 1081 | throwInvalidCookie('Expected TRUE or FALSE for a field'); 1082 | } 1083 | }; 1084 | var fields = line.split('\t'); 1085 | var httpOnly = line.startsWith(HTTP_ONLY); 1086 | if (line === '' || line.startsWith('#') && !httpOnly) { 1087 | // skip empty lines and comments. 1088 | return; 1089 | } 1090 | if (fields.length !== 7) { 1091 | throwInvalidCookie('Too few or too many fields'); 1092 | } 1093 | var cookie = { 1094 | domain: fields[0], 1095 | hostOnly: !toBool(fields[1]), 1096 | path: fields[2], 1097 | secure: toBool(fields[3]), 1098 | expirationDate: 1 * fields[4], 1099 | name: fields[5], 1100 | value: fields[6], 1101 | httpOnly: httpOnly, 1102 | storeId: overrideStore, 1103 | }; 1104 | if (httpOnly) { 1105 | cookie.domain = cookie.domain.substr(HTTP_ONLY.length); 1106 | } 1107 | if (isNaN(cookie.expirationDate)) { 1108 | throwInvalidCookie('Bad expiry date'); 1109 | } 1110 | if (cookie.expirationDate === 0) { 1111 | delete cookie.expirationDate; 1112 | cookie.session = true; 1113 | } 1114 | var validationMessage = CookieExporter.validateCookieObject(cookie); 1115 | if (validationMessage) { 1116 | throwInvalidCookie(validationMessage); 1117 | } 1118 | cookie.url = cookieToUrl(cookie); 1119 | cookies.push(cookie); 1120 | }); 1121 | return cookies; 1122 | } 1123 | }; 1124 | document.getElementById('export-cancel').onclick = function() { 1125 | document.getElementById('exportform').reset(); 1126 | document.getElementById('export-text').hidden = true; 1127 | document.getElementById('export-before-import').hidden = true; 1128 | document.body.classList.remove('exporting-cookies'); 1129 | }; 1130 | document.getElementById('import-cancel').onclick = function() { 1131 | _importFormInstanceCounter++; // Invalidates existing import, if possible. 1132 | document.getElementById('importform').reset(); 1133 | document.getElementById('import-import').disabled = false; 1134 | document.querySelector('#importform progress').hidden = true; 1135 | document.getElementById('import-log').hidden = true; 1136 | document.getElementById('import-log').value = ''; 1137 | document.body.classList.remove('importing-cookies'); 1138 | }; 1139 | document.getElementById('shortcut-to-import-form').onclick = function(event) { 1140 | event.preventDefault(); // Do not submit export form. 1141 | document.getElementById('export-cancel').click(); 1142 | OtherActionsController.bulk_import(); 1143 | }; 1144 | document.getElementById('exportform').onsubmit = function(event) { 1145 | event.preventDefault(); 1146 | var exportFormat = document.querySelector('#exportform input[name="export-format"]:checked').value; 1147 | var exportType = document.querySelector('#exportform input[name="export-type"]:checked').value; 1148 | 1149 | var cookies = []; 1150 | getAllCookieRows().filter(isRowSelected).forEach(function(row) { 1151 | row.cmApi.forEachRawCookie(function(cookie) { 1152 | cookies.push(cookie); 1153 | }); 1154 | }); 1155 | if (exportType === 'copy_import') { 1156 | document.getElementById('import-text').value = CookieExporter.serialize(cookies); 1157 | document.getElementById('export-before-import').hidden = false; 1158 | document.getElementById('export-text').hidden = true; 1159 | return; 1160 | } 1161 | document.getElementById('export-before-import').hidden = true; 1162 | 1163 | var filename, text; 1164 | if (exportFormat === 'netscape') { 1165 | filename = 'cookies.txt'; 1166 | text = NetscapeCookieExporter.serialize(cookies); 1167 | } else { 1168 | filename = 'cookies.json'; 1169 | text = CookieExporter.serialize(cookies); 1170 | } 1171 | 1172 | if (exportType === 'file') { 1173 | // Trigger the download from a child frame to work around a Firefox bug where an attempt to 1174 | // load a blob:-URL causes the document to unload - https://bugzil.la/1420419 1175 | var f = document.createElement('iframe'); 1176 | f.style.position = 'fixed'; 1177 | f.style.left = f.style.top = '-999px'; 1178 | f.style.width = f.style.height = '99px'; 1179 | f.srcdoc = `${filename}`; 1180 | f.onload = function() { 1181 | var blob = new Blob([text], {type: 'application/json'}); 1182 | var a = f.contentDocument.querySelector('a'); 1183 | a.href = f.contentWindow.URL.createObjectURL(blob); 1184 | a.click(); 1185 | // Removing the frame document implicitly revokes the blob:-URL too. 1186 | setTimeout(function() { f.remove(); }, 2000); 1187 | }; 1188 | document.body.appendChild(f); 1189 | } else { 1190 | document.getElementById('export-text').value = text; 1191 | document.getElementById('export-text').hidden = false; 1192 | } 1193 | }; 1194 | 1195 | var _importFormInstanceCounter = 0; 1196 | document.getElementById('importform').onsubmit = function(event) { 1197 | event.preventDefault(); 1198 | var importFormInstanceId = ++_importFormInstanceCounter; 1199 | 1200 | importStarted(); 1201 | 1202 | var importFile = document.getElementById('import-file').files[0]; 1203 | if (importFile) { 1204 | var fr = new FileReader(); 1205 | fr.onloadend = function() { 1206 | fr.onloadend = null; 1207 | if (fr.error) { 1208 | importError('Failed to read file: ' + 1209 | (fr.error.message || fr.error.name || fr.error)); 1210 | } else if (!fr.result) { 1211 | importError('Failed to import: Input file is empty!'); 1212 | } else { 1213 | importText(fr.result); 1214 | } 1215 | 1216 | }; 1217 | try { 1218 | fr.readAsText(importFile); 1219 | } catch (e) { 1220 | fr.onloadend = null; 1221 | // Firefox may synchronously throw an Exception, e.g. when the file has been deleted. 1222 | importError('Failed to read file: ' + e); 1223 | } 1224 | } else { 1225 | importText(document.getElementById('import-text').value); 1226 | } 1227 | 1228 | function guessExporter(text) { 1229 | var exporters = [CookieExporter, NetscapeCookieExporter]; 1230 | return exporters.find((exporter) => exporter.probe(text)); 1231 | } 1232 | 1233 | function importText(text) { 1234 | if (!text) { 1235 | importError('Failed to import: You must select a file or use the text field.'); 1236 | return; 1237 | } 1238 | var exporter = guessExporter(text); 1239 | if (!exporter) { 1240 | importError('Failed to import: unrecognized format'); 1241 | return; 1242 | } 1243 | var overrideStore = document.getElementById('import-store').value; 1244 | var cookies; 1245 | try { 1246 | cookies = exporter.deserialize(text, overrideStore); 1247 | } catch (e) { 1248 | importError('Failed to import: ' + e.message); 1249 | return; 1250 | } 1251 | if (!cookies.length) { 1252 | importError('Failed to import: The list of cookies is empty'); 1253 | return; 1254 | } 1255 | WhitelistManager.initialize().then(function() { 1256 | importParsedCookies(cookies); 1257 | }); 1258 | } 1259 | function importParsedCookies(cookies) { 1260 | // One last chance to abort the import before actually (over)writing cookies. 1261 | if (importFormInstanceId !== _importFormInstanceCounter) { 1262 | console.log('Import was aborted because the form was closed.'); 1263 | return; 1264 | } 1265 | if (!cookies.every(WhitelistManager.isModificationAllowed)) { 1266 | importError('Failed to import: One or more cookies is locked by the whitelist.'); 1267 | WhitelistManager.requestModification(); 1268 | return; 1269 | } 1270 | var progressbar = document.querySelector('#importform progress'); 1271 | progressbar.hidden = false; 1272 | progressbar.max = cookies.length; 1273 | progressbar.value = 0; 1274 | document.getElementById('import-log').hidden = false; 1275 | document.getElementById('import-cancel').disabled = true; 1276 | 1277 | var deleteSameSite = cookies.some(c => 'sameSite' in c) && !chrome.cookies.SameSiteStatus; 1278 | var convertSameSiteUnspecified = !deleteSameSite && !chrome.cookies.SameSiteStatus.UNSPECIFIED; 1279 | var deleteFirstPartyDomain = cookies.some(c => 'firstPartyDomain' in c) && !checkFirstPartyDomainSupport(); 1280 | var deletePartitionKey = cookies.some(c => 'partitionKey' in c) && !checkPartitionKeySupport(); 1281 | 1282 | var progress = 0; 1283 | var failCount = 0; 1284 | cookies.forEach(function(cookie, i) { 1285 | if (cookie.expirationDate < Date.now() / 1000) { 1286 | onImportedOneCookie('Did not import cookie ' + i + ' because it has been expired.'); 1287 | return; 1288 | } 1289 | if (deleteSameSite) { 1290 | delete cookie.sameSite; 1291 | } 1292 | if (convertSameSiteUnspecified && cookie.sameSite === "unspecified") { 1293 | // Firefox doesn't support SameSite=unspecified in the cookies API. 1294 | // https://bugzilla.mozilla.org/show_bug.cgi?id=1550032 1295 | // Chrome supports it since 76.0.3787.0 1296 | // https://chromium.googlesource.com/chromium/src/+/218d4eae63313b8fcf80f4befab679d1a200c57b 1297 | delete cookie.sameSite; 1298 | } 1299 | if (deleteFirstPartyDomain) { 1300 | delete cookie.firstPartyDomain; 1301 | } 1302 | // partitionKey not supported at all, ignore the property. 1303 | // TODO: If partitionKey supports properties other than "topLevelSite", 1304 | // then we need to feature-detect that and selectively delete if needed. 1305 | // See comment in checkPartitionKeySupport. 1306 | if (deletePartitionKey) { 1307 | delete cookie.partitionKey; 1308 | } 1309 | var details = getDetailsForCookiesSetAPI(cookie); 1310 | chrome.cookies.set(details, function() { 1311 | var error = chrome.runtime.lastError; 1312 | onImportedOneCookie(error && 'Failed to import cookie ' + i + ': ' + error.message); 1313 | }); 1314 | }); 1315 | 1316 | function onImportedOneCookie(error) { 1317 | if (error) { 1318 | document.getElementById('import-log').value += error + '\n'; 1319 | ++failCount; 1320 | } 1321 | progressbar.value = ++progress; 1322 | if (progress !== cookies.length) { 1323 | return; 1324 | } 1325 | var message; 1326 | if (failCount) { 1327 | message = 'Imported ' + (cookies.length - failCount) + ' cookies, ' + 1328 | 'failed to import ' + failCount + ' cookies.'; 1329 | } else { 1330 | message = 'Imported all ' + cookies.length + ' cookies.'; 1331 | } 1332 | document.getElementById('import-log').value += message + '\n'; 1333 | document.getElementById('import-cancel').disabled = false; 1334 | importFinished(); 1335 | } 1336 | } 1337 | 1338 | function importError(error) { 1339 | importOutput('ERROR: ' + error); 1340 | importFinished(); 1341 | } 1342 | function importOutput(msg) { 1343 | document.querySelector('#importform output').value = msg; 1344 | } 1345 | 1346 | function importStarted() { 1347 | // Disallow concurrent imports. 1348 | document.getElementById('import-import').disabled = true; 1349 | // Clear previous error messages. 1350 | importOutput(''); 1351 | } 1352 | function importFinished() { 1353 | document.getElementById('import-import').disabled = false; 1354 | } 1355 | }; 1356 | 1357 | function checkFirstPartyDomainSupport() { 1358 | if (gFirstPartyDomainSupported !== undefined) { 1359 | return gFirstPartyDomainSupported; 1360 | } 1361 | try { 1362 | // firstPartyDomain is only supported in Firefox 59+. 1363 | browser.cookies.get({ 1364 | name: 'dummyName', 1365 | firstPartyDomain: 'dummy', 1366 | url: 'about:blank', 1367 | }); 1368 | gFirstPartyDomainSupported = true; 1369 | } catch (e) { 1370 | gFirstPartyDomainSupported = false; 1371 | } 1372 | return gFirstPartyDomainSupported; 1373 | } 1374 | 1375 | function checkFirstPartyIsolationStatus() { 1376 | if (!checkFirstPartyDomainSupport()) { 1377 | gFirstPartyIsolationEnabled = false; 1378 | return Promise.resolve(); 1379 | } 1380 | 1381 | // The following result may change at runtime depending on the privacy.firstparty.isolate preference. 1382 | // We use the cookies API to detect whether the feature is enabled, 1383 | // because the alternative (browser.privacy.websites.firstPartyIsolate) requires permissions. 1384 | return browser.cookies.get({ 1385 | name: 'dummyName', 1386 | // Using cookies.get with an invalid URL is very cheap in Firefox: 1387 | // https://searchfox.org/mozilla-central/rev/26b40a44691e0710838130b614c2f2662bc91eec/toolkit/components/extensions/parent/ext-cookies.js#193-199 1388 | url: 'about:blank', 1389 | }).then(() => { 1390 | gFirstPartyIsolationEnabled = false; 1391 | }, (error) => { 1392 | if (!error.message.includes('firstPartyDomain')) { 1393 | console.error('Unexpected error message. Expected firstPartyDomain error, got: ' + error); 1394 | } 1395 | gFirstPartyIsolationEnabled = true; 1396 | }); 1397 | } 1398 | 1399 | function checkPartitionKeySupport() { 1400 | if (gPartitionKeySupported !== undefined) { 1401 | return gPartitionKeySupported; 1402 | } 1403 | try { 1404 | // firstPartyDomain is only supported in Firefox 94+ and Chrome 119+. 1405 | // TODO: If partitionKey ever supports other properties, then we need to 1406 | // update cookieToPartitionKeyString / partitionKeyFromString to account 1407 | // for that (and convert / ignore properties if applicable). 1408 | chrome.cookies.get({ 1409 | name: 'dummyName', 1410 | partitionKey: { topLevelSite: "" }, 1411 | url: 'about:blank', 1412 | }, () => { 1413 | // "No host permissions for cookies at url: "about:blank" 1414 | void chrome.runtime.lastError; 1415 | }); 1416 | gPartitionKeySupported = true; 1417 | } catch (e) { 1418 | gPartitionKeySupported = false; 1419 | } 1420 | return gPartitionKeySupported; 1421 | } 1422 | 1423 | // Return a mapping from a cookieStoreId to a human-readable name. 1424 | function getContextualIdentityNames() { 1425 | // contextualIdentities is Firefox-only. 1426 | var contextualIdNameMap = {}; 1427 | if (typeof browser !== 'object' || !browser.contextualIdentities) { 1428 | return Promise.resolve(contextualIdNameMap); 1429 | } 1430 | return browser.contextualIdentities.query({}).then(function(contextualIdentities) { 1431 | if (!contextualIdentities) { 1432 | // contextualIdentities can be false or null - https://bugzil.la/1389265 1433 | return contextualIdNameMap; 1434 | } 1435 | var byName = Object.create(null); 1436 | contextualIdentities.forEach(function(contextualIdentity) { 1437 | var name = contextualIdentity.name; 1438 | contextualIdNameMap[contextualIdentity.cookieStoreId] = name; 1439 | (byName[name] || (byName[name] = [])).push(contextualIdentity); 1440 | }); 1441 | // Create more specific names if necessary. 1442 | Object.values(byName).forEach(function(contextualIdentitySubset) { 1443 | if (contextualIdentitySubset.length < 2) { 1444 | return; 1445 | } 1446 | var nameGenerators = [ 1447 | // First try to create a unique name with the icon. 1448 | function(contextualIdentity) { 1449 | return contextualIdentity.name + ' (' + contextualIdentity.icon + ')'; 1450 | }, 1451 | // If the icon is not unique, try a unique color. 1452 | function(contextualIdentity) { 1453 | return contextualIdentity.name + ' (' + contextualIdentity.color + ')'; 1454 | }, 1455 | // If the color is not unique, use both. 1456 | function(contextualIdentity) { 1457 | return contextualIdentity.name + ' (' + contextualIdentity.icon + ', ' + contextualIdentity.color + ')'; 1458 | }, 1459 | ]; 1460 | var uniqNames = []; 1461 | for (var i = 0; i < contextualIdentitySubset.length; ++i) { 1462 | var contextualIdentity = contextualIdentitySubset[i]; 1463 | var name = nameGenerators[0](contextualIdentity); 1464 | if (nameGenerators.length && uniqNames.includes(name)) { 1465 | // Not unique. Restart the loop with the next name generator. 1466 | uniqNames.length = 0; 1467 | i = 0; 1468 | } else { 1469 | contextualIdNameMap[contextualIdentity.cookieStoreId] = name; 1470 | uniqNames.push(name); 1471 | } 1472 | } 1473 | }); 1474 | return contextualIdNameMap; 1475 | }, function(error) { 1476 | console.error('Unexpected error in contextualIdentities.query: ' + error); 1477 | return contextualIdNameMap; 1478 | }); 1479 | } 1480 | 1481 | function updateCookieStoreIds() { 1482 | return Promise.all([ 1483 | new Promise(function(resolve) { 1484 | chrome.cookies.getAllCookieStores(resolve); 1485 | }), 1486 | getContextualIdentityNames(), 1487 | ]).then(function(args) { 1488 | var cookieStores = args[0]; 1489 | var contextualIdNameMap = args[1]; 1490 | 1491 | var cookieJarDropdown = document.getElementById('.storeId'); 1492 | var editCoJarDropdown = document.getElementById('editform.storeId'); 1493 | var importCoJarDropdown = document.getElementById('import-store'); 1494 | var selectedValue = cookieJarDropdown.value; 1495 | var editValue = editCoJarDropdown.value; 1496 | var importValue = importCoJarDropdown.value; 1497 | cookieJarDropdown.textContent = ''; 1498 | cookieJarDropdown.appendChild(new Option('Any cookie jar', ANY_COOKIE_STORE_ID)); 1499 | editCoJarDropdown.textContent = ''; 1500 | // Remove all cookie stores except for the "implied" one. 1501 | importCoJarDropdown.length = 1; 1502 | // TODO: Do something with cookieStores[*].tabIds ? 1503 | cookieStores.forEach(function(cookieStore) { 1504 | var option = new Option(storeIdToHumanName(cookieStore.id, contextualIdNameMap), cookieStore.id); 1505 | cookieJarDropdown.appendChild(option.cloneNode(true)); 1506 | editCoJarDropdown.appendChild(option.cloneNode(true)); 1507 | importCoJarDropdown.add(option.cloneNode(true)); 1508 | }); 1509 | cookieJarDropdown.value = selectedValue; 1510 | editCoJarDropdown.value = editValue; 1511 | importCoJarDropdown.value = importValue; 1512 | if (cookieJarDropdown.selectedIndex === -1) { 1513 | cookieJarDropdown.value = ANY_COOKIE_STORE_ID; 1514 | } 1515 | if (editCoJarDropdown.selectedIndex === -1) { 1516 | // Presumably the default cookie jar. 1517 | editCoJarDropdown.selectedIndex = 0; 1518 | } 1519 | if (importCoJarDropdown.selectedIndex === -1) { 1520 | // Select the cookie jar implied from the format. 1521 | importCoJarDropdown.selectedIndex = 0; 1522 | } 1523 | }); 1524 | } 1525 | 1526 | function storeIdToHumanName(storeId, contextualIdNameMap) { 1527 | // Chrome 1528 | // These values are not documented, but they appear to be hard-coded in 1529 | // https://chromium.googlesource.com/chromium/src/+/3c7170a0bed4bf8cc9b0a95f5066100bec0f15bb/chrome/browser/extensions/api/cookies/cookies_helpers.cc#43 1530 | if (storeId === '0') { 1531 | return 'Cookie jar: Default'; 1532 | } 1533 | if (storeId === '1') { 1534 | return 'Cookie jar: Incognito'; 1535 | } 1536 | 1537 | // Firefox 1538 | // Not documented either, but also hardcoded in 1539 | // http://searchfox.org/mozilla-central/rev/7419b368156a6efa24777b21b0e5706be89a9c2f/toolkit/components/extensions/ext-cookies.js#15 1540 | if (storeId === 'firefox-default') { 1541 | return 'Cookie jar: Default'; 1542 | } 1543 | if (storeId === 'firefox-private') { 1544 | return 'Cookie jar: Private browsing'; 1545 | } 1546 | var tmp = /^firefox-container-(.*)$/.exec(storeId); 1547 | if (tmp) { 1548 | var contextualIdName = contextualIdNameMap[storeId]; 1549 | if (contextualIdName) { 1550 | return 'Cookie jar: ' + contextualIdName + ' (Container Tab)'; 1551 | } 1552 | return 'Cookie jar: Container ' + tmp[1]; 1553 | } 1554 | return 'Cookie jar: ID ' + storeId; 1555 | } 1556 | 1557 | function doSearch() { 1558 | // Filters for cookie: 1559 | var filters = {}; 1560 | var query = {}; 1561 | function setQueryOrFilter(param, value) { 1562 | if (value.includes('*')) { 1563 | if (value !== '*' && (param !== 'path' || value !== '/*')) { 1564 | // Optimization: Do not create the query and filter if the 1565 | // user wants to see all results. 1566 | filters[param] = patternToRegExp(value, param === 'domain'); 1567 | } 1568 | } else if (value) { 1569 | query[param] = value; 1570 | } 1571 | } 1572 | [ 1573 | 'name', 1574 | 'secure', 1575 | 'httpOnly', 1576 | 'session', 1577 | 'sameSite', 1578 | 'storeId', 1579 | ].forEach(function(param) { 1580 | var input = document.getElementById('.' + param); 1581 | var value = input.value; 1582 | if (input.tagName === 'SELECT') { 1583 | if (value === 'true') { 1584 | query[param] = true; 1585 | } else if (value === 'false') { 1586 | query[param] = false; 1587 | } else if (value) { 1588 | query[param] = value; 1589 | } 1590 | } else { 1591 | setQueryOrFilter(param, value); 1592 | } 1593 | }); 1594 | 1595 | var fpdInputValue; // undefined = not specified, (possibly empty) string otherwise. 1596 | var pkeyInputValue; // undefined = not specified, (possibly empty) stirng otherwise. 1597 | var urlInputValue = document.getElementById('.url').value; 1598 | // "fpd:" may be at the start or end. 1599 | urlInputValue = urlInputValue.replace(/^fpd:(\S*) ?| ?fpd:(\S*)$/, function(_, a, b) { 1600 | fpdInputValue = a || b || ''; 1601 | return ''; 1602 | }); 1603 | // "partition:" may be at the start or end. 1604 | // Mutually exclusive with fpd. 1605 | urlInputValue = urlInputValue.replace(/^partition:(\S*) ?| ?partition:(\S*)$/, function(_, a, b) { 1606 | pkeyInputValue = a || b || ''; 1607 | return ''; 1608 | }); 1609 | if (urlInputValue) { 1610 | if (urlInputValue.includes('/')) { 1611 | setQueryOrFilter('url', urlInputValue); 1612 | } else { 1613 | setQueryOrFilter('domain', urlInputValue); 1614 | } 1615 | } 1616 | 1617 | if (typeof query.url === 'string') { 1618 | query.url = urlWithoutPort(query.url); 1619 | } 1620 | 1621 | // Custom filter: value 1622 | var valueFilterPattern = document.getElementById('.value').value; 1623 | if (valueFilterPattern && valueFilterPattern !== '*') { 1624 | filters.value = patternToRegExp(valueFilterPattern); 1625 | } 1626 | // Custom filter: Minimal/maximal expiry date 1627 | var expiryMinFilter = dateToExpiryCompatibleTimestamp(document.getElementById('.expiry.min')); 1628 | var expiryMaxFilter = dateToExpiryCompatibleTimestamp(document.getElementById('.expiry.max')); 1629 | 1630 | // Filter by httpOnly. The chrome.cookies API somehow does not support filtering by httpOnly... 1631 | var httpOnly = query.httpOnly; 1632 | delete query.httpOnly; 1633 | 1634 | // Filter by sameSite. The chrome.cookies API does not support filtering by sameSite status. 1635 | var sameSite = query.sameSite; 1636 | delete query.sameSite; 1637 | 1638 | var parsedUrl; 1639 | if (query.url) { 1640 | // Non-wildcard URLs must be a valid URL. 1641 | try { 1642 | parsedUrl = new URL(urlInputValue); 1643 | } catch (e) { 1644 | renderAllCookies([], ['Invalid URL: ' + urlInputValue]); 1645 | return; 1646 | } 1647 | } 1648 | 1649 | var extraFirstPartyQuery; 1650 | var extraPartitionedQuery; 1651 | var compiledFilters = []; 1652 | 1653 | // partitionKey and firstPartyDomain (dFPI and FPI) are mutually exclusive. 1654 | if (checkPartitionKeySupport() && !fpdInputValue) { 1655 | // We're going to include partitioned cookies by default. 1656 | // 1657 | // By default, partitioned cookies are not returned. A non-void 1658 | // partitionKey value should be set to return partitioned cookies. 1659 | // `partitionKey: {}` means to match any cookie, partitioned or not. 1660 | query.partitionKey = {}; 1661 | if (pkeyInputValue !== undefined) { 1662 | if (!pkeyInputValue.includes('*')) { 1663 | // Note: this should be a valid URL. 1664 | query.partitionKey = partitionKeyFromString(pkeyInputValue); 1665 | } else if (pkeyInputValue !== '*') { 1666 | let pkeyRegexp = patternToRegExp(pkeyInputValue); 1667 | compiledFilters.push(function matchesPartitionKey(cookie) { 1668 | return pkeyRegexp.test(cookieToPartitionKeyString(cookie)); 1669 | }); 1670 | } 1671 | } else { 1672 | // While the firstPartyDomain implementation puts much more effort 1673 | // into trying to match FPD cookies (not just when query.url is 1674 | // set, but also query.domain), we will only do that if a URL is 1675 | // given, for performance reasons. Otherwise the default cookie 1676 | // query would have to look up all cookies and filter the result, 1677 | // which is quite expensive. 1678 | if (parsedUrl) { 1679 | extraPartitionedQuery = Object.assign({}, query); 1680 | delete extraPartitionedQuery.url; 1681 | // Not set in practice, because mutually exclusive with url, 1682 | // but delete just in case we change the implementation: 1683 | delete extraPartitionedQuery.domain; 1684 | // topLevelSite is automatically normalized to the site, 1685 | // even if we pass the full URL. 1686 | extraPartitionedQuery.partitionKey = partitionKeyFromString(parsedUrl.href); 1687 | } 1688 | } 1689 | } 1690 | 1691 | checkFirstPartyIsolationStatus().then(function() { 1692 | if (!gFirstPartyDomainSupported) { 1693 | return; 1694 | } 1695 | if (fpdInputValue !== undefined) { 1696 | if (!fpdInputValue.includes('*')) { // Not a wildcard, possibly empty string. 1697 | query.firstPartyDomain = fpdInputValue; 1698 | } else { 1699 | // firstPartyDomain must explicitly be null to select all cookies. 1700 | query.firstPartyDomain = null; 1701 | if (fpdInputValue !== '*') { // '*' is same as not filtering at all. 1702 | filters.firstPartyDomain = patternToRegExp(fpdInputValue); 1703 | } 1704 | } 1705 | } else if (gFirstPartyIsolationEnabled) { 1706 | // If first-party isolation is enabled, include cookies for any firstPartyDomain. 1707 | query.firstPartyDomain = null; 1708 | 1709 | // Also include cookies whose firstPartyDomain matches (but url/domain does not). 1710 | var matchesDomainOrUrl; 1711 | var firstPartyDomainQuery; 1712 | var firstPartyDomainRegExp; 1713 | // query.domain, query.url, filters.domain and filters.url are mutually exclusive. 1714 | if (query.domain) { 1715 | // A plain and simple domain without wildcards. 1716 | firstPartyDomainQuery = query.domain; 1717 | matchesDomainOrUrl = compileDomainFilter(query.domain); 1718 | } else if (filters.domain) { 1719 | // Maybe a wildcard. 1720 | firstPartyDomainRegExp = filters.domain; 1721 | matchesDomainOrUrl = compileRegExpFilter('domain', filters.domain); 1722 | } else if (parsedUrl) { 1723 | // Simply a URL. 1724 | firstPartyDomainQuery = parsedUrl.hostname; 1725 | matchesDomainOrUrl = compileUrlFilter(parsedUrl); 1726 | } else if (filters.url) { 1727 | // Strip scheme, path comonent and port, if any. 1728 | firstPartyDomainQuery = urlInputValue.replace(/^[^/]*\/\//, '').split('/', 1)[0].replace(/:\d+$/, ''); 1729 | if (firstPartyDomainQuery.includes('*')) { // Wildcard? 1730 | firstPartyDomainRegExp = patternToRegExp(firstPartyDomainQuery, true); 1731 | firstPartyDomainQuery = undefined; 1732 | } 1733 | matchesDomainOrUrl = compileRegExpFilter('url', filters.url); 1734 | } 1735 | 1736 | let matchesFirstPartyDomain = 1737 | firstPartyDomainQuery ? function(cookie) { return cookie.firstPartyDomain === firstPartyDomainQuery; } : 1738 | firstPartyDomainRegExp ? compileRegExpFilter('firstPartyDomain', firstPartyDomainRegExp) : 1739 | null; 1740 | 1741 | if (matchesFirstPartyDomain) { 1742 | if (query.url || query.domain) { 1743 | if (firstPartyDomainQuery) { 1744 | extraFirstPartyQuery = Object.assign({}, query); 1745 | delete extraFirstPartyQuery.url; 1746 | delete extraFirstPartyQuery.domain; 1747 | extraFirstPartyQuery.firstPartyDomain = firstPartyDomainQuery; 1748 | } else { 1749 | delete query.url; 1750 | delete query.domain; 1751 | } 1752 | } 1753 | delete filters.url; 1754 | delete filters.domain; 1755 | compiledFilters.push(function matchesDomainOrUrlOrFirstPartyDomain(cookie) { 1756 | return matchesDomainOrUrl(cookie) || matchesFirstPartyDomain(cookie); 1757 | }); 1758 | } 1759 | } else { 1760 | // Otherwise, default to non-first-party cookies only. 1761 | query.firstPartyDomain = ''; 1762 | } 1763 | }).then(function() { 1764 | Object.keys(filters).forEach(function(key) { 1765 | compiledFilters.push(compileRegExpFilter(key, filters[key])); 1766 | }); 1767 | 1768 | if (query.storeId !== ANY_COOKIE_STORE_ID) { 1769 | useCookieStoreIds(query, [query.storeId]); 1770 | return; 1771 | } 1772 | chrome.cookies.getAllCookieStores(function(cookieStores) { 1773 | var cookieStoreIds = cookieStores.map(function(cookieStore) { 1774 | return cookieStore.id; 1775 | }); 1776 | useCookieStoreIds(query, cookieStoreIds); 1777 | }); 1778 | }); 1779 | 1780 | /** 1781 | * Fetches all cookies matching `query` from the cookie stores listed in `storeIds`, 1782 | * and renders the result. 1783 | * 1784 | * @param {object} query 1785 | * @param {string[]} cookieStoreIds List of CookieStore IDs for which cookies should be shown. 1786 | */ 1787 | function useCookieStoreIds(query, cookieStoreIds) { 1788 | var errors = []; 1789 | function promiseCookies(query, storeId) { 1790 | return new Promise(function(resolve) { 1791 | var queryWithId = Object.assign({}, query); 1792 | queryWithId.storeId = storeId; 1793 | chrome.cookies.getAll(queryWithId, function(cookies) { 1794 | var error = chrome.runtime.lastError && chrome.runtime.lastError.message; 1795 | if (error) { 1796 | // This should never happen. 1797 | // This might happen if the browser profile was closed while the user tries to 1798 | // access cookies in its cookie store. 1799 | console.error('Cannot retrieve cookies: ' + error); 1800 | errors.push('Failed to fetch cookies from cookie store ' + storeId + ': ' + error); 1801 | } 1802 | resolve(cookies || []); 1803 | }); 1804 | }); 1805 | } 1806 | var cookiePromises = cookieStoreIds.map(function(storeId) { 1807 | return promiseCookies(query, storeId).then(function(cookies) { 1808 | if (!extraFirstPartyQuery) { 1809 | return cookies; 1810 | } 1811 | // We are performing a separate query for firstPartyDomain. 1812 | // Ensure that the collections are disjoint. 1813 | cookies = cookies.filter(function(cookie) { 1814 | return cookie.firstPartyDomain !== extraFirstPartyQuery.firstPartyDomain; 1815 | }); 1816 | return promiseCookies(extraFirstPartyQuery, storeId).then(function(extraCookies) { 1817 | return cookies.concat(extraCookies); 1818 | }); 1819 | }).then(function(cookies) { 1820 | if (!extraPartitionedQuery) { 1821 | return cookies; 1822 | } 1823 | // The original query looks for cookies with the matching URL, 1824 | // the extra query returns cookies whose topLevelSite matches the URL. 1825 | // Same-site cookies (frames where the top level is the same site) 1826 | // are not partitioned, so there are no cookies where top-level site 1827 | // matches with the cookie's URL, 1828 | // except possibly when the public suffix has changed. 1829 | // In the worst case we just end up with duplicate cookies, which is 1830 | // a reasonable fallback for this unexpected scenario. 1831 | return promiseCookies(extraPartitionedQuery, storeId).then(function(extraCookies) { 1832 | return cookies.concat(extraCookies); 1833 | }); 1834 | }); 1835 | }); 1836 | 1837 | // Not really a cookie promise, but before showing any cookie rows we need to ensure that 1838 | // the whitelist is initialized. We can do that here. 1839 | cookiePromises.push(WhitelistManager.initialize().then(() => [])); 1840 | 1841 | Promise.all(cookiePromises).then(function(allCookies) { 1842 | // Flatten [[...a], [...b], ...] to [...a, ...b, ...] 1843 | allCookies = allCookies.reduce(function(a, b) { 1844 | return a.concat(b); 1845 | }, []); 1846 | renderAllCookies(allCookies, errors); 1847 | }, function(error) { 1848 | var allCookies = []; 1849 | var errors = ['Failed to fetch cookies: ' + error]; 1850 | renderAllCookies(allCookies, errors); 1851 | }); 1852 | } 1853 | 1854 | /** 1855 | * @pre cookies is a list of chrome.cookie.Cookie objects. 1856 | * @modifies cookie.url for each cookie in cookies 1857 | * @return filtered and sorted cookies 1858 | */ 1859 | function processAllCookies(cookies) { 1860 | var whitelistChoice = document.getElementById('.whitelist').value; 1861 | if (whitelistChoice) { // If 'true' or 'false' instead of ''. 1862 | whitelistChoice = whitelistChoice === 'true' ? true : false; 1863 | cookies = cookies.filter(function(cookie) { 1864 | return whitelistChoice === WhitelistManager.isWhitelisted(cookie); 1865 | }); 1866 | } 1867 | // For filtering, deletion and restoration. 1868 | cookies.forEach(function(cookie) { 1869 | cookie.url = cookieToUrl(cookie); 1870 | cookie._comparatorOperand = reverseString(cookie.domain) + cookie.path; 1871 | }); 1872 | 1873 | cookies = cookies.filter(function(cookie) { 1874 | if (httpOnly !== undefined && cookie.httpOnly !== httpOnly || 1875 | sameSite !== undefined && cookie.sameSite !== sameSite || 1876 | !cookie.session && ( 1877 | !isNaN(expiryMinFilter) && cookie.expirationDate < expiryMinFilter || 1878 | !isNaN(expiryMaxFilter) && cookie.expirationDate > expiryMaxFilter)) { 1879 | return false; 1880 | } 1881 | // Exclude cookies that do not match every filter 1882 | return compiledFilters.every(function(filter) { 1883 | return filter(cookie); 1884 | }); 1885 | }); 1886 | 1887 | // Sort the stuff. 1888 | cookies.sort(function(cookieA, cookieB) { 1889 | return cookieA._comparatorOperand.localeCompare(cookieB._comparatorOperand); 1890 | }); 1891 | // Clean-up 1892 | cookies.forEach(function(cookie) { 1893 | delete cookie._comparatorOperand; 1894 | }); 1895 | return cookies; 1896 | } 1897 | function renderAllCookies(cookies, errors) { 1898 | cookies = processAllCookies(cookies); 1899 | 1900 | var hasNoCookies = cookies.length === 0; 1901 | 1902 | var col_fpdo = document.querySelector('.col_fpdo'); 1903 | if (gFirstPartyIsolationEnabled) { 1904 | // Ensure that the FPD column exists after the Domain column. 1905 | var col_doma = document.querySelector('.col_doma'); 1906 | if (col_doma.nextSibling !== col_fpdo) { 1907 | col_doma.parentNode.insertBefore(col_fpdo, col_doma.nextSibling); 1908 | } 1909 | col_fpdo.classList.remove('columnDisabled'); 1910 | } else { 1911 | // Move element to ensure that CSS nth-child/even works as desired. 1912 | // Visually hide the column via CSS instead of removing to ensure that 1913 | // colspan/colSpan of other rows match the number of cells in the thead. 1914 | col_fpdo.parentNode.appendChild(col_fpdo); 1915 | col_fpdo.classList.add('columnDisabled'); 1916 | } 1917 | var col_pkey = document.querySelector('.col_pkey'); 1918 | if (checkPartitionKeySupport()) { 1919 | // Ensure that the PKEY column is shown after Domain, 1920 | // or even after FPD if the FPD column is visible. 1921 | var col_prev = 1922 | gFirstPartyIsolationEnabled ? 1923 | document.querySelector('.col_fpdo') : 1924 | document.querySelector('.col_doma'); 1925 | if (col_prev.nextSibling !== col_pkey) { 1926 | col_prev.parentNode.insertBefore(col_pkey, col_prev.nextSibling); 1927 | } 1928 | col_pkey.classList.remove('columnDisabled'); 1929 | } else { 1930 | // Move element to ensure that CSS nth-child/even works as desired. 1931 | // Visually hide the column via CSS instead of removing to ensure that 1932 | // colspan/colSpan of other rows match the number of cells in the thead. 1933 | col_pkey.parentNode.appendChild(col_pkey); 1934 | col_pkey.classList.add('columnDisabled'); 1935 | } 1936 | 1937 | var result = document.getElementById('result'); 1938 | result.classList.toggle('no-results', hasNoCookies); 1939 | result.tBodies[0].textContent = ''; 1940 | 1941 | if (hasNoCookies) { 1942 | var cell = result.tBodies[0].insertRow().insertCell(); 1943 | cell.colSpan = 7; 1944 | if (errors.length === 0) { 1945 | cell.textContent = 'No cookies found.'; 1946 | if (typeof query.domain === 'string' && 1947 | query.domain !== 'localhost' && 1948 | !query.domain.includes('.') && 1949 | !query.domain.includes('*')) { 1950 | cell.textContent += '\nPlease enter a full domain, or use wildcards (*) to match parts of domains.'; 1951 | cell.textContent += '\nFor example: *' + query.domain + '*'; 1952 | } 1953 | } else { 1954 | cell.style.whiteSpace = 'pre-wrap'; 1955 | cell.textContent = errors.join('\n'); 1956 | } 1957 | } 1958 | 1959 | invalidateRowReferences(); 1960 | 1961 | var cmApis = cookies.map(createBaseCookieManagerAPI); 1962 | var remainingCmApis = cmApis.splice(getMaxCookiesPerView()); 1963 | appendCookiesAsRows(cmApis, remainingCmApis); 1964 | } 1965 | 1966 | function getMaxCookiesPerView() { 1967 | var maxCookiesPerView = 20; 1968 | // Calculate the least number of rows to fill the screen, plus a bit more. 1969 | // "plus a bit more" because "minimumRowHeight" is lower than the actual minimal height of 1970 | // a row because the row also has padding, and rows themselves can also span multiple lines. 1971 | // Thus the result is a list that can be more than a few times larger than what fits in a 1972 | // screen. 1973 | var minimumRowHeight = parseFloat(window.getComputedStyle(document.body).fontSize) || 16; 1974 | var minCookiesPerScreen = Math.ceil(screen.availHeight / minimumRowHeight); 1975 | if (maxCookiesPerView < minCookiesPerScreen) { 1976 | maxCookiesPerView = minCookiesPerScreen; 1977 | } 1978 | if (maxCookiesPerView > 1000) { 1979 | // This is an excessive count. Let's bound the number for sanity. 1980 | maxCookiesPerView = 1000; 1981 | } 1982 | return maxCookiesPerView; 1983 | } 1984 | 1985 | function appendCookiesAsRows(cmApis, remainingCmApis) { 1986 | var result = document.getElementById('result'); 1987 | if (remainingCmApis.length === 1) { 1988 | // If there is only one row left, just render it. 1989 | cmApis.push(remainingCmApis.shift()); 1990 | } 1991 | 1992 | var fragment = document.createDocumentFragment(); 1993 | cmApis.forEach(function(cmApi) { 1994 | var tr = document.createElement('tr'); 1995 | renderCookie(tr, cmApi); 1996 | fragment.appendChild(tr); 1997 | }); 1998 | result.tBodies[0].appendChild(fragment); 1999 | 2000 | if (remainingCmApis.length === 0) { 2001 | showMoreResultsRow.remove(); 2002 | renderMultipleCookies(showMoreResultsRow, []); 2003 | updateButtonView(); 2004 | return; 2005 | } 2006 | result.tBodies[0].appendChild(showMoreResultsRow); 2007 | renderMultipleCookies(showMoreResultsRow, remainingCmApis); 2008 | 2009 | var maxCookiesPerView = getMaxCookiesPerView(); 2010 | document.getElementById('show-more-count').textContent = maxCookiesPerView; 2011 | document.getElementById('show-more-remaining-count').textContent = remainingCmApis.length; 2012 | 2013 | var shouldIgnoreClick = false; 2014 | document.getElementById('show-more-results-button').onclick = function(event) { 2015 | if (shouldIgnoreClick) { 2016 | // Do not propagate the button click to the row. 2017 | event.stopPropagation(); 2018 | return; 2019 | } 2020 | // The button is inside a row; normally clicking toggles the selection, 2021 | // but we don't want to do that. 2022 | event.stopPropagation(); 2023 | // ctrl-shift-click = show all. 2024 | var newRemainingCmApis = event.shiftKey && event.ctrlKey ? [] : remainingCmApis.splice(maxCookiesPerView); 2025 | appendCookiesAsRows(remainingCmApis, newRemainingCmApis); 2026 | }; 2027 | document.getElementById('show-more-results-button').onkeyup = function(event) { 2028 | if (event.keyCode === 32) { 2029 | // Pressing spacebar on a button usually causes the button to be clicked. 2030 | // Since the spacebar is used to toggle the row selection, prevent the 2031 | // spacebar from triggering a button click too. 2032 | shouldIgnoreClick = true; 2033 | setTimeout(function() { 2034 | shouldIgnoreClick = false; 2035 | }, 0); 2036 | } 2037 | }; 2038 | updateButtonView(); 2039 | } 2040 | } 2041 | 2042 | function invalidateRowReferences() { 2043 | if (currentlyEditingCookieRow) { 2044 | // currentlyEditingCookieRow should be null because it should not be possible 2045 | // to invalidate the table rows while an edit form is being shown. 2046 | console.warn('currentlyEditingCookieRow should be null'); 2047 | } 2048 | 2049 | _visibleCookieRows = null; 2050 | } 2051 | 2052 | var _confirmCounts; 2053 | function confirmOnce(messageId, msg) { 2054 | if (!_confirmCounts) { 2055 | try { 2056 | // If the tab has been reload but not closed, re-use the previously used confirmation settings. 2057 | _confirmCounts = JSON.parse(sessionStorage.confirmationCounts || '{}'); 2058 | } catch (e) { 2059 | _confirmCounts = {}; 2060 | } 2061 | } 2062 | var count = _confirmCounts[messageId] || 0; 2063 | if (count >= 2) { 2064 | // Auto-confirm after two repetitions. 2065 | return true; 2066 | } 2067 | if (count === 1) { 2068 | msg += '\n\nThis will not be asked again if you confirm (twice in a row, until the next startup of the Cookie Manager).'; 2069 | } 2070 | var result = window.confirm(msg); 2071 | _confirmCounts[messageId] = result ? count + 1 : 0; 2072 | try { 2073 | sessionStorage.confirmationCounts = JSON.stringify(_confirmCounts); 2074 | } catch (e) { 2075 | } 2076 | return result; 2077 | } 2078 | 2079 | // Utility functions. 2080 | 2081 | function patternToRegExp(pattern, isDomainPattern) { 2082 | pattern = pattern.replace(/[[^$.|?+(){}\\]/g, '\\$&'); 2083 | pattern = pattern.replace(/\*/g, '.*'); 2084 | if (isDomainPattern) { 2085 | // The cookies API is not consistent in filtering dots. 2086 | // Filtering by example.com and .example.com has the same effect. 2087 | // So we too permit an optional dot in the front. 2088 | // The following extra matches are added: 2089 | // example.com* -> .example.com* 2090 | // *example.com -> *.example.com 2091 | // .example.com -> *.example.com 2092 | pattern = pattern.replace(/^(((?:\.\*)*)\\\.?)?/, '$1\\.?'); 2093 | } 2094 | pattern = '^' + pattern + '$'; 2095 | return new RegExp(pattern, 'i'); 2096 | } 2097 | 2098 | /** 2099 | * Converts the value of input[type=date] to a timestamp that can be used in 2100 | * comparisons with cookie.expirationDate 2101 | */ 2102 | function dateToExpiryCompatibleTimestamp(dateInput) { 2103 | if (!dateInput || !dateInput.value) { 2104 | return NaN; 2105 | } 2106 | if (dateInput.valueAsNumber) { 2107 | return dateInput.valueAsNumber / 1000; 2108 | } 2109 | var date = dateInput.valueAsDate || new Date(dateInput.value); 2110 | return date.getTime() / 1000; 2111 | } 2112 | 2113 | function setExpiryTimestamp(dateInput, expirationDate) { 2114 | expirationDate *= 1000; 2115 | console.assert(!isNaN(expirationDate), 2116 | 'expirationDate is not a valid numeric timestamp: ' + arguments[1]); 2117 | 2118 | try { 2119 | dateInput.valueAsNumber = expirationDate; 2120 | } catch (e) { 2121 | // Not supported (e.g. Firefox 52). 2122 | dateInput.value = new Date(expirationDate).toJSON(); 2123 | } 2124 | } 2125 | 2126 | var months = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' '); 2127 | function pad(d) { 2128 | return d < 10 ? '0' + d : d; 2129 | } 2130 | function formatDate(date) { 2131 | return date.getDate() + '/' + months[date.getMonth()] + '/' + date.getFullYear() + ' ' + 2132 | pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds()); 2133 | } 2134 | 2135 | function reverseString(string) { 2136 | var result = ''; 2137 | for (var i = string.length - 1; i >= 0; i--) { 2138 | result += string[i]; 2139 | } 2140 | return result; 2141 | } 2142 | 2143 | function isPartOfDomain(domain, mainDomain) { 2144 | function normalizeDomain(d) { 2145 | return d.toLowerCase().replace(/^\.?/, '.'); 2146 | } 2147 | domain = normalizeDomain(domain); 2148 | mainDomain = normalizeDomain(mainDomain); 2149 | return domain !== '' && mainDomain.endsWith(domain); 2150 | } 2151 | 2152 | function compileDomainFilter(domain) { 2153 | return function matchesDomain(cookie) { 2154 | return isPartOfDomain(cookie.domain, domain); 2155 | }; 2156 | } 2157 | 2158 | function compileUrlFilter(parsedUrl) { 2159 | var protocolIsNotSecure = parsedUrl.protocol !== 'https:'; 2160 | var hostname = parsedUrl.hostname; 2161 | var path = parsedUrl.path + '//'; 2162 | 2163 | return function matchesUrl(cookie) { 2164 | if (cookie.hostOnly && hostname !== cookie.domain) 2165 | return false; 2166 | if (!isPartOfDomain(cookie.domain, hostname)) 2167 | return false; 2168 | if (cookie.secure && protocolIsNotSecure) 2169 | return false; 2170 | if (cookie.path !== '/' && !path.startsWith(cookie.path + '/')) 2171 | return false; 2172 | return true; 2173 | }; 2174 | } 2175 | 2176 | function compileRegExpFilter(key, regExp) { 2177 | return function matchesKeyRegExp(cookie) { 2178 | return regExp.test(cookie[key]); 2179 | }; 2180 | } 2181 | 2182 | var cookieValidators = {}; 2183 | cookieValidators._cookiePartCommon = function(prefix, v) { 2184 | // Based on ParsedCookie::ParseTokenString and ParsedCookie::ParseValueString 2185 | // via CanonicalCookie::Create. 2186 | // TODO: These restrictions are for Chrome. 2187 | // TODO: Look at netwerk/cookie/nsCookieService.cpp for Firefox. 2188 | if (/^[ \t]/.test(v)) 2189 | return prefix + ' cannot start with whitespace.'; 2190 | if (/[ \t]$/.test(v)) 2191 | return prefix + ' cannot end with whitespace.'; 2192 | if (/[\r\n\0]/.test(v)) 2193 | return prefix + ' cannot contain line terminators.'; 2194 | if (v.includes(';')) 2195 | return prefix + ' cannot contain ";".'; 2196 | }; 2197 | cookieValidators.name = function(name) { 2198 | // Based on ParsedCookie::ParseTokenString via CanonicalCookie::Create. 2199 | if (name.includes('=')) 2200 | return 'The cookie name cannot contain "=".'; 2201 | return cookieValidators._cookiePartCommon('The cookie name', name); 2202 | }; 2203 | cookieValidators.value = function(value) { 2204 | // Based on ParsedCookie::ParseValueString via CanonicalCookie::Create. 2205 | return cookieValidators._cookiePartCommon('The cookie value', value); 2206 | }; 2207 | cookieValidators.domain = function(domain, mainDomain) { 2208 | if (!isPartOfDomain(domain, mainDomain)) 2209 | return 'The domain must be a part of the given URL.'; 2210 | }; 2211 | cookieValidators.path = function(path) { 2212 | if (!path.startsWith('/')) 2213 | return 'The path must start with a /.'; 2214 | return cookieValidators._cookiePartCommon('The path', path); 2215 | }; 2216 | cookieValidators.expirationDate = function(expirationDate) { 2217 | // expirationDate is parsed using dateToExpiryCompatibleTimestamp. 2218 | // If the input is invalid, then it is NaN. 2219 | if (isNaN(expirationDate)) 2220 | return 'Please enter a valid expiration date.'; 2221 | }; 2222 | 2223 | 2224 | 2225 | function renderCookie(row, cmApi) { 2226 | var cookie = cmApi.rawCookie; 2227 | row.appendChild(document.getElementById('cookie_row_template').content.cloneNode(true)); 2228 | row.onclick = function(event) { 2229 | if (event.altKey || event.ctrlKey || event.cmdKey || event.shiftKey) { 2230 | return; // Do nothing if a key modifier was pressed. 2231 | } 2232 | if (!isEmptyTextSelection()) { 2233 | return; 2234 | } 2235 | row.cmApi.toggleHighlight(); 2236 | updateButtonView(); 2237 | }; 2238 | row.cmApi = cmApi; 2239 | row.cmApi.renderDeleted = function(isDeleted) { 2240 | row.classList.toggle('cookie-removed', isDeleted); 2241 | }; 2242 | row.cmApi.renderHighlighted = function(isHighlighted) { 2243 | row.classList.toggle('highlighted', isHighlighted); 2244 | }; 2245 | row.cmApi.renderListState = function() { 2246 | if (cookieIsWhitelisted === WhitelistManager.isWhitelisted(cookie)) { 2247 | return; // Common case, nothing to change; 2248 | } 2249 | cookieIsWhitelisted = !cookieIsWhitelisted; 2250 | var flagCell = row.querySelector('.flag_'); 2251 | var flagCellText = flagCell.textContent; 2252 | if (cookieIsWhitelisted) { 2253 | if (flagCellText.length) { 2254 | flagCell.textContent = TEXT_FLAG_WHITELIST + TEXT_FLAG_SEPARATOR + flagCellText; 2255 | } else { 2256 | flagCell.textContent = TEXT_FLAG_WHITELIST; 2257 | } 2258 | } else { 2259 | // If flagCellText === TEXT_FLAG_WHITELIST, then .slice(...) returns an empty string. 2260 | flagCell.textContent = flagCellText.slice( 2261 | TEXT_FLAG_WHITELIST.length + TEXT_FLAG_SEPARATOR.length); 2262 | } 2263 | }; 2264 | 2265 | if (cmApi.isHighlighted()) { 2266 | row.cmApi.renderHighlighted(true); 2267 | } 2268 | if (cmApi.isDeleted()) { 2269 | row.cmApi.renderDeleted(true); 2270 | } 2271 | 2272 | var TEXT_FLAG_SEPARATOR = ', '; 2273 | var TEXT_FLAG_WHITELIST = 'whitelist'; 2274 | var cookieIsWhitelisted = WhitelistManager.isWhitelisted(cookie); 2275 | 2276 | row.querySelector('.name_').textContent = cookie.name; 2277 | row.querySelector('.valu_').textContent = cookie.value; 2278 | row.querySelector('.doma_').textContent = cookie.domain; 2279 | if (gFirstPartyIsolationEnabled) { 2280 | row.querySelector('.fpdo_').textContent = cookie.firstPartyDomain; 2281 | } else { 2282 | // If First-Party Isolation is disabled, hide it from the main table. 2283 | row.querySelector('.fpdo_').closest('td').remove(); 2284 | // If it is a first-party cookie, a flag will be appended. 2285 | } 2286 | if (checkPartitionKeySupport()) { 2287 | row.querySelector('.pkey_').textContent = cookieToPartitionKeyString(cookie); 2288 | } else { 2289 | // If partitionKey is not supported in this browser, hide it from the main table. 2290 | row.querySelector('.pkey_').closest('td').remove(); 2291 | } 2292 | 2293 | var extraInfo = []; 2294 | if (cookieIsWhitelisted) extraInfo.push(TEXT_FLAG_WHITELIST); 2295 | // Not sure if host-only should be added 2296 | if (cookie.secure) extraInfo.push('secure'); 2297 | if (cookie.httpOnly) extraInfo.push('httpOnly'); 2298 | if (cookie.storeId === '1') extraInfo.push('incognito'); 2299 | else if (cookie.storeId === 'firefox-private') extraInfo.push('private'); 2300 | else if (/^firefox-container-/.test(cookie.storeId)) extraInfo.push('container'); 2301 | if (cookie.sameSite === 'lax') extraInfo.push('sameSite=lax'); 2302 | else if (cookie.sameSite === 'strict') extraInfo.push('sameSite=strict'); 2303 | // When first-party isolation is disabled, we don't show a column, so add a flag. 2304 | if (!gFirstPartyIsolationEnabled && cookie.firstPartyDomain) extraInfo.push('fpDomain'); 2305 | // When partitionKey is not supported, it is stripped from the cookie so we 2306 | // won't end up here. Unlike firstPartyDomain above, we always show the 2307 | // partitionKey column when possible. 2308 | 2309 | extraInfo = extraInfo.join(TEXT_FLAG_SEPARATOR); 2310 | row.querySelector('.flag_').textContent = extraInfo; 2311 | 2312 | var expiryInfo; 2313 | if (cookie.session) { 2314 | expiryInfo = 'At end of session'; 2315 | } else { 2316 | expiryInfo = formatDate(new Date(cookie.expirationDate*1000)); 2317 | } 2318 | var expiCell = row.querySelector('.expi_'); 2319 | expiCell.textContent = expiryInfo; 2320 | if (cookie.expirationDate < Date.now() / 1000) { 2321 | expiCell.title = 2322 | 'This cookie has already been expired and will not be sent to websites.\n' + 2323 | 'To explicitly delete it, select the cookie and click on the Remove button.'; 2324 | expiCell.style.cursor = 'help'; 2325 | expiCell.style.color = 'red'; 2326 | } 2327 | 2328 | row.querySelector('.edit-single-cookie').onclick = function(event) { 2329 | event.stopPropagation(); 2330 | renderEditCookieForm(cookie, row); 2331 | }; 2332 | 2333 | bindKeyboardToRow(row); 2334 | } 2335 | 2336 | function renderMultipleCookies(row, cmApis) { 2337 | if (!cmApis.length) { 2338 | // Release original list of cmApis from the closure. 2339 | row.cmApi = null; 2340 | return; 2341 | } 2342 | cmApis = cmApis.slice(); 2343 | 2344 | row.onclick = function(event) { 2345 | if (event.altKey || event.ctrlKey || event.cmdKey || event.shiftKey) { 2346 | return; // Do nothing if a key modifier was pressed. 2347 | } 2348 | if (!isEmptyTextSelection()) { 2349 | return; 2350 | } 2351 | row.cmApi.toggleHighlight(); 2352 | updateButtonView(); 2353 | }; 2354 | row.cmApi = wrapMultipleCookieManagerApis(cmApis); 2355 | row.cmApi.renderDeleted = function(isDeleted) { 2356 | row.classList.toggle('cookie-removed', isDeleted); 2357 | }; 2358 | row.cmApi.renderHighlighted = function(isHighlighted) { 2359 | row.classList.toggle('highlighted', isHighlighted); 2360 | }; 2361 | row.cmApi.renderListState = function() { 2362 | var count = row.cmApi.getWhitelistCount(); 2363 | document.getElementById('show-more-whitelist-info').textContent = 2364 | count ? '(' + count + ' whitelisted)' : ''; 2365 | }; 2366 | 2367 | row.cmApi.renderListState(); 2368 | 2369 | bindKeyboardToRow(row); 2370 | } 2371 | 2372 | /** 2373 | * @param {object} cookie chrome.cookies.Cookie type extended with "url" key. 2374 | */ 2375 | function createBaseCookieManagerAPI(cookie) { 2376 | // TODO: Make this a class. 2377 | var cmApi = { 2378 | // The caller should not modify this value! 2379 | get rawCookie() { return cookie; }, 2380 | _isDeleted: false, 2381 | _isHighlighted: false, 2382 | }; 2383 | cmApi.forEachRawCookie = function(callback) { 2384 | callback(cookie); 2385 | }; 2386 | cmApi.isDeleted = function() { 2387 | return cmApi._isDeleted; 2388 | }; 2389 | cmApi.getDeletionCount = function() { 2390 | return cmApi._isDeleted ? 1 : 0; 2391 | }; 2392 | cmApi.isHighlighted = function() { 2393 | return cmApi._isHighlighted; 2394 | }; 2395 | cmApi.toggleHighlight = function(forceHighlight) { 2396 | if (typeof forceHighlight === 'boolean') { 2397 | if (cmApi._isHighlighted === forceHighlight) return; 2398 | cmApi._isHighlighted = forceHighlight; 2399 | } else { 2400 | cmApi._isHighlighted = !cmApi._isHighlighted; 2401 | } 2402 | cmApi.renderHighlighted(cmApi._isHighlighted); 2403 | }; 2404 | cmApi.toggleWhitelist = function(forceWhitelist) { 2405 | // Require a boolean until we have a legitimate need for making the param optional. 2406 | if (typeof forceWhitelist != 'boolean') throw new Error('Expecting a boolean'); 2407 | if (forceWhitelist) { 2408 | WhitelistManager.addToList(cookie); 2409 | } else { 2410 | WhitelistManager.removeFromList(cookie); 2411 | } 2412 | }; 2413 | cmApi.getWhitelistCount = function() { 2414 | return WhitelistManager.isWhitelisted(cookie) ? 1 : 0; 2415 | }; 2416 | cmApi.getCookieCount = function() { 2417 | return 1; 2418 | }; 2419 | cmApi.deleteCookie = function() { 2420 | // Promise is resolved regardless of whether the call succeeded. 2421 | // The resolution value is an error string if an error occurs. 2422 | return new Promise(deleteCookie); 2423 | }; 2424 | cmApi.restoreCookie = function() { 2425 | // Promise is resolved regardless of whether the call succeeded. 2426 | // The resolution value is an error string if an error occurs. 2427 | return new Promise(restoreCookie); 2428 | }; 2429 | cmApi.renderDeleted = function(isDeleted) { 2430 | // Should be overridden. 2431 | }; 2432 | cmApi.renderHighlighted = function(isHighlighted) { 2433 | // Should be overridden. 2434 | }; 2435 | cmApi.renderListState = function() { 2436 | // No-op. When the row is not rendered, the state of the whitelist is not relevant. 2437 | }; 2438 | 2439 | return cmApi; 2440 | 2441 | function shouldBlockModification(resolve) { 2442 | if (!WhitelistManager.isModificationAllowed(cookie)) { 2443 | WhitelistManager.requestModification(); 2444 | resolve('Refused to modify a whitelisted cookie.'); 2445 | return true; 2446 | } 2447 | } 2448 | 2449 | function deleteCookie(resolve) { 2450 | if (shouldBlockModification(resolve)) { 2451 | return; 2452 | } 2453 | var details = getDetailsForCookiesSetAPI(cookie); 2454 | details.value = ''; 2455 | details.expirationDate = 0; 2456 | chrome.cookies.set(details, function(newCookie) { 2457 | if (chrome.runtime.lastError) { 2458 | resolve(chrome.runtime.lastError.message); 2459 | } else { 2460 | cmApi._isDeleted = true; 2461 | cmApi.renderDeleted(true); 2462 | resolve(); 2463 | } 2464 | }); 2465 | } 2466 | function restoreCookie(resolve) { 2467 | if (shouldBlockModification(resolve)) { 2468 | return; 2469 | } 2470 | var details = getDetailsForCookiesSetAPI(cookie); 2471 | chrome.cookies.set(details, function() { 2472 | if (chrome.runtime.lastError) { 2473 | resolve(chrome.runtime.lastError.message); 2474 | } else { 2475 | cmApi._isDeleted = false; 2476 | cmApi.renderDeleted(false); 2477 | resolve(); 2478 | } 2479 | }); 2480 | } 2481 | } 2482 | 2483 | /** 2484 | * Create a manager for a list of cmApi objects. This function must have 2485 | * exclusive access to the cmApis, to ensure data integrity. 2486 | * 2487 | * @param {array} cmApis A non-empty list of cmApi objects. 2488 | */ 2489 | function wrapMultipleCookieManagerApis(cmApis) { 2490 | // In this function: _cmApi = wrapper on cmApis, cmApi = element in cmApi. 2491 | // _cmApi implements the same interface as cmApi. 2492 | var _cmApi = { 2493 | // Cannot return a single cookie; should not be used. 2494 | get rawCookie() { throw new Error('Should not use rawCookie'); }, 2495 | }; 2496 | _cmApi.forEachRawCookie = function(callback) { 2497 | cmApis.forEach(cmApi => callback(cmApi.rawCookie)); 2498 | }; 2499 | _cmApi.isDeleted = function() { 2500 | return cmApis.some(cmApi => cmApi.isDeleted()); 2501 | }; 2502 | _cmApi.getDeletionCount = function() { 2503 | return cmApis.reduce(function(count, cmApi) { 2504 | return count + cmApi.getDeletionCount(); 2505 | }, 0); 2506 | }; 2507 | _cmApi.isHighlighted = function() { 2508 | return cmApis[0].isHighlighted(); 2509 | }; 2510 | _cmApi.toggleHighlight = function(forceHighlight) { 2511 | if (typeof forceHighlight !== 'boolean') { 2512 | forceHighlight = !_cmApi.isHighlighted(); 2513 | } 2514 | cmApis.forEach(function(cmApi) { 2515 | cmApi.toggleHighlight(forceHighlight); 2516 | }); 2517 | _cmApi.renderHighlighted(forceHighlight); 2518 | }; 2519 | _cmApi.toggleWhitelist = function(forceWhitelist) { 2520 | cmApis.forEach(function(cmApi) { 2521 | cmApi.toggleHighlight(forceWhitelist); 2522 | }); 2523 | }; 2524 | _cmApi.getWhitelistCount = function() { 2525 | return cmApis.reduce(function(count, cmApi) { 2526 | return count + cmApi.getWhitelistCount(); 2527 | }, 0); 2528 | }; 2529 | _cmApi.getCookieCount = function() { 2530 | return cmApis.length; 2531 | }; 2532 | _cmApi.deleteCookie = function() { 2533 | return Promise.all(cmApis.map(function(cmApi) { 2534 | return cmApi.deleteCookie(); 2535 | })).then(function(errors) { 2536 | _cmApi.renderDeleted(_cmApi.isDeleted()); 2537 | return Array.from(new Set(errors)).filter(e => e).join('\n'); 2538 | }); 2539 | }; 2540 | _cmApi.restoreCookie = function() { 2541 | return Promise.all(cmApis.map(function(cmApi) { 2542 | if (!WhitelistManager.isModificationAllowed(cmApi.rawCookie)) return; 2543 | return cmApi.restoreCookie(); 2544 | })).then(function(errors) { 2545 | _cmApi.renderDeleted(_cmApi.isDeleted()); 2546 | return Array.from(new Set(errors)).filter(e => e).join('\n'); 2547 | }); 2548 | }; 2549 | _cmApi.renderDeleted = function(isDeleted) { 2550 | // Should be overridden. 2551 | }; 2552 | _cmApi.renderHighlighted = function(isHighlighted) { 2553 | // Should be overridden. 2554 | }; 2555 | _cmApi.renderListState = function() { 2556 | // No-op. When the row is not rendered, the state of the whitelist is not relevant. 2557 | }; 2558 | 2559 | return _cmApi; 2560 | } 2561 | 2562 | function bindKeyboardToRow(row) { 2563 | row.tabIndex = 1; 2564 | row.onfocus = function() { 2565 | var rect = row.getBoundingClientRect(); 2566 | var viewTop = 0; 2567 | var viewBottom = document.getElementById('footer-controls').offsetTop; 2568 | var deltaY; 2569 | if (rect.top < viewTop) { 2570 | deltaY = (rect.top - viewTop); 2571 | } else if (rect.bottom > viewBottom) { 2572 | deltaY = (rect.bottom - viewBottom); 2573 | } 2574 | if (deltaY) { 2575 | window.scrollBy({ 2576 | top: deltaY, 2577 | behavior: 'instant', 2578 | }); 2579 | } 2580 | }; 2581 | row.onkeydown = function(event) { 2582 | if (event.altKey || 2583 | event.ctrlKey || 2584 | event.cmdKey) { 2585 | // Do nothing if a key modifier was pressed. 2586 | return; 2587 | } 2588 | if (event.shiftKey && !isEmptyTextSelection()) { 2589 | return; 2590 | } 2591 | switch (event.keyCode) { 2592 | case 32: // Spacebar 2593 | row.cmApi.toggleHighlight(); 2594 | updateButtonView(); 2595 | break; 2596 | case 33: // Page up 2597 | case 34: // Page down 2598 | handlePageUpOrDown(event); 2599 | return; 2600 | case 35: // End 2601 | jumpRange(event, row.parentNode.rows[row.parentNode.rows.length - 1]); 2602 | break; 2603 | case 36: // Home 2604 | jumpRange(event, row.parentNode.rows[0]); 2605 | break; 2606 | case 38: // Arrow up 2607 | case 40: // Arrow down 2608 | var next = event.keyCode === 40 ? row.nextElementSibling : row.previousElementSibling; 2609 | if (!next && event.keyCode === 40 && 2610 | row === showMoreResultsRow) { 2611 | var previousRow = row.previousElementSibling; 2612 | console.assert(!next, 'showMoreResultsRow should be the last row'); 2613 | document.getElementById('show-more-results-button').click(); 2614 | next = previousRow.nextElementSibling; 2615 | console.assert(next, 'showMoreResultsRow should have a new sibling'); 2616 | } 2617 | if (next) { 2618 | next.focus(); 2619 | if (event.shiftKey) { 2620 | next.cmApi.toggleHighlight(row.cmApi.isHighlighted()); 2621 | } 2622 | } 2623 | break; 2624 | case 46: // Delete 2625 | deleteThisRowCookie(); 2626 | break; 2627 | default: 2628 | return; 2629 | } 2630 | event.preventDefault(); 2631 | }; 2632 | 2633 | function handlePageUpOrDown(event) { 2634 | var forwards = event.keyCode === 34; // Page down 2635 | 2636 | var bottomOffset = document.getElementById('footer-controls').getBoundingClientRect().top; 2637 | var minimumVisibleRowHeight = document.querySelector('#result thead > tr').offsetHeight || 1; 2638 | var scrollDelta = bottomOffset - minimumVisibleRowHeight * 2; 2639 | if (scrollDelta < 0) { 2640 | console.warn('Page height is too narrow, defaulting to default Page up/down handler'); 2641 | return; 2642 | } 2643 | 2644 | if (!forwards) { 2645 | scrollDelta *= -1; 2646 | } 2647 | 2648 | document.documentElement.scrollTop += scrollDelta; 2649 | var visibleCookieRows = getVisibleCookieRows(true); 2650 | var nextRow = visibleCookieRows[forwards ? visibleCookieRows.length - 1 : 0]; 2651 | if (nextRow) { 2652 | jumpRange(event, nextRow); 2653 | event.preventDefault(); 2654 | } 2655 | } 2656 | 2657 | function jumpRange(event, nextRow) { 2658 | var currentRow = event.currentTarget; 2659 | var forwards = currentRow.rowIndex < nextRow.rowIndex; 2660 | if (event.shiftKey) { 2661 | var forceHighlight = currentRow.cmApi.isHighlighted(); 2662 | for (var row = currentRow; row != nextRow; row = forwards ? row.nextElementSibling : row.previousElementSibling) { 2663 | row.cmApi.toggleHighlight(forceHighlight); 2664 | } 2665 | } 2666 | nextRow.focus(); 2667 | } 2668 | 2669 | function deleteThisRowCookie() { 2670 | var msg = 'Do you really want to delete the currently focused cookie?'; 2671 | msg += '\nTo delete all selected cookies (instead of the currently focused cookie),' + 2672 | ' use the "Remove selected" button at the bottom.'; 2673 | if (confirmOnce('DELETE_THIS_ROW', msg)) { 2674 | row.cmApi.deleteCookie().then(function(error) { 2675 | if (error) { 2676 | alert('Failed to delete cookie:\n' + error); 2677 | } else { 2678 | updateButtonView(); 2679 | } 2680 | }); 2681 | } 2682 | } 2683 | } 2684 | 2685 | var _delayedMultiSelectShower; 2686 | var _delayedMultiSelectHider; 2687 | function onMouseUpAfterTextSelection(event) { 2688 | // The following line should probably be kept in sync with hideMultiSelectionToolOnMousedown. 2689 | if (event.button !== 0 || event.target.closest('#multi-selection-tool')) return; 2690 | 2691 | clearTimeout(_delayedMultiSelectShower); 2692 | // Wait a short timeout to allow the selection change to propagate, if needed. 2693 | // Also to not immediately disturb the user when they release the mouse. 2694 | _delayedMultiSelectShower = setTimeout(onClickAfterTextSelection, 200, event); 2695 | } 2696 | function onClickAfterTextSelection(event) { 2697 | if (isEmptyTextSelection()) { 2698 | hideMultiSelectionTool(); 2699 | return; 2700 | } 2701 | var tbody = document.getElementById('result').tBodies[0]; 2702 | if (!tbody || !tbody.contains(event.target)) { 2703 | // Did not click inside the result table. 2704 | hideMultiSelectionTool(); 2705 | return; 2706 | } 2707 | function getRow(node) { 2708 | if (node.nodeType === 3) node = node.parentNode; 2709 | if (node.nodeType === 1) { 2710 | node = node.closest('tr'); 2711 | if (node && node.parentNode === tbody) return node; 2712 | } 2713 | return null; 2714 | } 2715 | var sel = window.getSelection(); 2716 | var rows = new Set(); 2717 | for (var i = 0; i < sel.rangeCount; ++i) { 2718 | var range = sel.getRangeAt(i); 2719 | var row = getRow(range.commonAncestorContainer); 2720 | if (row) { 2721 | // Range spans one row. E.g. Firefox puts each row in a separate row. 2722 | rows.add(row); 2723 | continue; 2724 | } 2725 | var rowStart = getRow(range.startContainer); 2726 | var rowEnd = getRow(range.endContainer); 2727 | if (!rowStart || !rowEnd || rowStart === rowEnd) { 2728 | // At most one row. 2729 | row = rowStart || rowEnd; 2730 | if (row) rows.add(row); 2731 | continue; 2732 | } 2733 | // Multiple rows. 2734 | if (rowStart.rowIndex > rowEnd.rowIndex) { 2735 | [rowStart, rowEnd] = [rowEnd, rowStart]; 2736 | } 2737 | for (row = rowStart; row !== rowEnd; row = row.nextElementSibling) { 2738 | rows.add(row); 2739 | } 2740 | rows.add(rowEnd); 2741 | } 2742 | if (rows.size < 2) { 2743 | hideMultiSelectionTool(); 2744 | return; 2745 | } 2746 | 2747 | // We have at least two rows. Ask whether the user wants to highlight both rows. 2748 | 2749 | setButtonCount('multi-selection-select', rows.size); 2750 | document.getElementById('multi-selection-select').onclick = function() { 2751 | rows.forEach(function(row) { 2752 | row.cmApi.toggleHighlight(true); 2753 | }); 2754 | updateButtonView(); 2755 | }; 2756 | document.getElementById('multi-selection-invert').onclick = function() { 2757 | rows.forEach(function(row) { 2758 | row.cmApi.toggleHighlight(); 2759 | }); 2760 | updateButtonView(); 2761 | }; 2762 | 2763 | // Initially we have a mouseleave listener to easily and automatically 2764 | // hide this tool. Do not show the Cancel button to not encourage an 2765 | // unnecessary click inside the tool (which would clear the selection). 2766 | document.getElementById('multi-selection-cancel').hidden = true; 2767 | 2768 | cancelHideMultiSelectionTool(); 2769 | var multiSelectionTool = document.getElementById('multi-selection-tool'); 2770 | 2771 | // TODO: Try to not fall off the screen. 2772 | // Add +5 to ensure that the user can move the mouse without immediately triggering mouseleave. 2773 | var x = event.clientX + 5; 2774 | var y = event.clientY + 5; 2775 | multiSelectionTool.style.transform = 'translate(' + x + 'px,' + y + 'px)'; 2776 | multiSelectionTool.hidden = false; 2777 | 2778 | // The tool can only be shown with mouse interaction, so it makes sense to only allow the 2779 | // tool to be hidden via other mouse events (opposed to keyboard events). 2780 | multiSelectionTool.addEventListener('mouseenter', cancelHideMultiSelectionTool); 2781 | multiSelectionTool.addEventListener('mouseleave', hideMultiSelectionToolAfterDelay); 2782 | document.documentElement.addEventListener('mousedown', hideMultiSelectionToolOnMousedown); 2783 | } 2784 | function hideMultiSelectionTool() { 2785 | document.getElementById('multi-selection-select').onclick = null; 2786 | document.getElementById('multi-selection-invert').onclick = null; 2787 | document.getElementById('multi-selection-cancel').onclick = null; 2788 | var multiSelectionTool = document.getElementById('multi-selection-tool'); 2789 | multiSelectionTool.hidden = true; 2790 | multiSelectionTool.removeEventListener('mouseenter', cancelHideMultiSelectionTool); 2791 | multiSelectionTool.removeEventListener('mouseleave', hideMultiSelectionToolAfterDelay); 2792 | document.documentElement.removeEventListener('mousedown', hideMultiSelectionToolOnMousedown); 2793 | 2794 | if (isEmptyTextSelection()) { 2795 | document.documentElement.removeEventListener('mouseup', onMouseUpAfterTextSelection); 2796 | } 2797 | } 2798 | function cancelHideMultiSelectionTool() { 2799 | clearTimeout(_delayedMultiSelectHider); 2800 | } 2801 | function hideMultiSelectionToolAfterDelay() { 2802 | clearTimeout(_delayedMultiSelectHider); 2803 | // Almost a second before it disappears. Should be more than sufficient. 2804 | _delayedMultiSelectHider = setTimeout(hideMultiSelectionTool, 750); 2805 | } 2806 | function hideMultiSelectionToolOnMousedown(event) { 2807 | // The following line should probably be kept in sync with onMouseUpAfterTextSelection. 2808 | if (event.button !== 0) return; 2809 | var multiSelectionTool = event.target.closest('#multi-selection-tool'); 2810 | if (multiSelectionTool) { 2811 | // When the user is interacting with the multi-selection tool, 2812 | // they probably don't care about the text selection itself. 2813 | var selection = window.getSelection(); 2814 | if (selection) { 2815 | selection.removeAllRanges(); 2816 | } 2817 | // Since the selection has been removed, reset all listeners and 2818 | // pending tasks that assume the existence of a selection. 2819 | clearTimeout(_delayedMultiSelectShower); 2820 | document.documentElement.removeEventListener('mouseup', onMouseUpAfterTextSelection); 2821 | 2822 | // Require the user to take an explicit user action to close the tool. 2823 | multiSelectionTool.removeEventListener('mouseleave', hideMultiSelectionToolAfterDelay); 2824 | document.getElementById('multi-selection-cancel').hidden = false; 2825 | document.getElementById('multi-selection-cancel').onclick = hideMultiSelectionTool; 2826 | return; 2827 | } 2828 | hideMultiSelectionTool(); 2829 | } 2830 | 2831 | function isEmptyTextSelection() { 2832 | var sel = window.getSelection(); 2833 | return !sel || sel.anchorNode === sel.focusNode && sel.anchorOffset === sel.focusOffset; 2834 | } 2835 | 2836 | function cookieToUrl(cookie) { 2837 | var url = ''; 2838 | url += cookie.secure ? 'https' : 'http'; 2839 | url += '://'; 2840 | if (cookie.domain.charAt(0) === '.') { 2841 | url += cookie.domain.slice(1); 2842 | } else if (cookie.domain === '') { 2843 | // http(s) cookies must have a domain. The only supported non-http scheme is file: 2844 | // https://searchfox.org/mozilla-central/rev/2c61e59a48af27c100c2dd2756b5efad573dbc71/dom/base/Document.h#2496-2508 2845 | // ... and that always has a plain string as domain. 2846 | // NOTE: Even though we have the permissions etc to set the cookie, we cannot edit them yet due to 2847 | // https://bugzilla.mozilla.org/show_bug.cgi?id=1473412#c12 2848 | // Note that Chrome does not support cookies on file:-URLs. 2849 | url = 'file://'; 2850 | } else { 2851 | url += cookie.domain; 2852 | } 2853 | url += cookie.path; 2854 | return url; 2855 | } 2856 | 2857 | // Convert the cookie.partitionKey to a canonical string that can be shown 2858 | // to the user (and used for comparisons). 2859 | function cookieToPartitionKeyString(cookie) { 2860 | var partitionKey = cookie.partitionKey || {}; 2861 | // partitionKey currently supports the topLevelSite key only. 2862 | // TODO: If partitionKey ever supports other properties, then we'd need 2863 | // to account for that. See checkPartitionKeySupport. 2864 | return partitionKey.topLevelSite || ""; 2865 | } 2866 | // Given a topLevelSite string, return an equivalent value that can be used 2867 | // as an input to all cookies methods. 2868 | function partitionKeyFromString(topLevelSite) { 2869 | var partitionKey; 2870 | if (topLevelSite) { 2871 | partitionKey = { topLevelSite }; 2872 | } 2873 | // Note: topLevelSite void or empty string means unpartitioned. 2874 | // Let partitionKey be void instead of an object with void/empty partitionKey, 2875 | // because in cookies.getAll an empty object means "return all cookies, including partitioned" instead of "unpartitioned cookies". 2876 | return partitionKey; 2877 | } 2878 | 2879 | /** 2880 | * @param cookie chrome.cookies.Cookie type extended with "url" key. 2881 | * @returns {object} A parameter for the cookies.set API. 2882 | */ 2883 | function getDetailsForCookiesSetAPI(cookie) { 2884 | var details = {}; 2885 | details.url = cookie.url; 2886 | details.name = cookie.name; 2887 | details.value = cookie.value; 2888 | if (!cookie.hostOnly) { 2889 | details.domain = cookie.domain; 2890 | } 2891 | details.path = cookie.path; 2892 | details.secure = cookie.secure; 2893 | details.httpOnly = cookie.httpOnly; 2894 | if (cookie.sameSite) details.sameSite = cookie.sameSite; 2895 | if (!cookie.session) details.expirationDate = cookie.expirationDate; 2896 | 2897 | var firstPartyDomainInCookie = 'firstPartyDomain' in cookie; 2898 | console.assert(firstPartyDomainInCookie === gFirstPartyDomainSupported); 2899 | if (firstPartyDomainInCookie) details.firstPartyDomain = cookie.firstPartyDomain; 2900 | 2901 | if (cookie.partitionKey) details.partitionKey = cookie.partitionKey; 2902 | 2903 | details.storeId = cookie.storeId; 2904 | return details; 2905 | } 2906 | 2907 | function urlWithoutPort(url) { 2908 | // Strip port to work around https://bugzil.la/1417828 2909 | return url && url.replace(/^(https?:\/\/[^\/]+):\d+(\/|$)/i, '$1$2'); 2910 | } 2911 | 2912 | // Checks whether the given cookies would be written to the same cookie slot. 2913 | // The given cookies must be of the type cookies.Cookie. 2914 | function isSameCookieKey(cookieA, cookieB) { 2915 | // Cookies are keyed by (domain, path, name) + origin attributes. 2916 | // (where the domain starts with a dot iff it is a domain cookie (opposed to host-only)). 2917 | // Origin attributes currently include: 2918 | // - userContextId and privateBrowsingId -> storeId 2919 | // - firstPartyDomain -> firstPartyDomain (Firefox 59+) 2920 | if (cookieA.name !== cookieB.name || 2921 | cookieA.domain !== cookieB.domain || 2922 | cookieA.path !== cookieB.path || 2923 | cookieA.firstPartyDomain !== cookieB.firstPartyDomain || 2924 | cookieToPartitionKeyString(cookieA) !== cookieToPartitionKeyString(cookieB) || 2925 | cookieA.storeId !== cookieB.storeId) { 2926 | return false; 2927 | } 2928 | return true; 2929 | } 2930 | -------------------------------------------------------------------------------- /datetime-local-polyfill.js: -------------------------------------------------------------------------------- 1 | /* jshint browser:true */ 2 | // A polyfill for input[type=datetime-local]. 3 | // Designed to be compatible with Chrome and Firefox, Desktop and mobile. 4 | (function() { 5 | 'use strict'; 6 | function hasNativeDatepicker() { 7 | // Chrome supports input[type=datetime-local] since way before version 30. 8 | // Firefox for Android supports it too, at least in 53, but probably already 9 | // way back to 40 and possibly earlier. 10 | var input = document.createElement('input'); 11 | input.type = 'datetime-local'; 12 | return input.type === 'datetime-local'; 13 | } 14 | if (hasNativeDatepicker()) { 15 | return; 16 | } 17 | 18 | document.body.addEventListener('focus', function(e) { 19 | var input = e.target; 20 | if (input.tagName.toUpperCase() === 'INPUT' && 21 | input.getAttribute('type') === 'datetime-local') { 22 | showDatePickerForInput(input); 23 | } 24 | }, true); 25 | 26 | var lastDatePicker = null; 27 | var dialogId = Math.floor(Date.now() * Math.random()); 28 | 29 | // E.g. 2017-04-05T16:18 30 | function showDatePickerForInput(input) { 31 | var container = document.createElement('div'); 32 | container.id = 'datetime-local-polyfill-' + (++dialogId); 33 | container.appendChild(document.createElement('style')).textContent = createStyleSheetText(container.id); 34 | var d = document.createElement('form'); 35 | d.innerHTML = 36 | '
' + 37 | ' - ' + 38 | ' - ' + 39 | ' , ' + 40 | ' : ' + 41 | ' : ' + 42 | '' + 43 | '
' + 44 | '
' + 45 | ' ' + 46 | ' ' + 47 | '' + 48 | '' + 49 | '
'; 50 | var dayInput = d.querySelector('.datetime-local-day'); 51 | var monthInput = d.querySelector('.datetime-local-month'); 52 | var yearInput = d.querySelector('.datetime-local-year'); 53 | var hourInput = d.querySelector('.datetime-local-hour'); 54 | var minuteInput = d.querySelector('.datetime-local-minute'); 55 | var secondInput = d.querySelector('.datetime-local-second'); 56 | 57 | 58 | d.querySelector('.datetime-local-cancel').onclick = function(event) { 59 | event.preventDefault(); 60 | hideDatePicker(); 61 | }; 62 | d.querySelector('.datetime-local-now').onclick = function(event) { 63 | event.preventDefault(); 64 | useDateForPicker(new Date()); 65 | }; 66 | d.querySelector('.datetime-local-clear').onclick = function(event) { 67 | event.preventDefault(); 68 | setInputValue(''); 69 | hideDatePicker(); 70 | }; 71 | d.onsubmit = function(event) { 72 | event.preventDefault(); 73 | setInputValue(uiDateAsValue()); 74 | hideDatePicker(); 75 | }; 76 | 77 | getMonthLabels().forEach(function(monthLabel, i) { 78 | monthInput.appendChild(new Option(monthLabel, i + 1)); 79 | }); 80 | container.appendChild(d); 81 | document.body.appendChild(container); 82 | 83 | hideDatePicker(); 84 | lastDatePicker = container; 85 | dayInput.focus(); 86 | useDateForPicker(new Date(input.value)); 87 | window.addEventListener('keydown', onKeyDown, true); 88 | 89 | function setInputValue(value) { 90 | if (input.value !== value) { 91 | input.value = value; 92 | input.dispatchEvent(new CustomEvent('change', { 93 | bubbles: true, 94 | })); 95 | } 96 | } 97 | 98 | function useDateForPicker(date) { 99 | if (!isNaN(date.getTime())) { 100 | dayInput.value = date.getDate(); 101 | monthInput.value = date.getMonth() + 1; 102 | yearInput.value = date.getFullYear(); 103 | hourInput.value = date.getHours(); 104 | minuteInput.value = date.getMinutes(); 105 | secondInput.value = date.getSeconds(); 106 | } 107 | } 108 | 109 | function uiDateAsValue() { 110 | return yearInput.value.padStart(4, '0') + '-' + 111 | monthInput.value.padStart(2, '0') + '-' + 112 | dayInput.value.padStart(2, '0') + 'T' + 113 | hourInput.value.padStart(2, '0') + ':' + 114 | minuteInput.value.padStart(2, '0') + ':' + 115 | secondInput.value.padStart(2, '0'); 116 | } 117 | } 118 | 119 | function onKeyDown(event) { 120 | if (event.keyCode === 27) { // Esc 121 | hideDatePicker(); 122 | } 123 | } 124 | 125 | function hideDatePicker() { 126 | window.removeEventListener('keydown', onKeyDown, true); 127 | if (lastDatePicker) { 128 | lastDatePicker.remove(); 129 | lastDatePicker = null; 130 | } 131 | } 132 | 133 | function createStyleSheetText(id) { 134 | var styleSheetText = ''; 135 | function addScopedStyle(selector, cssText) { 136 | selector = selector.replace(/^|,/g, '$&#' + id + ' '); 137 | styleSheetText += selector + '{' + cssText + '}'; 138 | } 139 | addScopedStyle('', 140 | 'position:fixed;top:0;left:0;right:0;bottom:0;' + 141 | 'display:flex;align-items:center;justify-content:center;' + 142 | 'background:rgba(0,0,0,.5);'); 143 | addScopedStyle('div', 144 | 'margin:2rem;'); 145 | addScopedStyle('.datetime-local-buttonset', 146 | 'display:flex;justify-content:space-between;'); 147 | addScopedStyle('form', 148 | 'background:rgba(0,0,0,0.9);color:#EEE;'); 149 | addScopedStyle('input, select', 150 | 'font-size:1rem;' + 151 | 'line-height:2em;' + 152 | 'text-align:center;'); 153 | addScopedStyle('.datetime-local-day, .datetime-local-hour, .datetime-local-minute, .datetime-local-second', 154 | 'width:6ch;'); 155 | addScopedStyle('.datetime-local-year', 156 | 'width:8ch;'); 157 | return styleSheetText; 158 | } 159 | 160 | function getMonthLabels() { 161 | try { 162 | var months = []; 163 | var dtf = new Intl.DateTimeFormat(navigator.language, {month:'long'}); 164 | var d = new Date(2000, 1, 1); 165 | for (var i = 0; i < 12; ++i) { 166 | d.setMonth(i); 167 | months[i] = dtf.formatToParts(d).filter(function(part) { return part.type === 'month'; })[0].value; 168 | } 169 | return months; 170 | } catch (e) { 171 | return [ 172 | 'January', 173 | 'February', 174 | 'March', 175 | 'April', 176 | 'May', 177 | 'June', 178 | 'July', 179 | 'August', 180 | 'September', 181 | 'October', 182 | 'November', 183 | 'December', 184 | ]; 185 | } 186 | } 187 | })(); 188 | -------------------------------------------------------------------------------- /fake-api-snippet.js: -------------------------------------------------------------------------------- 1 | /* To test the cookie manager UI as a regular web page without extension environment. 2 | * Put the following in cookie-manager.html 3 | 4 | 5 | 6 | */ 7 | /* jshint esversion:6, browser:true, devel:true */ 8 | /* globals compileDomainFilter, compileUrlFilter */ // from cookie-manager.js 9 | 'use strict'; 10 | 11 | var _FAKE_INITIAL_COOKIE_COUNT = 1234; 12 | var _FAKE_FPD_SUPPORT = 13 | location.href.includes('fpd=0') ? false : 14 | location.href.includes('fpd=1') ? true : 15 | true; // Default to true for now. 16 | 17 | var _fakeCookies = (() => { 18 | var cookies = []; 19 | var value = '#'; 20 | for (var i = 0; i < _FAKE_INITIAL_COOKIE_COUNT; ++i) { 21 | var cookie = { 22 | name: 'cook' + i, 23 | domain: 'num' + (i % 10) + '.example.com', 24 | path: '/', 25 | value: (value += ' & value of ' + i), 26 | storeId: (i % 12) ? 'firefox-default' : 'firefox-private', 27 | hostOnly: (i % 9) === 0, 28 | httpOnly: (i % 4) === 1, 29 | sameSite: (i % 17) === 0 ? 'lax' : (i % 17) === 1 ? 'strict' : 'no_restriction', 30 | secure: (i % 6) === 0, 31 | }; 32 | if (_FAKE_FPD_SUPPORT) { 33 | cookie.firstPartyDomain = (i % 10) ? 'num.' + (i % 5) + '.example.com' : ''; 34 | } 35 | if (i % 2) { 36 | cookie.session = true; 37 | } else { 38 | cookie.expirationDate = Math.floor((Date.now() + 15000 * (i - 10)) / 1000); 39 | } 40 | if (!cookie.hostOnly) { 41 | cookie.domain = '.' + cookie.domain; 42 | } 43 | 44 | cookies.push(cookie); 45 | } 46 | return cookies; 47 | })(); 48 | 49 | function _getFakeCookies(details) { 50 | var {url, name, domain, path, secure, session, storeId, firstPartyDomain} = details; 51 | var matchesUrl = url && compileUrlFilter(new URL(url)); 52 | var matchesDomain = domain && compileDomainFilter(domain); 53 | if (!_FAKE_FPD_SUPPORT && 'firstPartyDomain' in details) { 54 | throw new Error("firstPartyDomain is not supported because _FAKE_FPD_SUPPORT is false"); 55 | } 56 | return _fakeCookies.filter(function(cookie) { 57 | // Logic copied from cookie-manager-firefox.js and extended. 58 | if (matchesUrl && !matchesUrl(cookie)) 59 | return false; 60 | if (name && cookie.name !== name) 61 | return false; 62 | if (path && cookie.path !== path) 63 | return false; 64 | if (typeof secure === 'boolean' && cookie.secure !== secure) 65 | return false; 66 | if (typeof session === 'boolean' && cookie.session !== session) 67 | return false; 68 | if (storeId && cookie.storeId !== storeId) 69 | return false; 70 | if (matchesDomain && !matchesDomain(cookie)) 71 | return false; 72 | if (firstPartyDomain != null && cookie.firstPartyDomain !== firstPartyDomain) 73 | return false; 74 | return true; 75 | }); 76 | } 77 | 78 | if (window.chrome && window.chrome.cookies) { 79 | throw new Error('Do not load fake-api-snippet.js in an extension!'); 80 | } 81 | 82 | if (_FAKE_FPD_SUPPORT) { 83 | window.addEventListener("load", function() { 84 | console.assert(typeof checkFirstPartyIsolationStatus === 'function', 85 | 'checkFirstPartyIsolationStatus should be defined by cookie-manager.js'); 86 | window.checkFirstPartyIsolationStatus = function() { 87 | /* globals gFirstPartyIsolationEnabled:true */ 88 | /* globals gFirstPartyDomainSupported:true */ 89 | gFirstPartyIsolationEnabled = true; 90 | gFirstPartyDomainSupported = true; 91 | return Promise.resolve(); 92 | }; 93 | }); 94 | } 95 | 96 | // The bare minimum of chrome.* APIs that are used by cookie-manager.js 97 | window.chrome = { 98 | extension: { 99 | isAllowedIncognitoAccess(cb) { 100 | cb(true); 101 | }, 102 | }, 103 | storage: { 104 | local: { 105 | get(mixed, cb) { 106 | var keys = 107 | typeof mixed === 'string' ? [mixed] : 108 | Array.isArray(mixed) ? mixed : 109 | mixed === null ? Object.keys(sessionStorage) : 110 | Object.keys(mixed); 111 | function defaultItem(key) { 112 | try { 113 | if (typeof mixed === 'object' && mixed && key in mixed) 114 | return JSON.parse(JSON.stringify(mixed[key])); 115 | } catch (e) {} 116 | } 117 | var items = {}; 118 | keys.forEach(function(key) { 119 | try { 120 | var value = sessionStorage.getItem(key); 121 | items[key] = value === null ? defaultItem(key) : JSON.parse(value); 122 | } catch (e) { 123 | items[key] = defaultItem(key); 124 | console.error('fake storage.local.get failed to parse ' + key + ' : ' + e); 125 | } 126 | }); 127 | cb(items); 128 | }, 129 | set(items, cb) { 130 | Object.keys(items).forEach(function(key) { 131 | sessionStorage[key] = JSON.stringify(items[key]); 132 | }); 133 | if (cb) cb(); 134 | }, 135 | }, 136 | }, 137 | runtime: { 138 | // Used for chrome.runtime.lastError 139 | 140 | getManifest() { 141 | try { 142 | var x = new XMLHttpRequest(); 143 | x.open('get', 'manifest.json', false); 144 | x.overrideMimeType('application/json'); 145 | x.send(); 146 | return JSON.parse(x.responseText); 147 | } catch (e) { 148 | return { 149 | version: '', 150 | }; 151 | } 152 | }, 153 | }, 154 | cookies: { 155 | SameSiteStatus: {LAX: "lax", NO_RESTRICTION: "no_restriction", STRICT: "strict"}, 156 | getAllCookieStores(cb) { 157 | cb([ 158 | {id:'firefox-default'}, 159 | {id:'firefox-private'} 160 | ]); 161 | }, 162 | getAll(details, cb) { 163 | var cookies = _getFakeCookies(details); 164 | cookies = cookies.map(cookie => Object.assign({}, cookie)); 165 | cb(cookies); 166 | }, 167 | set(details, cb) { 168 | var cookie = _getFakeCookies(details)[0] || null; 169 | var i = _fakeCookies.indexOf(cookie); 170 | console.assert(cookie === null || i >= 0, '_fakeCookies should have the cookie.'); 171 | if ('expirationDate' in details && details.expirationDate < Date.now() / 1000) { 172 | if (cookie) _fakeCookies.splice(i, 1); 173 | cb(null); 174 | return; 175 | } 176 | // Logic copied from cookie-manager.js 177 | var newCookie = Object.assign({}, details); 178 | newCookie.hostOnly = !('domain' in newCookie); 179 | if (newCookie.hostOnly) { 180 | newCookie.domain = new URL(details.url).hostname; 181 | } else if (!newCookie.domain.startsWith('.')) { 182 | newCookie.domain = '.' + newCookie.domain; 183 | } 184 | if (!('path' in newCookie)) { 185 | newCookie.path = '/'; 186 | } 187 | if (!('expirationDate' in newCookie)) { 188 | newCookie.session = true; 189 | } 190 | if (cookie) { 191 | _fakeCookies[i] = newCookie; 192 | } else { 193 | _fakeCookies.push(newCookie); 194 | } 195 | cb(Object.assign({}, newCookie)); 196 | }, 197 | } 198 | }; 199 | -------------------------------------------------------------------------------- /icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/cookie-manager/f74a042eee9d0563ed199d51e4d9f048be6d3190/icons/16.png -------------------------------------------------------------------------------- /icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/cookie-manager/f74a042eee9d0563ed199d51e4d9f048be6d3190/icons/32.png -------------------------------------------------------------------------------- /icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/cookie-manager/f74a042eee9d0563ed199d51e4d9f048be6d3190/icons/48.png -------------------------------------------------------------------------------- /icons/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/cookie-manager/f74a042eee9d0563ed199d51e4d9f048be6d3190/icons/64.png -------------------------------------------------------------------------------- /icons/96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/cookie-manager/f74a042eee9d0563ed199d51e4d9f048be6d3190/icons/96.png -------------------------------------------------------------------------------- /icons/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 40 | 42 | 43 | 45 | image/svg+xml 46 | 48 | 49 | 50 | 51 | 52 | 66 | 72 | C ooki 97 | 98 | 104 | C ooki 127 | 128 | 129 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Cookie Manager", 3 | "description": "An efficient cookie manager. View, edit, delete and search for cookies. Designed for privacy aware users. Automatically opens the Cookie Manager upon extension startup (this can be configured via the UI).", 4 | "version": "1.8", 5 | "manifest_version": 3, 6 | "permissions": [ 7 | "activeTab", 8 | "cookies", 9 | "storage" 10 | ], 11 | "host_permissions": [ 12 | "*://*/*" 13 | ], 14 | "options_ui": { 15 | "page": "cookie-manager.html", 16 | "open_in_tab": true 17 | }, 18 | "background": { 19 | "service_worker": "background.js" 20 | }, 21 | "icons": { 22 | "48": "icons/48.png", 23 | "96": "icons/96.png" 24 | }, 25 | "action": { 26 | "default_icon": { 27 | "48": "icons/48.png", 28 | "96": "icons/96.png" 29 | }, 30 | "default_popup": "popup.html", 31 | "default_title": "Open Cookie Manager" 32 | }, 33 | "minimum_chrome_version": "88" 34 | } 35 | -------------------------------------------------------------------------------- /manifest_firefox.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Cookie Manager", 3 | "description": "An efficient cookie manager. View, edit, delete and search for cookies. Designed for privacy aware users. Automatically opens the Cookie Manager upon extension startup (this can be configured via the UI).", 4 | "version": "1.8", 5 | "manifest_version": 2, 6 | "permissions": [ 7 | "activeTab", 8 | "contextualIdentities", 9 | "cookies", 10 | "storage", 11 | "file://*/*", 12 | "*://*/*" 13 | ], 14 | "options_ui": { 15 | "page": "cookie-manager.html", 16 | "open_in_tab": true 17 | }, 18 | "background": { 19 | "persistent": false, 20 | "scripts": ["background.js"] 21 | }, 22 | "icons": { 23 | "48": "icons/48.png", 24 | "96": "icons/96.png" 25 | }, 26 | "browser_action": { 27 | "default_icon": { 28 | "48": "icons/48.png", 29 | "96": "icons/96.png" 30 | }, 31 | "default_popup": "popup.html", 32 | "default_title": "Open Cookie Manager" 33 | }, 34 | "browser_specific_settings": { 35 | "gecko": { 36 | "strict_min_version": "69.0", 37 | "id": "cookie-manager@robwu.nl" 38 | }, 39 | "gecko_android": {} 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /options.js: -------------------------------------------------------------------------------- 1 | /* globals chrome */ 2 | /* jshint browser: true */ 3 | 'use strict'; 4 | 5 | var autostart = document.getElementById('autostart'); 6 | autostart.onchange = function() { 7 | var items = { 8 | autostart: autostart.checked, 9 | }; 10 | if (!chrome.storage) { 11 | // Firefox 51- 12 | chrome.storage.local.set(items); 13 | return; 14 | } 15 | chrome.storage.sync.set(items, function() { 16 | if (chrome.runtime.lastError) { 17 | // Can happen in Firefox 52. 18 | chrome.storage.local.set(items); 19 | } 20 | }); 21 | }; 22 | 23 | if (!chrome.storage.sync) { 24 | // Firefox 51-. 25 | chrome.storage.local.get('autostart', onGotStorage); 26 | } else { 27 | chrome.storage.sync.get('autostart', function(items) { 28 | if (items) { 29 | onGotStorage(items); 30 | } else { // Can happen in Firefox 52. 31 | chrome.storage.local.get('autostart', onGotStorage); 32 | } 33 | }); 34 | } 35 | 36 | function onGotStorage(items) { 37 | autostart.checked = !items || items.autostart !== false; 38 | } 39 | -------------------------------------------------------------------------------- /popup.css: -------------------------------------------------------------------------------- 1 | button { 2 | display: block; 3 | padding: 10px; 4 | width: 100%; 5 | } 6 | -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /popup.js: -------------------------------------------------------------------------------- 1 | /* globals chrome */ 2 | /* globals URLSearchParams */ 3 | /* jshint esversion: 6 */ 4 | /* jshint browser: true */ 5 | 'use strict'; 6 | 7 | var activeTab; 8 | 9 | document.getElementById('open-cm-any').onclick = openAnyCookieManager; 10 | document.getElementById('open-cm-tab-top').onclick = openTopTabCookieManager; 11 | document.getElementById('open-cm-tab-top').disabled = true; 12 | 13 | function getActiveTab(callback) { 14 | chrome.tabs.query({ 15 | lastFocusedWindow: true, 16 | active: true, 17 | }, function(tabs) { 18 | callback(tabs && tabs[0]); 19 | }); 20 | } 21 | getActiveTab(function(tab) { 22 | if (tab && tab.url && tab.url.startsWith('http')) { 23 | activeTab = tab; 24 | document.getElementById('open-cm-tab-top').disabled = false; 25 | document.getElementById('open-cm-tab-top').title = 26 | 'View all cookies for ' + tab.url; 27 | } else { 28 | document.getElementById('open-cm-tab-top').remove(); 29 | } 30 | }); 31 | 32 | function openAnyCookieManager() { 33 | openOrActivateCookieManager(''); 34 | } 35 | 36 | function openTopTabCookieManager() { 37 | var params = new URLSearchParams(); 38 | params.append('url', activeTab.url); 39 | params.append('storeId', storeIdForTab(activeTab)); 40 | openOrActivateCookieManager('?' + params.toString()); 41 | } 42 | 43 | function openOrActivateCookieManager(query) { 44 | chrome.tabs.query({ 45 | lastFocusedWindow: true, 46 | active: true, 47 | }, function(tabs) { 48 | openOrActivateCookieManager_(tabs && tabs[0], query); 49 | }); 50 | } 51 | 52 | function openOrActivateCookieManager_(currentTab, query) { 53 | // Note: Using chrome.extension.getViews is a very good way to find 54 | // extension tabs. chrome.tabs.query cannot be used here, because 55 | // before Firefox 56, extension URLs cannot be filtered 56 | // (https://bugzil.la/1269341). 57 | Promise.all( 58 | chrome.extension.getViews({ type: 'tab' }) 59 | .map(function(win) { 60 | return new Promise(function(resolve) { 61 | if (win.location.pathname === '/cookie-manager.html' && 62 | isSameQuery(win.location.search, query)) { 63 | win.chrome.tabs.getCurrent(function(tab) { 64 | resolve(tab); 65 | }); 66 | } else { 67 | resolve(); 68 | } 69 | }).catch(function() { 70 | // Never reject the promise. 71 | }); 72 | }) 73 | ).then(function(tabs) { 74 | // Exclude missing tabs and tabs from other windows. 75 | return tabs.filter(function(tab) { 76 | return tab && currentTab && tab.windowId === currentTab.windowId; 77 | }); 78 | }).then(function(tabs) { 79 | if (tabs.some(function(tab) { return tab.active; })) { 80 | // Current tab is already the cookie manager. 81 | return; 82 | } 83 | if (tabs.length) { 84 | // Focus the first cookie manager tab. 85 | chrome.tabs.update(tabs[0].id, { 86 | active: true, 87 | }); 88 | return; 89 | } 90 | var createProperties = { 91 | url: 'cookie-manager.html' + query, 92 | }; 93 | if (currentTab) { 94 | createProperties.windowId = currentTab.windowId; 95 | createProperties.index = currentTab.index + 1; 96 | } 97 | chrome.tabs.create(createProperties); 98 | }).then(function() { 99 | window.close(); 100 | }); 101 | } 102 | 103 | function isSameQuery(queryA, queryB) { 104 | return queryA.replace('?', '') === queryB.replace('?', ''); 105 | } 106 | 107 | function storeIdForTab(tab) { 108 | if (tab.cookieStoreId) { 109 | return tab.cookieStoreId; 110 | } 111 | // TODO: Do not hard-code the cookieStoreIds. 112 | if (typeof browser === 'undefined') { 113 | // Chrome 114 | return activeTab.incognito ? '1' : '0'; 115 | } 116 | // Firefox 117 | return activeTab.incognito ? 'firefox-private' : 'firefox-default'; 118 | } 119 | -------------------------------------------------------------------------------- /screenshots/cookie-manager-action-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/cookie-manager/f74a042eee9d0563ed199d51e4d9f048be6d3190/screenshots/cookie-manager-action-menu.png -------------------------------------------------------------------------------- /screenshots/cookie-manager-dark-theme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/cookie-manager/f74a042eee9d0563ed199d51e4d9f048be6d3190/screenshots/cookie-manager-dark-theme.png -------------------------------------------------------------------------------- /screenshots/cookie-manager-light-theme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/cookie-manager/f74a042eee9d0563ed199d51e4d9f048be6d3190/screenshots/cookie-manager-light-theme.png -------------------------------------------------------------------------------- /screenshots/open-cookie-manager-on-fennec.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rob--W/cookie-manager/f74a042eee9d0563ed199d51e4d9f048be6d3190/screenshots/open-cookie-manager-on-fennec.png --------------------------------------------------------------------------------