1/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- 2 * vim: sw=2 ts=2 sts=2 expandtab filetype=javascript 3 * This Source Code Form is subject to the terms of the Mozilla Public 4 * License, v. 2.0. If a copy of the MPL was not distributed with this 5 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 7var EXPORTED_SYMBOLS = ["PlacesBackups"]; 8 9const { XPCOMUtils } = ChromeUtils.import( 10 "resource://gre/modules/XPCOMUtils.jsm" 11); 12const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); 13 14XPCOMUtils.defineLazyModuleGetters(this, { 15 PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", 16 BookmarkJSONUtils: "resource://gre/modules/BookmarkJSONUtils.jsm", 17}); 18 19XPCOMUtils.defineLazyGetter( 20 this, 21 "filenamesRegex", 22 () => 23 /^bookmarks-([0-9-]+)(?:_([0-9]+)){0,1}(?:_([a-z0-9=+-]{24})){0,1}\.(json(lz4)?)$/i 24); 25 26async function limitBackups(aMaxBackups, backupFiles) { 27 if ( 28 typeof aMaxBackups == "number" && 29 aMaxBackups > -1 && 30 backupFiles.length >= aMaxBackups 31 ) { 32 let numberOfBackupsToDelete = backupFiles.length - aMaxBackups; 33 while (numberOfBackupsToDelete--) { 34 let oldestBackup = backupFiles.pop(); 35 await IOUtils.remove(oldestBackup); 36 } 37 } 38} 39 40/** 41 * Appends meta-data information to a given filename. 42 */ 43function appendMetaDataToFilename(aFilename, aMetaData) { 44 let matches = aFilename.match(filenamesRegex); 45 return ( 46 "bookmarks-" + 47 matches[1] + 48 "_" + 49 aMetaData.count + 50 "_" + 51 aMetaData.hash + 52 "." + 53 matches[4] 54 ); 55} 56 57/** 58 * Gets the hash from a backup filename. 59 * 60 * @return the extracted hash or null. 61 */ 62function getHashFromFilename(aFilename) { 63 let matches = aFilename.match(filenamesRegex); 64 if (matches && matches[3]) { 65 return matches[3]; 66 } 67 return null; 68} 69 70/** 71 * Given two filenames, checks if they contain the same date. 72 */ 73function isFilenameWithSameDate(aSourceName, aTargetName) { 74 let sourceMatches = aSourceName.match(filenamesRegex); 75 let targetMatches = aTargetName.match(filenamesRegex); 76 77 return sourceMatches && targetMatches && sourceMatches[1] == targetMatches[1]; 78} 79 80/** 81 * Given a filename, searches for another backup with the same date. 82 * 83 * @return path string or null. 84 */ 85function getBackupFileForSameDate(aFilename) { 86 return (async function() { 87 let backupFiles = await PlacesBackups.getBackupFiles(); 88 for (let backupFile of backupFiles) { 89 if (isFilenameWithSameDate(PathUtils.filename(backupFile), aFilename)) { 90 return backupFile; 91 } 92 } 93 return null; 94 })(); 95} 96 97var PlacesBackups = { 98 /** 99 * Matches the backup filename: 100 * 0: file name 101 * 1: date in form Y-m-d 102 * 2: bookmarks count 103 * 3: contents hash 104 * 4: file extension 105 */ 106 get filenamesRegex() { 107 return filenamesRegex; 108 }, 109 110 /** 111 * Gets backup folder asynchronously. 112 * @return {Promise} 113 * @resolve the folder (the folder string path). 114 */ 115 getBackupFolder: function PB_getBackupFolder() { 116 return (async () => { 117 if (this._backupFolder) { 118 return this._backupFolder; 119 } 120 let profileDir = await PathUtils.getProfileDir(); 121 let backupsDirPath = PathUtils.join( 122 profileDir, 123 this.profileRelativeFolderPath 124 ); 125 await IOUtils.makeDirectory(backupsDirPath); 126 return (this._backupFolder = backupsDirPath); 127 })(); 128 }, 129 130 get profileRelativeFolderPath() { 131 return "bookmarkbackups"; 132 }, 133 134 /** 135 * Cache current backups in a sorted (by date DESC) array. 136 * @return {Promise} 137 * @resolve a sorted array of string paths. 138 */ 139 getBackupFiles: function PB_getBackupFiles() { 140 return (async () => { 141 if (this._backupFiles) { 142 return this._backupFiles; 143 } 144 145 this._backupFiles = []; 146 147 let backupFolderPath = await this.getBackupFolder(); 148 let children = await IOUtils.getChildren(backupFolderPath); 149 let list = []; 150 for (const entry of children) { 151 // Since IOUtils I/O is serialized, we can safely remove .tmp files 152 // without risking to remove ongoing backups. 153 let filename = PathUtils.filename(entry); 154 if (filename.endsWith(".tmp")) { 155 list.push(IOUtils.remove(entry)); 156 continue; 157 } 158 159 if (filenamesRegex.test(filename)) { 160 // Remove bogus backups in future dates. 161 if (this.getDateForFile(entry) > new Date()) { 162 list.push(IOUtils.remove(entry)); 163 continue; 164 } 165 this._backupFiles.push(entry); 166 } 167 } 168 await Promise.all(list); 169 170 this._backupFiles.sort((a, b) => { 171 let aDate = this.getDateForFile(a); 172 let bDate = this.getDateForFile(b); 173 return bDate - aDate; 174 }); 175 176 return this._backupFiles; 177 })(); 178 }, 179 180 /** 181 * Invalidates the internal cache for testing purposes. 182 */ 183 invalidateCache() { 184 this._backupFiles = null; 185 }, 186 187 /** 188 * Generates a ISO date string (YYYY-MM-DD) from a Date object. 189 * 190 * @param dateObj 191 * The date object to parse. 192 * @return an ISO date string. 193 */ 194 toISODateString: function toISODateString(dateObj) { 195 if (!dateObj || dateObj.constructor.name != "Date" || !dateObj.getTime()) { 196 throw new Error("invalid date object"); 197 } 198 let padDate = val => ("0" + val).substr(-2, 2); 199 return [ 200 dateObj.getFullYear(), 201 padDate(dateObj.getMonth() + 1), 202 padDate(dateObj.getDate()), 203 ].join("-"); 204 }, 205 206 /** 207 * Creates a filename for bookmarks backup files. 208 * 209 * @param [optional] aDateObj 210 * Date object used to build the filename. 211 * Will use current date if empty. 212 * @param [optional] bool - aCompress 213 * Determines if file extension is json or jsonlz4 214 Default is json 215 * @return A bookmarks backup filename. 216 */ 217 getFilenameForDate: function PB_getFilenameForDate(aDateObj, aCompress) { 218 let dateObj = aDateObj || new Date(); 219 // Use YYYY-MM-DD (ISO 8601) as it doesn't contain illegal characters 220 // and makes the alphabetical order of multiple backup files more useful. 221 return ( 222 "bookmarks-" + 223 PlacesBackups.toISODateString(dateObj) + 224 ".json" + 225 (aCompress ? "lz4" : "") 226 ); 227 }, 228 229 /** 230 * Creates a Date object from a backup file. The date is the backup 231 * creation date. 232 * 233 * @param {Sring} aBackupFile The path of the backup. 234 * @return {Date} A Date object for the backup's creation time. 235 */ 236 getDateForFile: function PB_getDateForFile(aBackupFile) { 237 let filename = PathUtils.filename(aBackupFile); 238 let matches = filename.match(filenamesRegex); 239 if (!matches) { 240 throw new Error(`Invalid backup file name: ${filename}`); 241 } 242 return new Date(matches[1].replace(/-/g, "/")); 243 }, 244 245 /** 246 * Get the most recent backup file. 247 * 248 * @return {Promise} 249 * @result the path to the file. 250 */ 251 getMostRecentBackup: function PB_getMostRecentBackup() { 252 return (async () => { 253 let entries = await this.getBackupFiles(); 254 for (let entry of entries) { 255 let rx = /\.json(lz4)?$/; 256 if (PathUtils.filename(entry).match(rx)) { 257 return entry; 258 } 259 } 260 return null; 261 })(); 262 }, 263 264 /** 265 * Returns whether a recent enough backup exists, using these heuristic: if 266 * a backup exists, it should be newer than the last browser session date, 267 * otherwise it should not be older than maxDays. 268 * If the backup is older than the last session, the calculated time is 269 * reported to telemetry. 270 * 271 * @param [maxDays] The maximum number of days a backup can be old. 272 */ 273 async hasRecentBackup({ maxDays = 3 } = {}) { 274 let lastBackupFile = await PlacesBackups.getMostRecentBackup(); 275 if (!lastBackupFile) { 276 return false; 277 } 278 let lastBackupTime = PlacesBackups.getDateForFile(lastBackupFile); 279 let profileLastUse = Services.appinfo.replacedLockTime || Date.now(); 280 if (lastBackupTime > profileLastUse) { 281 return true; 282 } 283 let backupAge = Math.round((profileLastUse - lastBackupTime) / 86400000); 284 // Telemetry the age of the last available backup. 285 try { 286 Services.telemetry 287 .getHistogramById("PLACES_BACKUPS_DAYSFROMLAST") 288 .add(backupAge); 289 } catch (ex) { 290 Cu.reportError(new Error("Unable to report telemetry.")); 291 } 292 return backupAge <= maxDays; 293 }, 294 295 /** 296 * Serializes bookmarks using JSON, and writes to the supplied file. 297 * 298 * @param aFilePath 299 * path for the "bookmarks.json" file to be created. 300 * @return {Promise} 301 * @resolves the number of serialized uri nodes. 302 */ 303 async saveBookmarksToJSONFile(aFilePath) { 304 let { count: nodeCount, hash: hash } = await BookmarkJSONUtils.exportToFile( 305 aFilePath 306 ); 307 308 let backupFolderPath = await this.getBackupFolder(); 309 let profileDir = await PathUtils.getProfileDir(); 310 if (profileDir == backupFolderPath) { 311 // We are creating a backup in the default backups folder, 312 // so just update the internal cache. 313 if (!this._backupFiles) { 314 await this.getBackupFiles(); 315 } 316 this._backupFiles.unshift(aFilePath); 317 } else { 318 let aMaxBackup = Services.prefs.getIntPref( 319 "browser.bookmarks.max_backups" 320 ); 321 if (aMaxBackup === 0) { 322 if (!this._backupFiles) { 323 await this.getBackupFiles(); 324 } 325 limitBackups(aMaxBackup, this._backupFiles); 326 return nodeCount; 327 } 328 // If we are saving to a folder different than our backups folder, then 329 // we also want to create a new compressed version in it. 330 // This way we ensure the latest valid backup is the same saved by the 331 // user. See bug 424389. 332 let mostRecentBackupFile = await this.getMostRecentBackup(); 333 if ( 334 !mostRecentBackupFile || 335 hash != getHashFromFilename(PathUtils.filename(mostRecentBackupFile)) 336 ) { 337 let name = this.getFilenameForDate(undefined, true); 338 let newFilename = appendMetaDataToFilename(name, { 339 count: nodeCount, 340 hash, 341 }); 342 let newFilePath = PathUtils.join(backupFolderPath, newFilename); 343 let backupFile = await getBackupFileForSameDate(name); 344 if (backupFile) { 345 // There is already a backup for today, replace it. 346 await IOUtils.remove(backupFile); 347 if (!this._backupFiles) { 348 await this.getBackupFiles(); 349 } else { 350 this._backupFiles.shift(); 351 } 352 this._backupFiles.unshift(newFilePath); 353 } else { 354 // There is no backup for today, add the new one. 355 if (!this._backupFiles) { 356 await this.getBackupFiles(); 357 } 358 this._backupFiles.unshift(newFilePath); 359 } 360 let jsonString = await IOUtils.read(aFilePath); 361 await IOUtils.write(newFilePath, jsonString, { 362 compress: true, 363 }); 364 await limitBackups(aMaxBackup, this._backupFiles); 365 } 366 } 367 return nodeCount; 368 }, 369 370 /** 371 * Creates a dated backup in <profile>/bookmarkbackups. 372 * Stores the bookmarks using a lz4 compressed JSON file. 373 * 374 * @param [optional] int aMaxBackups 375 * The maximum number of backups to keep. If set to 0 376 * all existing backups are removed and aForceBackup is 377 * ignored, so a new one won't be created. 378 * @param [optional] bool aForceBackup 379 * Forces creating a backup even if one was already 380 * created that day (overwrites). 381 * @return {Promise} 382 */ 383 create: function PB_create(aMaxBackups, aForceBackup) { 384 return (async () => { 385 if (aMaxBackups === 0) { 386 // Backups are disabled, delete any existing one and bail out. 387 if (!this._backupFiles) { 388 await this.getBackupFiles(); 389 } 390 await limitBackups(0, this._backupFiles); 391 return; 392 } 393 394 // Ensure to initialize _backupFiles 395 if (!this._backupFiles) { 396 await this.getBackupFiles(); 397 } 398 let newBackupFilename = this.getFilenameForDate(undefined, true); 399 // If we already have a backup for today we should do nothing, unless we 400 // were required to enforce a new backup. 401 let backupFile = await getBackupFileForSameDate(newBackupFilename); 402 if (backupFile && !aForceBackup) { 403 return; 404 } 405 406 if (backupFile) { 407 // In case there is a backup for today we should recreate it. 408 this._backupFiles.shift(); 409 await IOUtils.remove(backupFile); 410 } 411 412 // Now check the hash of the most recent backup, and try to create a new 413 // backup, if that fails due to hash conflict, just rename the old backup. 414 let mostRecentBackupFile = await this.getMostRecentBackup(); 415 let mostRecentHash = 416 mostRecentBackupFile && 417 getHashFromFilename(PathUtils.filename(mostRecentBackupFile)); 418 419 // Save bookmarks to a backup file. 420 let backupFolder = await this.getBackupFolder(); 421 let newBackupFile = PathUtils.join(backupFolder, newBackupFilename); 422 let newFilenameWithMetaData; 423 try { 424 let { 425 count: nodeCount, 426 hash: hash, 427 } = await BookmarkJSONUtils.exportToFile(newBackupFile, { 428 compress: true, 429 failIfHashIs: mostRecentHash, 430 }); 431 newFilenameWithMetaData = appendMetaDataToFilename(newBackupFilename, { 432 count: nodeCount, 433 hash, 434 }); 435 } catch (ex) { 436 if (!ex.becauseSameHash) { 437 throw ex; 438 } 439 // The last backup already contained up-to-date information, just 440 // rename it as if it was today's backup. 441 this._backupFiles.shift(); 442 newBackupFile = mostRecentBackupFile; 443 // Ensure we retain the proper extension when renaming 444 // the most recent backup file. 445 if (/\.json$/.test(PathUtils.filename(mostRecentBackupFile))) { 446 newBackupFilename = this.getFilenameForDate(); 447 } 448 newFilenameWithMetaData = appendMetaDataToFilename(newBackupFilename, { 449 count: this.getBookmarkCountForFile(mostRecentBackupFile), 450 hash: mostRecentHash, 451 }); 452 } 453 454 // Append metadata to the backup filename. 455 let newBackupFileWithMetadata = PathUtils.join( 456 backupFolder, 457 newFilenameWithMetaData 458 ); 459 await IOUtils.move(newBackupFile, newBackupFileWithMetadata); 460 this._backupFiles.unshift(newBackupFileWithMetadata); 461 462 // Limit the number of backups. 463 await limitBackups(aMaxBackups, this._backupFiles); 464 })(); 465 }, 466 467 /** 468 * Gets the bookmark count for backup file. 469 * 470 * @param aFilePath 471 * File path The backup file. 472 * 473 * @return the bookmark count or null. 474 */ 475 getBookmarkCountForFile: function PB_getBookmarkCountForFile(aFilePath) { 476 let count = null; 477 let filename = PathUtils.filename(aFilePath); 478 let matches = filename.match(filenamesRegex); 479 if (matches && matches[2]) { 480 count = matches[2]; 481 } 482 return count; 483 }, 484 485 /** 486 * Gets a bookmarks tree representation usable to create backups in different 487 * file formats. The root or the tree is PlacesUtils.bookmarks.rootGuid. 488 * 489 * @return an object representing a tree with the places root as its root. 490 * Each bookmark is represented by an object having these properties: 491 * * id: the item id (make this not enumerable after bug 824502) 492 * * title: the title 493 * * guid: unique id 494 * * parent: item id of the parent folder, not enumerable 495 * * index: the position in the parent 496 * * dateAdded: microseconds from the epoch 497 * * lastModified: microseconds from the epoch 498 * * type: type of the originating node as defined in PlacesUtils 499 * The following properties exist only for a subset of bookmarks: 500 * * annos: array of annotations 501 * * uri: url 502 * * iconuri: favicon's url 503 * * keyword: associated keyword 504 * * charset: last known charset 505 * * tags: csv string of tags 506 * * root: string describing whether this represents a root 507 * * children: array of child items in a folder 508 */ 509 async getBookmarksTree() { 510 let startTime = Date.now(); 511 let root = await PlacesUtils.promiseBookmarksTree( 512 PlacesUtils.bookmarks.rootGuid, 513 { 514 includeItemIds: true, 515 } 516 ); 517 518 try { 519 Services.telemetry 520 .getHistogramById("PLACES_BACKUPS_BOOKMARKSTREE_MS") 521 .add(Date.now() - startTime); 522 } catch (ex) { 523 Cu.reportError("Unable to report telemetry."); 524 } 525 return [root, root.itemsCount]; 526 }, 527}; 528