summaryrefslogtreecommitdiffstats
path: root/toolkit/components/places/PlacesBackups.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/places/PlacesBackups.jsm')
-rw-r--r--toolkit/components/places/PlacesBackups.jsm550
1 files changed, 550 insertions, 0 deletions
diff --git a/toolkit/components/places/PlacesBackups.jsm b/toolkit/components/places/PlacesBackups.jsm
new file mode 100644
index 000000000..8315aeb3a
--- /dev/null
+++ b/toolkit/components/places/PlacesBackups.jsm
@@ -0,0 +1,550 @@
+/* -*- 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/. */
+
+this.EXPORTED_SYMBOLS = ["PlacesBackups"];
+
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+const Cc = Components.classes;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/PlacesUtils.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/NetUtil.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "BookmarkJSONUtils",
+ "resource://gre/modules/BookmarkJSONUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
+ "resource://gre/modules/Deprecated.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "OS",
+ "resource://gre/modules/osfile.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "localFileCtor",
+ () => Components.Constructor("@mozilla.org/file/local;1",
+ "nsILocalFile", "initWithPath"));
+
+XPCOMUtils.defineLazyGetter(this, "filenamesRegex",
+ () => /^bookmarks-([0-9-]+)(?:_([0-9]+)){0,1}(?:_([a-z0-9=+-]{24})){0,1}\.(json(lz4)?)$/i
+);
+
+/**
+ * Appends meta-data information to a given filename.
+ */
+function appendMetaDataToFilename(aFilename, aMetaData) {
+ let matches = aFilename.match(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(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(filenamesRegex);
+ let targetMatches = aTargetName.match(filenamesRegex);
+
+ return sourceMatches && targetMatches &&
+ sourceMatches[1] == targetMatches[1];
+}
+
+/**
+ * Given a filename, searches for another backup with the same date.
+ *
+ * @return OS.File path string or null.
+ */
+function getBackupFileForSameDate(aFilename) {
+ return Task.spawn(function* () {
+ let backupFiles = yield PlacesBackups.getBackupFiles();
+ for (let backupFile of backupFiles) {
+ if (isFilenameWithSameDate(OS.Path.basename(backupFile), aFilename))
+ return backupFile;
+ }
+ return null;
+ });
+}
+
+this.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 filenamesRegex;
+ },
+
+ get folder() {
+ Deprecated.warning(
+ "PlacesBackups.folder is deprecated and will be removed in a future version",
+ "https://bugzilla.mozilla.org/show_bug.cgi?id=859695");
+ return this._folder;
+ },
+
+ /**
+ * This exists just to avoid spamming deprecate warnings from internal calls
+ * needed to support deprecated methods themselves.
+ */
+ get _folder() {
+ let bookmarksBackupDir = Services.dirsvc.get("ProfD", Ci.nsILocalFile);
+ bookmarksBackupDir.append(this.profileRelativeFolderPath);
+ if (!bookmarksBackupDir.exists()) {
+ bookmarksBackupDir.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt("0700", 8));
+ if (!bookmarksBackupDir.exists())
+ throw ("Unable to create bookmarks backup folder");
+ }
+ delete this._folder;
+ return this._folder = bookmarksBackupDir;
+ },
+
+ /**
+ * Gets backup folder asynchronously.
+ * @return {Promise}
+ * @resolve the folder (the folder string path).
+ */
+ getBackupFolder: function PB_getBackupFolder() {
+ return Task.spawn(function* () {
+ if (this._backupFolder) {
+ return this._backupFolder;
+ }
+ let profileDir = OS.Constants.Path.profileDir;
+ let backupsDirPath = OS.Path.join(profileDir, this.profileRelativeFolderPath);
+ yield OS.File.makeDir(backupsDirPath, { ignoreExisting: true });
+ return this._backupFolder = backupsDirPath;
+ }.bind(this));
+ },
+
+ get profileRelativeFolderPath() {
+ return "bookmarkbackups";
+ },
+
+ /**
+ * Cache current backups in a sorted (by date DESC) array.
+ */
+ get entries() {
+ Deprecated.warning(
+ "PlacesBackups.entries is deprecated and will be removed in a future version",
+ "https://bugzilla.mozilla.org/show_bug.cgi?id=859695");
+ return this._entries;
+ },
+
+ /**
+ * This exists just to avoid spamming deprecate warnings from internal calls
+ * needed to support deprecated methods themselves.
+ */
+ get _entries() {
+ delete this._entries;
+ this._entries = [];
+ let files = this._folder.directoryEntries;
+ while (files.hasMoreElements()) {
+ let entry = files.getNext().QueryInterface(Ci.nsIFile);
+ // A valid backup is any file that matches either the localized or
+ // not-localized filename (bug 445704).
+ if (!entry.isHidden() && filenamesRegex.test(entry.leafName)) {
+ // Remove bogus backups in future dates.
+ if (this.getDateForFile(entry) > new Date()) {
+ entry.remove(false);
+ continue;
+ }
+ this._entries.push(entry);
+ }
+ }
+ this._entries.sort((a, b) => {
+ let aDate = this.getDateForFile(a);
+ let bDate = this.getDateForFile(b);
+ return bDate - aDate;
+ });
+ return this._entries;
+ },
+
+ /**
+ * Cache current backups in a sorted (by date DESC) array.
+ * @return {Promise}
+ * @resolve a sorted array of string paths.
+ */
+ getBackupFiles: function PB_getBackupFiles() {
+ return Task.spawn(function* () {
+ if (this._backupFiles)
+ return this._backupFiles;
+
+ this._backupFiles = [];
+
+ let backupFolderPath = yield this.getBackupFolder();
+ let iterator = new OS.File.DirectoryIterator(backupFolderPath);
+ yield iterator.forEach(function(aEntry) {
+ // Since this is a lazy getter and OS.File I/O is serialized, we can
+ // safely remove .tmp files without risking to remove ongoing backups.
+ if (aEntry.name.endsWith(".tmp")) {
+ OS.File.remove(aEntry.path);
+ return undefined;
+ }
+
+ if (filenamesRegex.test(aEntry.name)) {
+ // Remove bogus backups in future dates.
+ let filePath = aEntry.path;
+ if (this.getDateForFile(filePath) > new Date()) {
+ return OS.File.remove(filePath);
+ }
+ this._backupFiles.push(filePath);
+ }
+
+ return undefined;
+ }.bind(this));
+ iterator.close();
+
+ this._backupFiles.sort((a, b) => {
+ let aDate = this.getDateForFile(a);
+ let bDate = this.getDateForFile(b);
+ return bDate - aDate;
+ });
+
+ return this._backupFiles;
+ }.bind(this));
+ },
+
+ /**
+ * 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 aBackupFile
+ * nsIFile or string path of the backup.
+ * @return A Date object for the backup's creation time.
+ */
+ getDateForFile: function PB_getDateForFile(aBackupFile) {
+ let filename = (aBackupFile instanceof Ci.nsIFile) ? aBackupFile.leafName
+ : OS.Path.basename(aBackupFile);
+ let matches = filename.match(filenamesRegex);
+ if (!matches)
+ throw ("Invalid backup file name: " + filename);
+ return new Date(matches[1].replace(/-/g, "/"));
+ },
+
+ /**
+ * Get the most recent backup file.
+ *
+ * @returns nsIFile backup file
+ */
+ getMostRecent: function PB_getMostRecent() {
+ Deprecated.warning(
+ "PlacesBackups.getMostRecent is deprecated and will be removed in a future version",
+ "https://bugzilla.mozilla.org/show_bug.cgi?id=859695");
+
+ for (let i = 0; i < this._entries.length; i++) {
+ let rx = /\.json(lz4)?$/;
+ if (this._entries[i].leafName.match(rx))
+ return this._entries[i];
+ }
+ return null;
+ },
+
+ /**
+ * Get the most recent backup file.
+ *
+ * @return {Promise}
+ * @result the path to the file.
+ */
+ getMostRecentBackup: function PB_getMostRecentBackup() {
+ return Task.spawn(function* () {
+ let entries = yield this.getBackupFiles();
+ for (let entry of entries) {
+ let rx = /\.json(lz4)?$/;
+ if (OS.Path.basename(entry).match(rx)) {
+ return entry;
+ }
+ }
+ return null;
+ }.bind(this));
+ },
+
+ /**
+ * Serializes bookmarks using JSON, and writes to the supplied file.
+ * Note: any item that should not be backed up must be annotated with
+ * "places/excludeFromBackup".
+ *
+ * @param aFilePath
+ * OS.File path for the "bookmarks.json" file to be created.
+ * @return {Promise}
+ * @resolves the number of serialized uri nodes.
+ * @deprecated passing an nsIFile is deprecated
+ */
+ saveBookmarksToJSONFile: function PB_saveBookmarksToJSONFile(aFilePath) {
+ if (aFilePath instanceof Ci.nsIFile) {
+ Deprecated.warning("Passing an nsIFile to PlacesBackups.saveBookmarksToJSONFile " +
+ "is deprecated. Please use an OS.File path instead.",
+ "https://developer.mozilla.org/docs/JavaScript_OS.File");
+ aFilePath = aFilePath.path;
+ }
+ return Task.spawn(function* () {
+ let { count: nodeCount, hash: hash } =
+ yield BookmarkJSONUtils.exportToFile(aFilePath);
+
+ let backupFolderPath = yield this.getBackupFolder();
+ if (OS.Path.dirname(aFilePath) == backupFolderPath) {
+ // We are creating a backup in the default backups folder,
+ // so just update the internal cache.
+ this._entries.unshift(new localFileCtor(aFilePath));
+ if (!this._backupFiles) {
+ yield this.getBackupFiles();
+ }
+ this._backupFiles.unshift(aFilePath);
+ } else {
+ // 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 = yield this.getMostRecentBackup();
+ if (!mostRecentBackupFile ||
+ hash != getHashFromFilename(OS.Path.basename(mostRecentBackupFile))) {
+ let name = this.getFilenameForDate(undefined, true);
+ let newFilename = appendMetaDataToFilename(name,
+ { count: nodeCount,
+ hash: hash });
+ let newFilePath = OS.Path.join(backupFolderPath, newFilename);
+ let backupFile = yield getBackupFileForSameDate(name);
+ if (backupFile) {
+ // There is already a backup for today, replace it.
+ yield OS.File.remove(backupFile, { ignoreAbsent: true });
+ if (!this._backupFiles)
+ yield this.getBackupFiles();
+ else
+ this._backupFiles.shift();
+ this._backupFiles.unshift(newFilePath);
+ } else {
+ // There is no backup for today, add the new one.
+ this._entries.unshift(new localFileCtor(newFilePath));
+ if (!this._backupFiles)
+ yield this.getBackupFiles();
+ this._backupFiles.unshift(newFilePath);
+ }
+ let jsonString = yield OS.File.read(aFilePath);
+ yield OS.File.writeAtomic(newFilePath, jsonString, { compression: "lz4" });
+ }
+ }
+
+ return nodeCount;
+ }.bind(this));
+ },
+
+ /**
+ * Creates a dated backup in <profile>/bookmarkbackups.
+ * Stores the bookmarks using a lz4 compressed JSON file.
+ * Note: any item that should not be backed up must be annotated with
+ * "places/excludeFromBackup".
+ *
+ * @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) {
+ let limitBackups = function* () {
+ let backupFiles = yield this.getBackupFiles();
+ if (typeof aMaxBackups == "number" && aMaxBackups > -1 &&
+ backupFiles.length >= aMaxBackups) {
+ let numberOfBackupsToDelete = backupFiles.length - aMaxBackups;
+ while (numberOfBackupsToDelete--) {
+ this._entries.pop();
+ let oldestBackup = this._backupFiles.pop();
+ yield OS.File.remove(oldestBackup);
+ }
+ }
+ }.bind(this);
+
+ return Task.spawn(function* () {
+ if (aMaxBackups === 0) {
+ // Backups are disabled, delete any existing one and bail out.
+ yield limitBackups(0);
+ return;
+ }
+
+ // Ensure to initialize _backupFiles
+ if (!this._backupFiles)
+ yield 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 = yield getBackupFileForSameDate(newBackupFilename);
+ if (backupFile && !aForceBackup)
+ return;
+
+ if (backupFile) {
+ // In case there is a backup for today we should recreate it.
+ this._backupFiles.shift();
+ this._entries.shift();
+ yield OS.File.remove(backupFile, { ignoreAbsent: true });
+ }
+
+ // 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 = yield this.getMostRecentBackup();
+ let mostRecentHash = mostRecentBackupFile &&
+ getHashFromFilename(OS.Path.basename(mostRecentBackupFile));
+
+ // Save bookmarks to a backup file.
+ let backupFolder = yield this.getBackupFolder();
+ let newBackupFile = OS.Path.join(backupFolder, newBackupFilename);
+ let newFilenameWithMetaData;
+ try {
+ let { count: nodeCount, hash: hash } =
+ yield BookmarkJSONUtils.exportToFile(newBackupFile,
+ { compress: true,
+ failIfHashIs: mostRecentHash });
+ newFilenameWithMetaData = appendMetaDataToFilename(newBackupFilename,
+ { count: nodeCount,
+ hash: 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();
+ this._entries.shift();
+ newBackupFile = mostRecentBackupFile;
+ // Ensure we retain the proper extension when renaming
+ // the most recent backup file.
+ if (/\.json$/.test(OS.Path.basename(mostRecentBackupFile)))
+ newBackupFilename = this.getFilenameForDate();
+ newFilenameWithMetaData = appendMetaDataToFilename(
+ newBackupFilename,
+ { count: this.getBookmarkCountForFile(mostRecentBackupFile),
+ hash: mostRecentHash });
+ }
+
+ // Append metadata to the backup filename.
+ let newBackupFileWithMetadata = OS.Path.join(backupFolder, newFilenameWithMetaData);
+ yield OS.File.move(newBackupFile, newBackupFileWithMetadata);
+ this._entries.unshift(new localFileCtor(newBackupFileWithMetadata));
+ this._backupFiles.unshift(newBackupFileWithMetadata);
+
+ // Limit the number of backups.
+ yield limitBackups(aMaxBackups);
+ }.bind(this));
+ },
+
+ /**
+ * 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 = OS.Path.basename(aFilePath);
+ let matches = filename.match(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.placesRootId.
+ * Items annotated with PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO and all of their
+ * descendants are excluded.
+ *
+ * @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
+ */
+ getBookmarksTree: Task.async(function* () {
+ let startTime = Date.now();
+ let root = yield PlacesUtils.promiseBookmarksTree(PlacesUtils.bookmarks.rootGuid, {
+ excludeItemsCallback: aItem => {
+ return aItem.annos &&
+ aItem.annos.find(a => a.name == PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO);
+ },
+ includeItemIds: true
+ });
+
+ try {
+ Services.telemetry
+ .getHistogramById("PLACES_BACKUPS_BOOKMARKSTREE_MS")
+ .add(Date.now() - startTime);
+ } catch (ex) {
+ Components.utils.reportError("Unable to report telemetry.");
+ }
+ return [root, root.itemsCount];
+ })
+}
+