1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5"use strict";
6
7/*
8 * A Bookmark object received through the policy engine will be an
9 * object with the following properties:
10 *
11 * - URL (URL)
12 *   (required) The URL for this bookmark
13 *
14 * - Title (string)
15 *   (required) The title for this bookmark
16 *
17 * - Placement (string)
18 *   (optional) Either "toolbar" or "menu". If missing or invalid,
19 *   "toolbar" will be used
20 *
21 * - Folder (string)
22 *   (optional) The name of the folder to put this bookmark into.
23 *   If present, a folder with this name will be created in the
24 *   chosen placement above, and the bookmark will be created there.
25 *   If missing, the bookmark will be created directly into the
26 *   chosen placement.
27 *
28 * - Favicon (URL)
29 *   (optional) An http:, https: or data: URL with the favicon.
30 *   If possible, we recommend against using this property, in order
31 *   to keep the json file small.
32 *   If a favicon is not provided through the policy, it will be loaded
33 *   naturally after the user first visits the bookmark.
34 *
35 *
36 * Note: The Policy Engine automatically converts the strings given to
37 * the URL and favicon properties into a URL object.
38 *
39 * The schema for this object is defined in policies-schema.json.
40 */
41
42ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
43ChromeUtils.import("resource://gre/modules/Services.jsm");
44
45ChromeUtils.defineModuleGetter(this, "PlacesUtils",
46  "resource://gre/modules/PlacesUtils.jsm");
47
48const PREF_LOGLEVEL = "browser.policies.loglevel";
49
50XPCOMUtils.defineLazyGetter(this, "log", () => {
51  let { ConsoleAPI } = ChromeUtils.import("resource://gre/modules/Console.jsm", {});
52  return new ConsoleAPI({
53    prefix: "BookmarksPolicies.jsm",
54    // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
55    // messages during development. See LOG_LEVELS in Console.jsm for details.
56    maxLogLevel: "error",
57    maxLogLevelPref: PREF_LOGLEVEL,
58  });
59});
60
61this.EXPORTED_SYMBOLS = [ "BookmarksPolicies" ];
62
63this.BookmarksPolicies = {
64  // These prefixes must only contain characters
65  // allowed by PlacesUtils.isValidGuid
66  BOOKMARK_GUID_PREFIX: "PolB-",
67  FOLDER_GUID_PREFIX: "PolF-",
68
69  /*
70   * Process the bookmarks specified by the policy engine.
71   *
72   * @param param
73   *        This will be an array of bookmarks objects, as
74   *        described on the top of this file.
75   */
76  processBookmarks(param) {
77    calculateLists(param).then(async function addRemoveBookmarks(results) {
78      for (let bookmark of results.add.values()) {
79        await insertBookmark(bookmark).catch(log.error);
80      }
81      for (let bookmark of results.remove.values()) {
82        await PlacesUtils.bookmarks.remove(bookmark).catch(log.error);
83      }
84      for (let bookmark of results.emptyFolders.values()) {
85        await PlacesUtils.bookmarks.remove(bookmark).catch(log.error);
86      }
87
88      gFoldersMapPromise.then(map => map.clear());
89    });
90  }
91};
92
93/*
94 * This function calculates the differences between the existing bookmarks
95 * that are managed by the policy engine (which are known through a guid
96 * prefix) and the specified bookmarks in the policy file.
97 * They can differ if the policy file has changed.
98 *
99 * @param specifiedBookmarks
100 *        This will be an array of bookmarks objects, as
101 *        described on the top of this file.
102 */
103async function calculateLists(specifiedBookmarks) {
104  // --------- STEP 1 ---------
105  // Build two Maps (one with the existing bookmarks, another with
106  // the specified bookmarks), to make iteration quicker.
107
108  // LIST A
109  // MAP of url (string) -> bookmarks objects from the Policy Engine
110  let specifiedBookmarksMap = new Map();
111  for (let bookmark of specifiedBookmarks) {
112    specifiedBookmarksMap.set(bookmark.URL.href, bookmark);
113  }
114
115  // LIST B
116  // MAP of url (string) -> bookmarks objects from Places
117  let existingBookmarksMap = new Map();
118  await PlacesUtils.bookmarks.fetch(
119    { guidPrefix: BookmarksPolicies.BOOKMARK_GUID_PREFIX },
120    (bookmark) => existingBookmarksMap.set(bookmark.url.href, bookmark)
121  );
122
123  // --------- STEP 2 ---------
124  //
125  //     /=====/====\=====\
126  //    /     /      \     \
127  //    |     |      |     |
128  //    |  A  |  {}  |  B  |
129  //    |     |      |     |
130  //    \     \      /     /
131  //     \=====\====/=====/
132  //
133  // Find the intersection of the two lists. Items in the intersection
134  // are removed from the original lists.
135  //
136  // The items remaining in list A are new bookmarks to be added.
137  // The items remaining in list B are old bookmarks to be removed.
138  //
139  // There's nothing to do with items in the intersection, so there's no
140  // need to keep track of them.
141  //
142  // BONUS: It's necessary to keep track of the folder names that were
143  // seen, to make sure we remove the ones that were left empty.
144
145  let foldersSeen = new Set();
146
147  for (let [url, item] of specifiedBookmarksMap) {
148    foldersSeen.add(item.Folder);
149
150    if (existingBookmarksMap.has(url)) {
151      log.debug(`Bookmark intersection: ${url}`);
152      // If this specified bookmark exists in the existing bookmarks list,
153      // we can remove it from both lists as it's in the intersection.
154      specifiedBookmarksMap.delete(url);
155      existingBookmarksMap.delete(url);
156    }
157  }
158
159  for (let url of specifiedBookmarksMap.keys()) {
160    log.debug(`Bookmark to add: ${url}`);
161  }
162
163  for (let url of existingBookmarksMap.keys()) {
164    log.debug(`Bookmark to remove: ${url}`);
165  }
166
167  // SET of folders to be deleted (bookmarks object from Places)
168  let foldersToRemove = new Set();
169
170  // If no bookmarks will be deleted, then no folder will
171  // need to be deleted either, so this next section can be skipped.
172  if (existingBookmarksMap.size > 0) {
173    await PlacesUtils.bookmarks.fetch(
174      { guidPrefix: BookmarksPolicies.FOLDER_GUID_PREFIX },
175      (folder) => {
176        if (!foldersSeen.has(folder.title)) {
177          log.debug(`Folder to remove: ${folder.title}`);
178          foldersToRemove.add(folder);
179        }
180      }
181    );
182  }
183
184  return {
185    add: specifiedBookmarksMap,
186    remove: existingBookmarksMap,
187    emptyFolders: foldersToRemove
188  };
189}
190
191async function insertBookmark(bookmark) {
192  let parentGuid = await getParentGuid(bookmark.Placement,
193                                       bookmark.Folder);
194
195  await PlacesUtils.bookmarks.insert({
196    url: Services.io.newURI(bookmark.URL.href),
197    title: bookmark.Title,
198    guid: generateGuidWithPrefix(BookmarksPolicies.BOOKMARK_GUID_PREFIX),
199    parentGuid,
200  });
201
202  if (bookmark.Favicon) {
203    await setFaviconForBookmark(bookmark).catch(
204      () => log.error(`Error setting favicon for ${bookmark.Title}`));
205  }
206}
207
208async function setFaviconForBookmark(bookmark) {
209  let faviconURI;
210  let nullPrincipal = Services.scriptSecurityManager.createNullPrincipal({});
211
212  switch (bookmark.Favicon.protocol) {
213    case "data:":
214      // data urls must first call replaceFaviconDataFromDataURL, using a
215      // fake URL. Later, it's needed to call setAndFetchFaviconForPage
216      // with the same URL.
217      faviconURI = Services.io.newURI("fake-favicon-uri:" + bookmark.URL.href);
218
219      PlacesUtils.favicons.replaceFaviconDataFromDataURL(
220        faviconURI,
221        bookmark.Favicon.href,
222        0, /* max expiration length */
223        nullPrincipal
224      );
225      break;
226
227    case "http:":
228    case "https:":
229      faviconURI = Services.io.newURI(bookmark.Favicon.href);
230      break;
231
232    default:
233      log.error(`Bad URL given for favicon on bookmark "${bookmark.Title}"`);
234      return Promise.resolve();
235  }
236
237  return new Promise(resolve => {
238    PlacesUtils.favicons.setAndFetchFaviconForPage(
239      Services.io.newURI(bookmark.URL.href),
240      faviconURI,
241      false, /* forceReload */
242      PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
243      resolve,
244      nullPrincipal
245    );
246  });
247}
248
249function generateGuidWithPrefix(prefix) {
250  // Generates a random GUID and replace its beginning with the given
251  // prefix. We do this instead of just prepending the prefix to keep
252  // the correct character length.
253  return prefix + PlacesUtils.history.makeGuid().substring(prefix.length);
254}
255
256// Cache of folder names to guids to be used by the getParentGuid
257// function. The name consists in the parentGuid (which should always
258// be the menuGuid or the toolbarGuid) + the folder title. This is to
259// support having the same folder name in both the toolbar and menu.
260XPCOMUtils.defineLazyGetter(this, "gFoldersMapPromise", () => {
261  return new Promise(resolve => {
262    let foldersMap = new Map();
263    return PlacesUtils.bookmarks.fetch(
264      {
265        guidPrefix: BookmarksPolicies.FOLDER_GUID_PREFIX
266      },
267      (result) => {
268        foldersMap.set(`${result.parentGuid}|${result.title}`, result.guid);
269      }
270    ).then(() => resolve(foldersMap));
271  });
272});
273
274async function getParentGuid(placement, folderTitle) {
275  // Defaults to toolbar if no placement was given.
276  let parentGuid = (placement == "menu") ?
277                   PlacesUtils.bookmarks.menuGuid :
278                   PlacesUtils.bookmarks.toolbarGuid;
279
280  if (!folderTitle) {
281    // If no folderTitle is given, this bookmark is to be placed directly
282    // into the toolbar or menu.
283    return parentGuid;
284  }
285
286  let foldersMap = await gFoldersMapPromise;
287  let folderName = `${parentGuid}|${folderTitle}`;
288
289  if (foldersMap.has(folderName)) {
290    return foldersMap.get(folderName);
291  }
292
293  let guid = generateGuidWithPrefix(BookmarksPolicies.FOLDER_GUID_PREFIX);
294  await PlacesUtils.bookmarks.insert({
295    type: PlacesUtils.bookmarks.TYPE_FOLDER,
296    title: folderTitle,
297    guid,
298    parentGuid
299  });
300
301  foldersMap.set(folderName, guid);
302  return guid;
303}
304