555 lines
17 KiB
JavaScript
555 lines
17 KiB
JavaScript
/* -*- 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
|
|
);
|
|
|
|
/**
|
|
* Limits the number of backup files by deleting old backups.
|
|
*
|
|
* @param {number} aMaxBackups
|
|
* @param {string[]} backupFiles
|
|
*
|
|
* @returns {Promise<void>}
|
|
* Resolves when the oldest files have been deleted.
|
|
*/
|
|
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.
|
|
*
|
|
* @param {string} aFilename
|
|
* The file name.
|
|
* @param {{count: string|number, hash: string}} aMetaData
|
|
* Metadata related to the file.
|
|
*
|
|
* @returns {string}
|
|
* The filename with appended metadata information.
|
|
*/
|
|
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.
|
|
*
|
|
* @param {string} aFilename
|
|
* The backup file name.
|
|
*
|
|
* @returns {null|string}
|
|
* 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.
|
|
*
|
|
* @param {string} aSourceName
|
|
* @param {string} aTargetName
|
|
*
|
|
* @returns {boolean}
|
|
*/
|
|
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.
|
|
*
|
|
* @param {string} aFilename
|
|
* The name of the backup file.
|
|
*
|
|
* @returns {Promise<string|null>}
|
|
* Resolves to a 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.
|
|
*
|
|
* @returns {Promise<string>}
|
|
* A promise that resolves to 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);
|
|
})();
|
|
},
|
|
|
|
/**
|
|
* @returns {string}
|
|
*/
|
|
get profileRelativeFolderPath() {
|
|
return "bookmarkbackups";
|
|
},
|
|
|
|
/**
|
|
* Cache current backups in a sorted (by date DESC) array.
|
|
*
|
|
* @returns {Promise<string[]>}
|
|
* 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.getTime() - aDate.getTime();
|
|
});
|
|
|
|
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 {Date} dateObj
|
|
* The date object to parse.
|
|
*
|
|
* @returns {string}
|
|
* 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 {Date} [aDateObj]
|
|
* Date object used to build the filename. Will use current date if empty.
|
|
* @param {boolean} [aCompress]
|
|
* Determines if the file extension is json or jsonlz4. Default is json.
|
|
*
|
|
* @returns {string}
|
|
* 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 {string} aBackupFile
|
|
* The path of the backup.
|
|
*
|
|
* @returns {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.
|
|
*
|
|
* @returns {Promise<string|null>}
|
|
* The path to the file or null if no valid backup was found.
|
|
*/
|
|
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 {object} [options={}]
|
|
* @param {number} [options.maxDays]
|
|
* The maximum number of days a backup can be old.
|
|
*
|
|
* @returns {Promise<boolean>}
|
|
* Resolves to true if a recent backup is available.
|
|
*/
|
|
async hasRecentBackup({ maxDays = 3 } = {}) {
|
|
let lastBackupFile = await PlacesBackups.getMostRecentBackup();
|
|
if (!lastBackupFile) {
|
|
return false;
|
|
}
|
|
let lastBackupTime = PlacesBackups.getDateForFile(lastBackupFile).getTime();
|
|
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.
|
|
Glean.places.backupsDaysfromlast.accumulateSingleSample(backupAge);
|
|
return backupAge <= maxDays;
|
|
},
|
|
|
|
/**
|
|
* Serializes bookmarks using JSON, and writes to the supplied file.
|
|
*
|
|
* @param {string} aFilePath
|
|
* Path for the "bookmarks.json" file to be created.
|
|
*
|
|
* @returns {Promise<number>}
|
|
* 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 {number} [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 {boolean} [aForceBackup]
|
|
* Forces creating a backup even if one was already created that day
|
|
* (overwrites).
|
|
*
|
|
* @returns {Promise<void>}
|
|
*/
|
|
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 {string} aFilePath
|
|
* The file path to the backup file.
|
|
*
|
|
* @returns {string|null}
|
|
* 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.
|
|
*
|
|
* @returns {Promise<[object, number]>}
|
|
* An array containing two values:
|
|
* The first value is an object representing a tree with the places root as
|
|
* its root. The second value the number of items the root has.
|
|
* 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 timerId = Glean.places.backupsBookmarkstree.start();
|
|
let root = await lazy.PlacesUtils.promiseBookmarksTree(
|
|
lazy.PlacesUtils.bookmarks.rootGuid,
|
|
{
|
|
includeItemIds: true,
|
|
}
|
|
);
|
|
|
|
Glean.places.backupsBookmarkstree.stopAndAccumulate(timerId);
|
|
return [root, root.itemsCount];
|
|
},
|
|
};
|