Source code

Revision control

Other Tools

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
var EXPORTED_SYMBOLS = ["BookmarkJSONUtils"];
6
7
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
8
const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
9
const { PlacesUtils } = ChromeUtils.import(
11
);
12
13
Cu.importGlobalProperties(["fetch"]);
14
15
ChromeUtils.defineModuleGetter(
16
this,
17
"PlacesBackups",
19
);
20
21
// This is used to translate old folder pseudonyms in queries with their newer
22
// guids.
23
const OLD_BOOKMARK_QUERY_TRANSLATIONS = {
24
PLACES_ROOT: PlacesUtils.bookmarks.rootGuid,
25
BOOKMARKS_MENU: PlacesUtils.bookmarks.menuGuid,
26
TAGS: PlacesUtils.bookmarks.tagsGuid,
27
UNFILED_BOOKMARKS: PlacesUtils.bookmarks.unfiledGuid,
28
TOOLBAR: PlacesUtils.bookmarks.toolbarGuid,
29
MOBILE_BOOKMARKS: PlacesUtils.bookmarks.mobileGuid,
30
};
31
32
/**
33
* Generates an hash for the given string.
34
*
35
* @note The generated hash is returned in base64 form. Mind the fact base64
36
* is case-sensitive if you are going to reuse this code.
37
*/
38
function generateHash(aString) {
39
let cryptoHash = Cc["@mozilla.org/security/hash;1"].createInstance(
40
Ci.nsICryptoHash
41
);
42
cryptoHash.init(Ci.nsICryptoHash.MD5);
43
let stringStream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
44
Ci.nsIStringInputStream
45
);
46
stringStream.setUTF8Data(aString);
47
cryptoHash.updateFromStream(stringStream, -1);
48
// base64 allows the '/' char, but we can't use it for filenames.
49
return cryptoHash.finish(true).replace(/\//g, "-");
50
}
51
52
var BookmarkJSONUtils = Object.freeze({
53
/**
54
* Import bookmarks from a url.
55
*
56
* @param {string} aSpec
57
* url of the bookmark data.
58
* @param {boolean} [options.replace]
59
* Whether we should erase existing bookmarks before importing.
60
* @param {PlacesUtils.bookmarks.SOURCES} [options.source]
61
* The bookmark change source, used to determine the sync status for
62
* imported bookmarks. Defaults to `RESTORE` if `replace = true`, or
63
* `IMPORT` otherwise.
64
*
65
* @return {Promise}
66
* @resolves When the new bookmarks have been created.
67
* @rejects JavaScript exception.
68
*/
69
async importFromURL(
70
aSpec,
71
{
72
replace: aReplace = false,
73
source: aSource = aReplace
74
? PlacesUtils.bookmarks.SOURCES.RESTORE
75
: PlacesUtils.bookmarks.SOURCES.IMPORT,
76
} = {}
77
) {
78
notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN, aReplace);
79
try {
80
let importer = new BookmarkImporter(aReplace, aSource);
81
await importer.importFromURL(aSpec);
82
83
notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS, aReplace);
84
} catch (ex) {
85
Cu.reportError("Failed to restore bookmarks from " + aSpec + ": " + ex);
86
notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED, aReplace);
87
throw ex;
88
}
89
},
90
91
/**
92
* Restores bookmarks and tags from a JSON file.
93
*
94
* @param aFilePath
95
* OS.File path string of bookmarks in JSON or JSONlz4 format to be restored.
96
* @param [options.replace]
97
* Whether we should erase existing bookmarks before importing.
98
* @param [options.source]
99
* The bookmark change source, used to determine the sync status for
100
* imported bookmarks. Defaults to `RESTORE` if `replace = true`, or
101
* `IMPORT` otherwise.
102
*
103
* @return {Promise}
104
* @resolves When the new bookmarks have been created.
105
* @rejects JavaScript exception.
106
*/
107
async importFromFile(
108
aFilePath,
109
{
110
replace: aReplace = false,
111
source: aSource = aReplace
112
? PlacesUtils.bookmarks.SOURCES.RESTORE
113
: PlacesUtils.bookmarks.SOURCES.IMPORT,
114
} = {}
115
) {
116
notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN, aReplace);
117
try {
118
if (!(await OS.File.exists(aFilePath))) {
119
throw new Error("Cannot restore from nonexisting json file");
120
}
121
122
let importer = new BookmarkImporter(aReplace, aSource);
123
if (aFilePath.endsWith("jsonlz4")) {
124
await importer.importFromCompressedFile(aFilePath);
125
} else {
126
await importer.importFromURL(OS.Path.toFileURI(aFilePath));
127
}
128
notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS, aReplace);
129
} catch (ex) {
130
Cu.reportError(
131
"Failed to restore bookmarks from " + aFilePath + ": " + ex
132
);
133
notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED, aReplace);
134
throw ex;
135
}
136
},
137
138
/**
139
* Serializes bookmarks using JSON, and writes to the supplied file path.
140
*
141
* @param aFilePath
142
* OS.File path string for the bookmarks file to be created.
143
* @param [optional] aOptions
144
* Object containing options for the export:
145
* - failIfHashIs: if the generated file would have the same hash
146
* defined here, will reject with ex.becauseSameHash
147
* - compress: if true, writes file using lz4 compression
148
* @return {Promise}
149
* @resolves once the file has been created, to an object with the
150
* following properties:
151
* - count: number of exported bookmarks
152
* - hash: file hash for contents comparison
153
* @rejects JavaScript exception.
154
*/
155
async exportToFile(aFilePath, aOptions = {}) {
156
let [bookmarks, count] = await PlacesBackups.getBookmarksTree();
157
let startTime = Date.now();
158
let jsonString = JSON.stringify(bookmarks);
159
// Report the time taken to convert the tree to JSON.
160
try {
161
Services.telemetry
162
.getHistogramById("PLACES_BACKUPS_TOJSON_MS")
163
.add(Date.now() - startTime);
164
} catch (ex) {
165
Cu.reportError("Unable to report telemetry.");
166
}
167
168
let hash = generateHash(jsonString);
169
170
if (hash === aOptions.failIfHashIs) {
171
let e = new Error("Hash conflict");
172
e.becauseSameHash = true;
173
throw e;
174
}
175
176
// Do not write to the tmp folder, otherwise if it has a different
177
// filesystem writeAtomic will fail. Eventual dangling .tmp files should
178
// be cleaned up by the caller.
179
let writeOptions = { tmpPath: OS.Path.join(aFilePath + ".tmp") };
180
if (aOptions.compress) {
181
writeOptions.compression = "lz4";
182
}
183
184
await OS.File.writeAtomic(aFilePath, jsonString, writeOptions);
185
return { count, hash };
186
},
187
});
188
189
function BookmarkImporter(aReplace, aSource) {
190
this._replace = aReplace;
191
this._source = aSource;
192
}
193
BookmarkImporter.prototype = {
194
/**
195
* Import bookmarks from a url.
196
*
197
* @param {string} aSpec
198
* url of the bookmark data.
199
*
200
* @return {Promise}
201
* @resolves When the new bookmarks have been created.
202
* @rejects JavaScript exception.
203
*/
204
async importFromURL(spec) {
205
if (!spec.startsWith("chrome://") && !spec.startsWith("file://")) {
206
throw new Error(
207
"importFromURL can only be used with chrome:// and file:// URLs"
208
);
209
}
210
let nodes = await (await fetch(spec)).json();
211
212
if (!nodes.children || !nodes.children.length) {
213
return;
214
}
215
216
await this.import(nodes);
217
},
218
219
/**
220
* Import bookmarks from a compressed file.
221
*
222
* @param aFilePath
223
* OS.File path string of the bookmark data.
224
*
225
* @return {Promise}
226
* @resolves When the new bookmarks have been created.
227
* @rejects JavaScript exception.
228
*/
229
importFromCompressedFile: async function BI_importFromCompressedFile(
230
aFilePath
231
) {
232
let aResult = await OS.File.read(aFilePath, { compression: "lz4" });
233
let decoder = new TextDecoder();
234
let jsonString = decoder.decode(aResult);
235
await this.importFromJSON(jsonString);
236
},
237
238
/**
239
* Import bookmarks from a JSON string.
240
*
241
* @param {String} aString JSON string of serialized bookmark data.
242
* @return {Promise}
243
* @resolves When the new bookmarks have been created.
244
* @rejects JavaScript exception.
245
*/
246
async importFromJSON(aString) {
247
let nodes = PlacesUtils.unwrapNodes(
248
aString,
249
PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER
250
);
251
252
if (!nodes.length || !nodes[0].children || !nodes[0].children.length) {
253
return;
254
}
255
256
await this.import(nodes[0]);
257
},
258
259
async import(rootNode) {
260
// Change to rootNode.children as we don't import the root, and also filter
261
// out any obsolete "tagsFolder" sections.
262
let nodes = rootNode.children.filter(node => node.root !== "tagsFolder");
263
264
// If we're replacing, then erase existing bookmarks first.
265
if (this._replace) {
266
await PlacesUtils.bookmarks.eraseEverything({ source: this._source });
267
}
268
269
let folderIdToGuidMap = {};
270
271
// Now do some cleanup on the imported nodes so that the various guids
272
// match what we need for insertTree, and we also have mappings of folders
273
// so we can repair any searches after inserting the bookmarks (see bug 824502).
274
for (let node of nodes) {
275
if (!node.children || !node.children.length) {
276
continue;
277
} // Nothing to restore for this root
278
279
// Ensure we set the source correctly.
280
node.source = this._source;
281
282
// Translate the node for insertTree.
283
let folders = translateTreeTypes(node);
284
285
folderIdToGuidMap = Object.assign(folderIdToGuidMap, folders);
286
}
287
288
// Now we can add the actual nodes to the database.
289
for (let node of nodes) {
290
// Drop any nodes without children, we can't insert them.
291
if (!node.children || !node.children.length) {
292
continue;
293
}
294
295
// Drop any roots whose guid we don't recognise - we don't support anything
296
// apart from the built-in roots.
297
if (!PlacesUtils.bookmarks.userContentRoots.includes(node.guid)) {
298
continue;
299
}
300
301
fixupSearchQueries(node, folderIdToGuidMap);
302
303
await PlacesUtils.bookmarks.insertTree(node, {
304
fixupOrSkipInvalidEntries: true,
305
});
306
307
// Now add any favicons.
308
try {
309
insertFaviconsForTree(node);
310
} catch (ex) {
311
Cu.reportError(`Failed to insert favicons: ${ex}`);
312
}
313
}
314
},
315
};
316
317
function notifyObservers(topic, replace) {
318
Services.obs.notifyObservers(null, topic, replace ? "json" : "json-append");
319
}
320
321
/**
322
* Iterates through a node, fixing up any place: URL queries that are found. This
323
* replaces any old (pre Firefox 62) queries that contain "folder=<id>" parts with
324
* "parent=<guid>".
325
*
326
* @param {Object} aNode The node to search.
327
* @param {Array} aFolderIdMap An array mapping of old folder IDs to new folder GUIDs.
328
*/
329
function fixupSearchQueries(aNode, aFolderIdMap) {
330
if (aNode.url && aNode.url.startsWith("place:")) {
331
aNode.url = fixupQuery(aNode.url, aFolderIdMap);
332
}
333
if (aNode.children) {
334
for (let child of aNode.children) {
335
fixupSearchQueries(child, aFolderIdMap);
336
}
337
}
338
}
339
340
/**
341
* Replaces imported folder ids with their local counterparts in a place: URI.
342
*
343
* @param {String} aQueryURL
344
* A place: URI with folder ids.
345
* @param {Object} aFolderIdMap
346
* An array mapping of old folder IDs to new folder GUIDs.
347
* @return {String} the fixed up URI if all matched. If some matched, it returns
348
* the URI with only the matching folders included. If none matched
349
* it returns the input URI unchanged.
350
*/
351
function fixupQuery(aQueryURL, aFolderIdMap) {
352
let invalid = false;
353
let convert = function(str, existingFolderId) {
354
let guid;
355
if (
356
Object.keys(OLD_BOOKMARK_QUERY_TRANSLATIONS).includes(existingFolderId)
357
) {
358
guid = OLD_BOOKMARK_QUERY_TRANSLATIONS[existingFolderId];
359
} else {
360
guid = aFolderIdMap[existingFolderId];
361
if (!guid) {
362
invalid = true;
363
return `invalidOldParentId=${existingFolderId}`;
364
}
365
}
366
return `parent=${guid}`;
367
};
368
369
let url = aQueryURL.replace(/folder=([A-Za-z0-9_]+)/g, convert);
370
if (invalid) {
371
// One or more of the folders don't exist, cause an empty query so that
372
// we don't try to display the whole database.
373
url += "&excludeItems=1";
374
}
375
return url;
376
}
377
378
/**
379
* A mapping of root folder names to Guids. To help fixupRootFolderGuid.
380
*/
381
const rootToFolderGuidMap = {
382
placesRoot: PlacesUtils.bookmarks.rootGuid,
383
bookmarksMenuFolder: PlacesUtils.bookmarks.menuGuid,
384
unfiledBookmarksFolder: PlacesUtils.bookmarks.unfiledGuid,
385
toolbarFolder: PlacesUtils.bookmarks.toolbarGuid,
386
mobileFolder: PlacesUtils.bookmarks.mobileGuid,
387
};
388
389
/**
390
* Updates a bookmark node from the json version to the places GUID. This
391
* will only change GUIDs for the built-in folders. Other folders will remain
392
* unchanged.
393
*
394
* @param {Object} A bookmark node that is updated with the new GUID if necessary.
395
*/
396
function fixupRootFolderGuid(node) {
397
if (!node.guid && node.root && node.root in rootToFolderGuidMap) {
398
node.guid = rootToFolderGuidMap[node.root];
399
}
400
}
401
402
/**
403
* Translates the JSON types for a node and its children into Places compatible
404
* types. Also handles updating of other parameters e.g. dateAdded and lastModified.
405
*
406
* @param {Object} node A node to be updated. If it contains children, they will
407
* be updated as well.
408
* @return {Array} An array containing two items:
409
* - {Object} A map of current folder ids to GUIDS
410
* - {Array} An array of GUIDs for nodes that contain query URIs
411
*/
412
function translateTreeTypes(node) {
413
let folderIdToGuidMap = {};
414
415
// Do the uri fixup first, so we can be consistent in this function.
416
if (node.uri) {
417
node.url = node.uri;
418
delete node.uri;
419
}
420
421
switch (node.type) {
422
case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER:
423
node.type = PlacesUtils.bookmarks.TYPE_FOLDER;
424
425
// Older type mobile folders have a random guid with an annotation. We need
426
// to make sure those go into the proper mobile folder.
427
let isMobileFolder =
428
node.annos &&
429
node.annos.some(anno => anno.name == PlacesUtils.MOBILE_ROOT_ANNO);
430
if (isMobileFolder) {
431
node.guid = PlacesUtils.bookmarks.mobileGuid;
432
} else {
433
// In case the Guid is broken, we need to fix it up.
434
fixupRootFolderGuid(node);
435
}
436
437
// Record the current id and the guid so that we can update any search
438
// queries later.
439
folderIdToGuidMap[node.id] = node.guid;
440
break;
441
case PlacesUtils.TYPE_X_MOZ_PLACE:
442
node.type = PlacesUtils.bookmarks.TYPE_BOOKMARK;
443
break;
444
case PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR:
445
node.type = PlacesUtils.bookmarks.TYPE_SEPARATOR;
446
if ("title" in node) {
447
delete node.title;
448
}
449
break;
450
default:
451
// No need to throw/reject here, insertTree will remove this node automatically.
452
Cu.reportError(`Unexpected bookmark type ${node.type}`);
453
break;
454
}
455
456
if (node.dateAdded) {
457
node.dateAdded = PlacesUtils.toDate(node.dateAdded);
458
}
459
460
if (node.lastModified) {
461
let lastModified = PlacesUtils.toDate(node.lastModified);
462
// Ensure we get a last modified date that's later or equal to the dateAdded
463
// so that we don't upset the Bookmarks API.
464
if (lastModified >= node.dateAdded) {
465
node.lastModified = lastModified;
466
} else {
467
delete node.lastModified;
468
}
469
}
470
471
if (node.tags) {
472
// Separate any tags into an array, and ignore any that are too long.
473
node.tags = node.tags
474
.split(",")
475
.filter(
476
aTag =>
477
!!aTag.length && aTag.length <= PlacesUtils.bookmarks.MAX_TAG_LENGTH
478
);
479
480
// If we end up with none, then delete the property completely.
481
if (!node.tags.length) {
482
delete node.tags;
483
}
484
}
485
486
// Sometimes postData can be null, so delete it to make the validators happy.
487
if (node.postData == null) {
488
delete node.postData;
489
}
490
491
// Now handle any children.
492
if (!node.children) {
493
return folderIdToGuidMap;
494
}
495
496
// First sort the children by index.
497
node.children = node.children.sort((a, b) => {
498
return a.index - b.index;
499
});
500
501
// Now do any adjustments required for the children.
502
for (let child of node.children) {
503
let folders = translateTreeTypes(child);
504
folderIdToGuidMap = Object.assign(folderIdToGuidMap, folders);
505
}
506
507
return folderIdToGuidMap;
508
}
509
510
/**
511
* Handles inserting favicons into the database for a bookmark node.
512
* It is assumed the node has already been inserted into the bookmarks
513
* database.
514
*
515
* @param {Object} node The bookmark node for icons to be inserted.
516
*/
517
function insertFaviconForNode(node) {
518
if (node.icon) {
519
try {
520
// Create a fake faviconURI to use (FIXME: bug 523932)
521
let faviconURI = Services.io.newURI("fake-favicon-uri:" + node.url);
522
PlacesUtils.favicons.replaceFaviconDataFromDataURL(
523
faviconURI,
524
node.icon,
525
0,
526
Services.scriptSecurityManager.getSystemPrincipal()
527
);
528
PlacesUtils.favicons.setAndFetchFaviconForPage(
529
Services.io.newURI(node.url),
530
faviconURI,
531
false,
532
PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
533
null,
534
Services.scriptSecurityManager.getSystemPrincipal()
535
);
536
} catch (ex) {
537
Cu.reportError("Failed to import favicon data:" + ex);
538
}
539
}
540
541
if (!node.iconUri) {
542
return;
543
}
544
545
try {
546
PlacesUtils.favicons.setAndFetchFaviconForPage(
547
Services.io.newURI(node.url),
548
Services.io.newURI(node.iconUri),
549
false,
550
PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
551
null,
552
Services.scriptSecurityManager.getSystemPrincipal()
553
);
554
} catch (ex) {
555
Cu.reportError("Failed to import favicon URI:" + ex);
556
}
557
}
558
559
/**
560
* Handles inserting favicons into the database for a bookmark tree - a node
561
* and its children.
562
*
563
* It is assumed the nodes have already been inserted into the bookmarks
564
* database.
565
*
566
* @param {Object} nodeTree The bookmark node tree for icons to be inserted.
567
*/
568
function insertFaviconsForTree(nodeTree) {
569
insertFaviconForNode(nodeTree);
570
571
if (nodeTree.children) {
572
for (let child of nodeTree.children) {
573
insertFaviconsForTree(child);
574
}
575
}
576
}