From 370e541b4486ab7e6507a7f7501a62f8a9bf1b2c Mon Sep 17 00:00:00 2001 From: Vincent Date: Wed, 22 Feb 2023 17:11:11 +0100 Subject: [PATCH] Download and show favicons for breached companies --- .gitignore | 1 + src/client/css/partials/breaches.css | 21 +++++++++++++++++++- src/client/images/fallback-logo.svg | 12 ++++++++++++ src/controllers/breaches.js | 20 +++++++++++++++++++ src/utils/hibp.js | 29 ++++++++++++++++++++++++++++ src/views/partials/breaches.js | 12 +++++++++--- 6 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 src/client/images/fallback-logo.svg diff --git a/.gitignore b/.gitignore index c7439070a..256efbb4a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ dist **/playwright/.cache/ **/storageState.json state.json +src/client/images/logo_cache/ diff --git a/src/client/css/partials/breaches.css b/src/client/css/partials/breaches.css index e9bd9b332..bdafcd842 100644 --- a/src/client/css/partials/breaches.css +++ b/src/client/css/partials/breaches.css @@ -101,7 +101,7 @@ display: flex; flex-wrap: wrap; overflow: hidden; - align-items: baseline; + align-items: center; gap: var(--gap); } @@ -215,6 +215,25 @@ display: none; } +.breach-row .breach-company { + display: flex; + align-items: center; + gap: var(--padding-sm); + text-decoration: none; +} + +.breach-row a.breach-company:hover { + text-decoration: underline; +} + +.breach-row .breach-company .breach-logo { + height: 1.5rem; +} + +.breach-row .breach-company span.breach-logo { + width: 1.5rem; + border-radius: 1.5rem; +} .breaches-table details > article { padding: var(--padding-md); border-top: 1px solid var(--gray-10); diff --git a/src/client/images/fallback-logo.svg b/src/client/images/fallback-logo.svg new file mode 100644 index 000000000..46ee05d3d --- /dev/null +++ b/src/client/images/fallback-logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/controllers/breaches.js b/src/controllers/breaches.js index 4396cec84..891fceb5d 100644 --- a/src/controllers/breaches.js +++ b/src/controllers/breaches.js @@ -2,6 +2,10 @@ * 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/. */ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { access } from 'node:fs/promises' +import { constants } from 'node:fs' import { mainLayout } from '../views/mainLayout.js' import { breaches } from '../views/partials/breaches.js' import { setBreachResolution, updateBreachStats } from '../db/tables/subscribers.js' @@ -22,6 +26,7 @@ async function breachesPage (req, res) { const data = { breachesData, + breachLogos: await getBreachLogos(req.app.locals.breaches), emailVerifiedCount, emailTotalCount, selectedEmailIndex, @@ -34,6 +39,21 @@ async function breachesPage (req, res) { res.send(mainLayout(data)) } +async function getBreachLogos (breaches) { + const breachLogos = new Map() + await Promise.all(breaches.map(async breach => { + const logoPath = resolve(dirname(fileURLToPath(import.meta.url)), '../client/images/logo_cache/', breach.Domain + '.ico') + try { + await access(logoPath, constants.F_OK) + breachLogos.set(breach.Domain, `/images/logo_cache/${breach.Domain}.ico`) + } catch { + // Do nothing; we don't have a cached version of the logo, + // so we don't add it to the Map. + } + })) + return breachLogos +} + /** * Get breaches from the database and return a JSON object * TODO: Takes in additional query parameters: diff --git a/src/utils/hibp.js b/src/utils/hibp.js index 7137e5aeb..c6ada022d 100644 --- a/src/utils/hibp.js +++ b/src/utils/hibp.js @@ -2,10 +2,15 @@ * 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/. */ +import { get } from 'node:https' +import { createWriteStream } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import mozlog from './log.js' import AppConstants from '../app-constants.js' import { fluentError } from './fluent.js' import { getAllBreaches, upsertBreaches } from '../db/tables/breaches.js' +import { mkdir } from 'node:fs/promises' const { HIBP_THROTTLE_MAX_TRIES, HIBP_THROTTLE_DELAY, HIBP_API_ROOT, HIBP_KANON_API_ROOT, HIBP_KANON_API_TOKEN } = AppConstants // TODO: fix hardcode @@ -141,6 +146,7 @@ async function loadBreachesIntoApp (app) { // sync the "breaches" table with the latest from HIBP await upsertBreaches(breaches) } + downloadBreachIcons(breaches) app.locals.breaches = breaches app.locals.breachesLoadedDateTime = Date.now() } catch (error) { @@ -149,6 +155,29 @@ async function loadBreachesIntoApp (app) { log.info('done-loading-breaches', 'great success 👍') } +async function downloadBreachIcons (breaches) { + const breachDomains = breaches.map(breach => breach.Domain) + const logoFolder = resolve(dirname(fileURLToPath(import.meta.url)), '../client/images/logo_cache/') + try { + await mkdir(logoFolder) + } catch { + // Do nothing; if the directory already exists, that's fine. + } + breachDomains.forEach(breachDomain => { + get(`https://icons.duckduckgo.com/ip3/${breachDomain}.ico`, (response) => { + if (response.statusCode !== 200) { + return + } + + const file = createWriteStream(resolve(logoFolder, breachDomain.toLowerCase() + '.ico')) + response.pipe(file) + file.on('finish', () => { + file.close() + }) + }) + }) +} + /** * Get addresses and language from either subscribers or email_addresses fields: * diff --git a/src/views/partials/breaches.js b/src/views/partials/breaches.js index 42b5f788b..906bc0409 100644 --- a/src/views/partials/breaches.js +++ b/src/views/partials/breaches.js @@ -12,7 +12,7 @@ function createEmailOptions (data, selectedEmailIndex) { return optionElements.join('') } -function createBreachRows (data) { +function createBreachRows (data, logos) { const locale = getLocale() const shortDate = new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'UTC' }) const shortList = new Intl.ListFormat(locale, { style: 'narrow' }) @@ -31,11 +31,17 @@ function createBreachRows (data) { addedDate: longDate.format(addedDate), dataClasses: longList.format(dataClassesTranslated) }) + const logoPath = logos.get(breach.Domain) ?? '/images/fallback-logo.svg' return `
- ${breach.Title} + + + + ${breach.Title} + + ${shortList.format(dataClassesTranslated)} ${getMessage('column-status-badge-resolved')} @@ -107,7 +113,7 @@ export const breaches = data => ` } ${getMessage('column-detected')} - ${createBreachRows(data.breachesData)} + ${createBreachRows(data.breachesData, data.breachLogos)}