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