/* globals XPCOMUtils, Services, gPrincipal, EventEmitter, PlacesUtils, Task, Bookmarks */ "use strict"; const {Ci, Cu} = require("chrome"); const base64 = require("sdk/base64"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Services.jsm"); XPCOMUtils.defineLazyGetter(this, "EventEmitter", function() { const {EventEmitter} = Cu.import("resource://devtools/shared/event-emitter.js", {}); return EventEmitter; }); XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", "resource://gre/modules/PlacesUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Bookmarks", "resource://gre/modules/Bookmarks.jsm"); XPCOMUtils.defineLazyGetter(this, "gPrincipal", function() { let uri = Services.io.newURI("about:newtab", null, null); return Services.scriptSecurityManager.getNoAppCodebasePrincipal(uri); }); // The maximum number of results PlacesProvider retrieves from history. const HISTORY_RESULTS_LIMIT = 20; // time interval for for frecent links query in milliseconds (72 hours). const FRECENT_RESULTS_TIME_LIMIT = 72 * 60 * 60 * 1000; /** * Singleton that checks if a given link should be displayed on about:newtab * or if we should rather not do it for security reasons. URIs that inherit * their caller's principal will be filtered. */ let LinkChecker = { _cache: new Map(), get flags() { return Ci.nsIScriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL | Ci.nsIScriptSecurityManager.DONT_REPORT_ERRORS; }, checkLoadURI: function LinkChecker_checkLoadURI(aURI) { if (!this._cache.has(aURI)) { this._cache.set(aURI, this._doCheckLoadURI(aURI)); } return this._cache.get(aURI); }, _doCheckLoadURI: function LinkChecker_doCheckLoadURI(aURI) { let result = false; // check for 'place:' protocol if (aURI.startsWith("place:")) { return false; } try { Services.scriptSecurityManager. checkLoadURIStrWithPrincipal(gPrincipal, aURI, this.flags); result = true; } catch (e) { // We got a weird URI or one that would inherit the caller's principal. Cu.reportError(e); } return result; } }; /* Queries history to retrieve the most visited sites. Emits events when the * history changes. * Implements the EventEmitter interface. */ let Links = function Links() { EventEmitter.decorate(this); }; Links.prototype = { /** * A set of functions called by @mozilla.org/browser/nav-historyservice * All history events are emitted from this object. */ historyObserver: { onDeleteURI: function historyObserver_onDeleteURI(aURI) { // let observers remove sensetive data associated with deleted visit gLinks.emit("deleteURI", { url: aURI.spec, }); }, onClearHistory: function historyObserver_onClearHistory() { gLinks.emit("clearHistory"); }, onFrecencyChanged: function historyObserver_onFrecencyChanged(aURI, aNewFrecency, aGUID, aHidden, aLastVisitDate) { // jshint ignore:line // The implementation of the query in getLinks excludes hidden and // unvisited pages, so it's important to exclude them here, too. if (!aHidden && aLastVisitDate) { gLinks.emit("linkChanged", { url: aURI.spec, frecency: aNewFrecency, lastVisitDate: aLastVisitDate, type: "history", }); } }, onManyFrecenciesChanged: function historyObserver_onManyFrecenciesChanged() { // Called when frecencies are invalidated and also when clearHistory is called // See toolkit/components/places/tests/unit/test_frecency_observers.js gLinks.emit("manyLinksChanged"); }, onTitleChanged: function historyObserver_onTitleChanged(aURI, aNewTitle) { gLinks.emit("linkChanged", { url: aURI.spec, title: aNewTitle }); }, QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryObserver, Ci.nsISupportsWeakReference]) }, /** * A set of functions called by @mozilla.org/browser/nav-bookmarks-service * All bookmark events are emitted from this object. */ bookmarksObserver: { onItemAdded(id, folderId, index, type) { if (type === Bookmarks.TYPE_BOOKMARK) { gLinks.getBookmark({id}).then(bookmark => { gLinks.emit("bookmarkAdded", bookmark); }); } }, onItemRemoved(id, folderId, index, type, uri) { if (type === Bookmarks.TYPE_BOOKMARK) { gLinks.emit("bookmarkRemoved", {bookmarkId: id, url: uri.spec}); } }, onItemChanged(id, property, isAnnotation, value, lastModified, type) { if (type === Bookmarks.TYPE_BOOKMARK) { gLinks.getBookmark({id}).then(bookmark => { gLinks.emit("bookmarkChanged", bookmark); }); } }, QueryInterface: XPCOMUtils.generateQI([Ci.nsINavBookmarkObserver, Ci.nsISupportsWeakReference]) }, /** * Must be called before the provider is used. * Makes it easy to disable under pref */ init: function PlacesProvider_init() { PlacesUtils.history.addObserver(this.historyObserver, true); PlacesUtils.bookmarks.addObserver(this.bookmarksObserver, true); }, /** * Must be called before the provider is unloaded. */ uninit: function PlacesProvider_uninit() { PlacesUtils.history.removeObserver(this.historyObserver); PlacesUtils.bookmarks.removeObserver(this.bookmarksObserver); }, /** * Removes a history link * * @param {String} url * @returns {Promise} Returns a promise set to true if link was removed */ deleteHistoryLink: function PlacesProvider_deleteHistoryLink(url) { return PlacesUtils.history.remove(url); }, /** * Gets links from history based on supplied options * * @param {Object} options * {Integer} limit: Maximum number of results to return. Max 20 * {Milliseconds} limitDate: Only return links fresher than limitDate * {String} order: if order=="frecency", orders by frecency, otherwise by recency * * @returns {Promise} Returns a promise with the array of links as payload. */ _getHistoryLinks: Task.async(function*(options={}) { let {limit, limitDate, order} = options; if (!limit || limit.options > HISTORY_RESULTS_LIMIT) { limit = HISTORY_RESULTS_LIMIT; } // setup binding parameters let params = {limit: limit}; // setup dateLimit binding and sql clause let dateLimitClause = ""; if (limitDate) { dateLimitClause = "AND moz_places.last_visit_date > :limitDate * 1000"; params.limitDate = limitDate; } // setup order by clause let orderbyClause = (order == "frecency") ? "ORDER BY frecency DESC, lastVisitDate DESC, url" : "ORDER BY lastVisitDate DESC, frecency DESC, url" ; // construct sql query let sqlQuery = `SELECT moz_places.url as url, moz_favicons.data as favicon, moz_favicons.mime_type as mimeType, moz_places.title, moz_places.frecency, moz_places.last_visit_date / 1000 as lastVisitDate, "history" as type, moz_bookmarks.guid as bookmarkGuid, moz_bookmarks.dateAdded / 1000 as bookmarkDateCreated FROM moz_places LEFT JOIN moz_favicons ON moz_places.favicon_id = moz_favicons.id LEFT JOIN moz_bookmarks ON moz_places.id = moz_bookmarks.fk WHERE hidden = 0 AND last_visit_date NOTNULL ${dateLimitClause} ${orderbyClause} LIMIT :limit`; let links = yield this.executePlacesQuery(sqlQuery, { columns: ["url", "favicon", "mimeType", "title", "lastVisitDate", "frecency", "type", "bookmarkGuid", "bookmarkDateCreated"], params: params }); links = this._faviconBytesToDataURI(links); return links.filter(link => LinkChecker.checkLoadURI(link.url)); }), /** * Gets the top recent links * * @param {Integer} options * limit: Maximum number of results to return. Max 20. * * @returns {Promise} Returns a promise with the array of links as payload. */ getRecentLinks: function PlacesProvider_getRecentLinks(options) { return this._getHistoryLinks(options); }, /** * Gets the top frecent links - visited in last 72 hours and ordered by frecency * * @param {Integer} options * limit: Maximum number of results to return. Max 20. * * @returns {Promise} Returns a promise with the array of links as payload. */ getFrecentLinks: function PlacesProvider_getFrecentLinks(options={}) { options.order = "frecency"; options.limitDate = (new Date()).getTime() - FRECENT_RESULTS_TIME_LIMIT; return this._getHistoryLinks(options); }, /** * Gets the top frecent sites. * * @param {Object} options * options.limit: Maximum number of results to return. Max 20. * * @returns {Promise} Returns a promise with the array of links as payload. */ getTopFrecentSites: Task.async(function*(options={}) { let {limit} = options; if (!limit || limit.options > HISTORY_RESULTS_LIMIT) { limit = HISTORY_RESULTS_LIMIT; } // this query does "GROUP BY rev_host" to remove urls from same domain. // Note that unlike mysql, sqlite picks the last raw from groupby bucket. // Which is why subselect orders frecency and last_visit_date backwards. // In general the groupby behavior in the absence of aggregates is not // defined in SQL, hence we are relying on sqlite implementation that may // change in the future. let sqlQuery = `SELECT url, title, frecency, last_visit_date / 1000 as lastVisitDate, favicon, mimeType, "history" as type FROM ( SELECT rev_host, moz_places.url, moz_favicons.data as favicon, mime_type as mimeType, title, frecency, last_visit_date FROM moz_places LEFT JOIN moz_favicons ON favicon_id = moz_favicons.id WHERE hidden = 0 AND last_visit_date NOTNULL ORDER BY rev_host, frecency, last_visit_date, moz_places.url DESC ) GROUP BY rev_host ORDER BY frecency DESC, lastVisitDate DESC, url LIMIT :limit`; let links = yield this.executePlacesQuery(sqlQuery, { columns: ["url", "title", "lastVisitDate", "frecency", "favicon", "mimeType", "type"], params: {limit} }); links = this._faviconBytesToDataURI(links); return links.filter(link => LinkChecker.checkLoadURI(link.url)); }), /** * Gets the most recent bookmarks * * @param {Object} options * options.limit: Maximum number of results to return. Max 20. * * @returns {Promise} Returns a promise with the array of links as payload. */ getRecentBookmarks: Task.async(function*(options={}) { let {limit} = options; if (!limit || limit.options > HISTORY_RESULTS_LIMIT) { limit = HISTORY_RESULTS_LIMIT; } let sqlQuery = `SELECT p.url, p.title, p.frecency, p.last_visit_date / 1000 as lastVisitDate, b.lastModified / 1000 as lastModified, b.dateAdded / 1000 as bookmarkDateCreated, b.id as bookmarkId, b.title as bookmarkTitle, b.guid as bookmarkGuid, "bookmark" as type, f.data as favicon, f.mime_type as mimeType FROM moz_places p, moz_bookmarks b LEFT JOIN moz_favicons f ON p.favicon_id = f.id WHERE b.fk = p.id AND p.rev_host IS NOT NULL AND b.type = :type ORDER BY b.lastModified DESC, lastVisitDate DESC, b.id DESC LIMIT :limit`; let links = yield this.executePlacesQuery(sqlQuery, { columns: ["bookmarkId", "bookmarkTitle", "bookmarkGuid", "bookmarkDateCreated", "url", "title", "lastVisitDate", "frecency", "type", "lastModified", "favicon", "mimeType"], params: {limit, type: Bookmarks.TYPE_BOOKMARK} }); links = this._faviconBytesToDataURI(links); return links.filter(link => LinkChecker.checkLoadURI(link.url)); }), /** * Gets a specific bookmark given an id * * @param {Object} options * options.id: bookmark ID */ getBookmark: Task.async(function*(options={}) { let {id} = options; let sqlQuery = `SELECT p.url, p.title, p.frecency, p.last_visit_date / 1000 as lastVisitDate, b.lastModified / 1000 as lastModified, b.id as bookmarkId, b.title as bookmarkTitle, b.guid as bookmarkGuid, "bookmark" as type, f.data as favicon, f.mime_type as mimeType FROM moz_places p, moz_bookmarks b LEFT JOIN moz_favicons f ON p.favicon_id = f.id WHERE b.fk = p.id AND p.rev_host IS NOT NULL AND b.type = :type AND b.id = :id ORDER BY b.lastModified, lastVisitDate DESC, b.id`; let links = yield this.executePlacesQuery(sqlQuery, { columns: ["bookmarkId", "bookmarkTitle", "bookmarkGuid", "url", "title", "lastVisitDate", "frecency", "type", "lastModified", "favicon", "mimeType"], params: {id, type: Bookmarks.TYPE_BOOKMARK} }); links = this._faviconBytesToDataURI(links); links.filter(link => LinkChecker.checkLoadURI(link.url)); if (links.length) { return links[0]; } return null; }), /** * From an Array of links, if favicons are present, convert to data URIs * * @param {Array} links * an array containing objects with favicon data and mimeTypes * * @returns {Array} an array of links with favicons as data uri */ _faviconBytesToDataURI(links) { return links.map(link => { if (link.favicon) { let encodedData = base64.encode(String.fromCharCode.apply(null, link.favicon)); link.favicon = `data:${link.mimeType};base64,${encodedData}`; } delete link.mimeType; return link; }); }, /** * Executes arbitrary query against places database * * @param {String} aSql * SQL query to execute * @param {Object} [optional] aOptions * aOptions.columns - an array of column names. if supplied the return * items will consists of objects keyed on column names. Otherwise * array of raw values is returned in the select order * aOptions.param - an object of SQL binding parameters * aOptions.callback - a callback to handle query raws * * @returns {Promise} Returns a promise with the array of retrieved items */ executePlacesQuery: function PlacesProvider_executePlacesQuery(aSql, aOptions={}) { let {columns, params, callback} = aOptions; let items = []; let queryError = null; return Task.spawn(function*() { let conn = yield PlacesUtils.promiseDBConnection(); yield conn.executeCached(aSql, params, aRow => { try { // check if caller wants to handle query raws if (callback) { callback(aRow); } // otherwise fill in the item and add items array else { let item = null; // if columns array is given construct an object if (columns && Array.isArray(columns)) { item = {}; columns.forEach(column => { item[column] = aRow.getResultByName(column); }); } else { // if no columns - make an array of raw values item = []; for (let i = 0; i < aRow.numEntries; i++) { item.push(aRow.getResultByIndex(i)); } } items.push(item); } } catch (e) { queryError = e; throw StopIteration; } }); if (queryError) { throw new Error(queryError); } return items; }); } }; /** * Singleton that serves as the default link provider for the grid. */ const gLinks = new Links(); exports.PlacesProvider = { LinkChecker: LinkChecker, links: gLinks, };