/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- * vim: sw=2 ts=2 sts=2 expandtab filetype=javascript * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", BookmarkJSONUtils: "resource://gre/modules/BookmarkJSONUtils.sys.mjs", }); ChromeUtils.defineLazyGetter( lazy, "filenamesRegex", () => /^bookmarks-([0-9-]+)(?:_([0-9]+)){0,1}(?:_([a-z0-9=+-]{24})){0,1}\.(json(lz4)?)$/i ); async function limitBackups(aMaxBackups, backupFiles) { if ( typeof aMaxBackups == "number" && aMaxBackups > -1 && backupFiles.length >= aMaxBackups ) { let numberOfBackupsToDelete = backupFiles.length - aMaxBackups; while (numberOfBackupsToDelete--) { let oldestBackup = backupFiles.pop(); await IOUtils.remove(oldestBackup); } } } /** * Appends meta-data information to a given filename. */ function appendMetaDataToFilename(aFilename, aMetaData) { let matches = aFilename.match(lazy.filenamesRegex); return ( "bookmarks-" + matches[1] + "_" + aMetaData.count + "_" + aMetaData.hash + "." + matches[4] ); } /** * Gets the hash from a backup filename. * * @return the extracted hash or null. */ function getHashFromFilename(aFilename) { let matches = aFilename.match(lazy.filenamesRegex); if (matches && matches[3]) { return matches[3]; } return null; } /** * Given two filenames, checks if they contain the same date. */ function isFilenameWithSameDate(aSourceName, aTargetName) { let sourceMatches = aSourceName.match(lazy.filenamesRegex); let targetMatches = aTargetName.match(lazy.filenamesRegex); return sourceMatches && targetMatches && sourceMatches[1] == targetMatches[1]; } /** * Given a filename, searches for another backup with the same date. * * @return path string or null. */ function getBackupFileForSameDate(aFilename) { return (async function () { let backupFiles = await PlacesBackups.getBackupFiles(); for (let backupFile of backupFiles) { if (isFilenameWithSameDate(PathUtils.filename(backupFile), aFilename)) { return backupFile; } } return null; })(); } export var PlacesBackups = { /** * Matches the backup filename: * 0: file name * 1: date in form Y-m-d * 2: bookmarks count * 3: contents hash * 4: file extension */ get filenamesRegex() { return lazy.filenamesRegex; }, /** * Gets backup folder asynchronously. * @return {Promise} * @resolve the folder (the folder string path). */ getBackupFolder: function PB_getBackupFolder() { return (async () => { if (this._backupFolder) { return this._backupFolder; } let backupsDirPath = PathUtils.join( PathUtils.profileDir, this.profileRelativeFolderPath ); await IOUtils.makeDirectory(backupsDirPath); return (this._backupFolder = backupsDirPath); })(); }, get profileRelativeFolderPath() { return "bookmarkbackups"; }, /** * Cache current backups in a sorted (by date DESC) array. * @return {Promise} * @resolve a sorted array of string paths. */ getBackupFiles: function PB_getBackupFiles() { return (async () => { if (this._backupFiles) { return this._backupFiles; } this._backupFiles = []; let backupFolderPath = await this.getBackupFolder(); let children = await IOUtils.getChildren(backupFolderPath); let list = []; for (const entry of children) { // Since IOUtils I/O is serialized, we can safely remove .tmp files // without risking to remove ongoing backups. let filename = PathUtils.filename(entry); if (filename.endsWith(".tmp")) { list.push(IOUtils.remove(entry)); continue; } if (lazy.filenamesRegex.test(filename)) { // Remove bogus backups in future dates. if (this.getDateForFile(entry) > new Date()) { list.push(IOUtils.remove(entry)); continue; } this._backupFiles.push(entry); } } await Promise.all(list); this._backupFiles.sort((a, b) => { let aDate = this.getDateForFile(a); let bDate = this.getDateForFile(b); return bDate - aDate; }); return this._backupFiles; })(); }, /** * Invalidates the internal cache for testing purposes. */ invalidateCache() { this._backupFiles = null; }, /** * Generates a ISO date string (YYYY-MM-DD) from a Date object. * * @param dateObj * The date object to parse. * @return an ISO date string. */ toISODateString: function toISODateString(dateObj) { if (!dateObj || dateObj.constructor.name != "Date" || !dateObj.getTime()) { throw new Error("invalid date object"); } let padDate = val => ("0" + val).substr(-2, 2); return [ dateObj.getFullYear(), padDate(dateObj.getMonth() + 1), padDate(dateObj.getDate()), ].join("-"); }, /** * Creates a filename for bookmarks backup files. * * @param [optional] aDateObj * Date object used to build the filename. * Will use current date if empty. * @param [optional] bool - aCompress * Determines if file extension is json or jsonlz4 Default is json * @return A bookmarks backup filename. */ getFilenameForDate: function PB_getFilenameForDate(aDateObj, aCompress) { let dateObj = aDateObj || new Date(); // Use YYYY-MM-DD (ISO 8601) as it doesn't contain illegal characters // and makes the alphabetical order of multiple backup files more useful. return ( "bookmarks-" + PlacesBackups.toISODateString(dateObj) + ".json" + (aCompress ? "lz4" : "") ); }, /** * Creates a Date object from a backup file. The date is the backup * creation date. * * @param {Sring} aBackupFile The path of the backup. * @return {Date} A Date object for the backup's creation time. */ getDateForFile: function PB_getDateForFile(aBackupFile) { let filename = PathUtils.filename(aBackupFile); let matches = filename.match(lazy.filenamesRegex); if (!matches) { throw new Error(`Invalid backup file name: ${filename}`); } return new Date(matches[1].replace(/-/g, "/")); }, /** * Get the most recent backup file. * * @return {Promise} * @result the path to the file. */ getMostRecentBackup: function PB_getMostRecentBackup() { return (async () => { let entries = await this.getBackupFiles(); for (let entry of entries) { let rx = /\.json(lz4)?$/; if (PathUtils.filename(entry).match(rx)) { return entry; } } return null; })(); }, /** * Returns whether a recent enough backup exists, using these heuristic: if * a backup exists, it should be newer than the last browser session date, * otherwise it should not be older than maxDays. * If the backup is older than the last session, the calculated time is * reported to telemetry. * * @param [maxDays] The maximum number of days a backup can be old. */ async hasRecentBackup({ maxDays = 3 } = {}) { let lastBackupFile = await PlacesBackups.getMostRecentBackup(); if (!lastBackupFile) { return false; } let lastBackupTime = PlacesBackups.getDateForFile(lastBackupFile); let profileLastUse = Services.appinfo.replacedLockTime || Date.now(); if (lastBackupTime > profileLastUse) { return true; } let backupAge = Math.round((profileLastUse - lastBackupTime) / 86400000); // Telemetry the age of the last available backup. try { Services.telemetry .getHistogramById("PLACES_BACKUPS_DAYSFROMLAST") .add(backupAge); } catch (ex) { console.error(new Error("Unable to report telemetry.")); } return backupAge <= maxDays; }, /** * Serializes bookmarks using JSON, and writes to the supplied file. * * @param aFilePath * path for the "bookmarks.json" file to be created. * @return {Promise} * @resolves the number of serialized uri nodes. */ async saveBookmarksToJSONFile(aFilePath) { let { count: nodeCount, hash: hash } = await lazy.BookmarkJSONUtils.exportToFile(aFilePath); let backupFolderPath = await this.getBackupFolder(); if (PathUtils.profileDir == backupFolderPath) { // We are creating a backup in the default backups folder, // so just update the internal cache. if (!this._backupFiles) { await this.getBackupFiles(); } this._backupFiles.unshift(aFilePath); } else { let aMaxBackup = Services.prefs.getIntPref( "browser.bookmarks.max_backups" ); if (aMaxBackup === 0) { if (!this._backupFiles) { await this.getBackupFiles(); } limitBackups(aMaxBackup, this._backupFiles); return nodeCount; } // If we are saving to a folder different than our backups folder, then // we also want to create a new compressed version in it. // This way we ensure the latest valid backup is the same saved by the // user. See bug 424389. let mostRecentBackupFile = await this.getMostRecentBackup(); if ( !mostRecentBackupFile || hash != getHashFromFilename(PathUtils.filename(mostRecentBackupFile)) ) { let name = this.getFilenameForDate(undefined, true); let newFilename = appendMetaDataToFilename(name, { count: nodeCount, hash, }); let newFilePath = PathUtils.join(backupFolderPath, newFilename); let backupFile = await getBackupFileForSameDate(name); if (backupFile) { // There is already a backup for today, replace it. await IOUtils.remove(backupFile); if (!this._backupFiles) { await this.getBackupFiles(); } else { this._backupFiles.shift(); } this._backupFiles.unshift(newFilePath); } else { // There is no backup for today, add the new one. if (!this._backupFiles) { await this.getBackupFiles(); } this._backupFiles.unshift(newFilePath); } let jsonString = await IOUtils.read(aFilePath); await IOUtils.write(newFilePath, jsonString, { compress: true, }); await limitBackups(aMaxBackup, this._backupFiles); } } return nodeCount; }, /** * Creates a dated backup in <profile>/bookmarkbackups. * Stores the bookmarks using a lz4 compressed JSON file. * * @param [optional] int aMaxBackups * The maximum number of backups to keep. If set to 0 * all existing backups are removed and aForceBackup is * ignored, so a new one won't be created. * @param [optional] bool aForceBackup * Forces creating a backup even if one was already * created that day (overwrites). * @return {Promise} */ create: function PB_create(aMaxBackups, aForceBackup) { return (async () => { if (aMaxBackups === 0) { // Backups are disabled, delete any existing one and bail out. if (!this._backupFiles) { await this.getBackupFiles(); } await limitBackups(0, this._backupFiles); return; } // Ensure to initialize _backupFiles if (!this._backupFiles) { await this.getBackupFiles(); } let newBackupFilename = this.getFilenameForDate(undefined, true); // If we already have a backup for today we should do nothing, unless we // were required to enforce a new backup. let backupFile = await getBackupFileForSameDate(newBackupFilename); if (backupFile && !aForceBackup) { return; } if (backupFile) { // In case there is a backup for today we should recreate it. this._backupFiles.shift(); await IOUtils.remove(backupFile); } // Now check the hash of the most recent backup, and try to create a new // backup, if that fails due to hash conflict, just rename the old backup. let mostRecentBackupFile = await this.getMostRecentBackup(); let mostRecentHash = mostRecentBackupFile && getHashFromFilename(PathUtils.filename(mostRecentBackupFile)); // Save bookmarks to a backup file. let backupFolder = await this.getBackupFolder(); let newBackupFile = PathUtils.join(backupFolder, newBackupFilename); let newFilenameWithMetaData; try { let { count: nodeCount, hash: hash } = await lazy.BookmarkJSONUtils.exportToFile(newBackupFile, { compress: true, failIfHashIs: mostRecentHash, }); newFilenameWithMetaData = appendMetaDataToFilename(newBackupFilename, { count: nodeCount, hash, }); } catch (ex) { if (!ex.becauseSameHash) { throw ex; } // The last backup already contained up-to-date information, just // rename it as if it was today's backup. this._backupFiles.shift(); newBackupFile = mostRecentBackupFile; // Ensure we retain the proper extension when renaming // the most recent backup file. if (/\.json$/.test(PathUtils.filename(mostRecentBackupFile))) { newBackupFilename = this.getFilenameForDate(); } newFilenameWithMetaData = appendMetaDataToFilename(newBackupFilename, { count: this.getBookmarkCountForFile(mostRecentBackupFile), hash: mostRecentHash, }); } // Append metadata to the backup filename. let newBackupFileWithMetadata = PathUtils.join( backupFolder, newFilenameWithMetaData ); await IOUtils.move(newBackupFile, newBackupFileWithMetadata); this._backupFiles.unshift(newBackupFileWithMetadata); // Limit the number of backups. await limitBackups(aMaxBackups, this._backupFiles); })(); }, /** * Gets the bookmark count for backup file. * * @param aFilePath * File path The backup file. * * @return the bookmark count or null. */ getBookmarkCountForFile: function PB_getBookmarkCountForFile(aFilePath) { let count = null; let filename = PathUtils.filename(aFilePath); let matches = filename.match(lazy.filenamesRegex); if (matches && matches[2]) { count = matches[2]; } return count; }, /** * Gets a bookmarks tree representation usable to create backups in different * file formats. The root or the tree is PlacesUtils.bookmarks.rootGuid. * * @return an object representing a tree with the places root as its root. * Each bookmark is represented by an object having these properties: * * id: the item id (make this not enumerable after bug 824502) * * title: the title * * guid: unique id * * parent: item id of the parent folder, not enumerable * * index: the position in the parent * * dateAdded: microseconds from the epoch * * lastModified: microseconds from the epoch * * type: type of the originating node as defined in PlacesUtils * The following properties exist only for a subset of bookmarks: * * annos: array of annotations * * uri: url * * iconUri: favicon's url * * keyword: associated keyword * * charset: last known charset * * tags: csv string of tags * * root: string describing whether this represents a root * * children: array of child items in a folder */ async getBookmarksTree() { let startTime = Date.now(); let root = await lazy.PlacesUtils.promiseBookmarksTree( lazy.PlacesUtils.bookmarks.rootGuid, { includeItemIds: true, } ); try { Services.telemetry .getHistogramById("PLACES_BACKUPS_BOOKMARKSTREE_MS") .add(Date.now() - startTime); } catch (ex) { console.error("Unable to report telemetry."); } return [root, root.itemsCount]; }, };