merge: main -> MNTOR-1486-tab-titles

This commit is contained in:
Florian Zia 2023-04-27 12:16:28 +02:00
Родитель 0184089cca 01c1a28fea
Коммит 3ba2afa546
1040 изменённых файлов: 5216 добавлений и 29802 удалений

Просмотреть файл

@ -217,25 +217,9 @@ workflows:
- lint-js
- lint-css
- lint-l10n
- unit-tests:
filters:
tags:
only: /.*/
- unit-tests-psql:
name: "unit-tests on psql 9.6"
postgres_version: "9.6.24"
filters:
tags:
only: /.*/
- unit-tests-psql:
name: "unit-tests on psql 13.7"
postgres_version: "13.7"
filters:
tags:
only: /.*/
- deploy:
requires:
- unit-tests
- lint-js
filters:
tags:
only: /.*/
@ -251,7 +235,7 @@ workflows:
name: Deploy main to Heroku
app-name: $HEROKU_MAIN_APP_NAME
requires:
- unit-tests
- lint-js
filters:
branches:
only: main
@ -260,7 +244,7 @@ workflows:
name: Deploy l10n to Heroku
app-name: $HEROKU_LOCALIZATION_APP_NAME
requires:
- unit-tests
- lint-js
filters:
branches:
only: localization

Просмотреть файл

@ -1,6 +1,3 @@
db/migration
coverage
loadtests
public/dist
tests
dist
dist
scripts

Просмотреть файл

@ -1,7 +1,7 @@
{
"env": {
"browser": true,
"es2021": true,
"es2022": true,
"node": true,
"jest": true
},
@ -51,7 +51,7 @@
"check-file/filename-naming-convention": [
"error",
{
"**/*.{js,css}": "CAMEL_CASE"
"**/*.{js,css} !src/db/migrations": "CAMEL_CASE"
},
{
"ignoreMiddleExtensions": true

7
.github/workflows/playwright.yml поставляемый
Просмотреть файл

@ -45,10 +45,7 @@ jobs:
with:
node-version: 18.12.1
- name: Install dependencies /src
run: npm install --workspace=src
- name: Install dependencies root
- name: Install dependencies
run: npm ci
- name: Copy env var
run: cp .env-dist .env
@ -60,7 +57,7 @@ jobs:
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npm run e2e --workspace=src
run: npm run e2e
env:
E2E_TEST_ENV: ${{ inputs.environment != null && inputs.environment || 'local' }}
E2E_TEST_BASE_URL: ${{ secrets.E2E_TEST_BASE_URL }}

5
.github/workflows/unittests.yaml поставляемый
Просмотреть файл

@ -13,8 +13,7 @@ jobs:
uses: actions/setup-node@v3
with:
node-version: '18.12.x'
- run: npm ci --workspace=src
- run: npm ci
- run: cp .env-dist .env
- run: npm run build --if-present --workspace=src
- run: npm test --workspace=src
- run: npm run build
- run: npm test

Просмотреть файл

@ -1,12 +0,0 @@
{
// names of npm modules to load into htmllint
"plugins": [],
"attr-bans": ["align", "background", "bgcolor", "border", "frameborder", "longdesc", "marginwidth", "marginheight", "onclick", "scrolling", "style", "width"],
"attr-name-style": "dash",
"attr-req-value": false,
"class-style": "dash",
"id-class-style": "dash",
"indent-style": "spaces",
"indent-width": 2
}

Просмотреть файл

@ -1 +0,0 @@
tests/

Просмотреть файл

@ -4,10 +4,14 @@
],
"rules": {
"alpha-value-notation": "number",
"import-notation": "string",
"no-descending-specificity": [
true,
{
"severity": "warning"
"severity": "warning",
"ignore": [
"selectors-within-list"
]
}
],
"property-no-vendor-prefix": null,

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
// See https://jestjs.io/docs/en/manual-mocks
function MessageValidator () {}
MessageValidator.prototype.validate = function (hash, cb) {
if (hash.Signature === 'invalid') {
cb(new Error('The message signature is invalid.'))
return
}
cb(null, hash)
}
module.exports = MessageValidator

Просмотреть файл

@ -1,85 +0,0 @@
'use strict'
/* eslint-disable no-process-env */
const path = require('path')
const fs = require('fs')
require('dotenv').config({ path: path.join(__dirname, '.env') })
const requiredEnvVars = [
'NODE_ENV',
'SERVER_URL',
'LOGOS_ORIGIN',
'COOKIE_SECRET',
'SESSION_DURATION_HOURS',
'SMTP_URL',
'EMAIL_FROM',
'SES_CONFIG_SET',
'SES_NOTIFICATION_LOG_ONLY',
'BREACH_RESOLUTION_ENABLED',
'FXA_ENABLED',
'FXA_SETTINGS_URL',
'FX_REMOTE_SETTINGS_WRITER_SERVER',
'FX_REMOTE_SETTINGS_WRITER_USER',
'FX_REMOTE_SETTINGS_WRITER_PASS',
'MOZLOG_FMT',
'MOZLOG_LEVEL',
'OAUTH_AUTHORIZATION_URI',
'OAUTH_TOKEN_URI',
'OAUTH_PROFILE_URI',
'OAUTH_CLIENT_ID',
'OAUTH_CLIENT_SECRET',
'HIBP_KANON_API_ROOT',
'HIBP_KANON_API_TOKEN',
'HIBP_API_ROOT',
'HIBP_RELOAD_BREACHES_TIMER',
'HIBP_THROTTLE_DELAY',
'HIBP_THROTTLE_MAX_TRIES',
'HIBP_NOTIFY_TOKEN',
'DATABASE_URL',
'PAGE_TOKEN_TIMER',
'PRODUCT_PROMOS_ENABLED',
'REDIS_URL',
'SENTRY_DSN_LEGACY',
'DELETE_UNVERIFIED_SUBSCRIBERS_TIMER',
'EXPERIMENT_ACTIVE',
'MAX_NUM_ADDRESSES'
]
const optionalEnvVars = [
'PORT',
'RECRUITMENT_BANNER_LINK',
'RECRUITMENT_BANNER_TEXT',
'GEOIP_GEOLITE2_PATH',
'GEOIP_GEOLITE2_CITY_FILENAME',
'GEOIP_GEOLITE2_COUNTRY_FILENAME',
'VPN_PROMO_BLOCKED_LOCALES',
'EDUCATION_VIDEO_URL_RELAY',
'EDUCATION_VIDEO_URL_VPN',
'ADMINS',
'MONTHLY_CRON_ENABLED',
'MONTHLY_CRON_LIMIT'
]
const AppConstants = { }
if (!process.env.SERVER_URL && process.env.NODE_ENV === 'heroku') {
process.env.SERVER_URL = `https://${process.env.HEROKU_APP_NAME}.herokuapp.com`
}
for (const v of requiredEnvVars) {
if (process.env[v] === undefined) {
throw new Error(`Required environment variable was not set: ${v}`)
}
AppConstants[v] = process.env[v]
}
optionalEnvVars.forEach(key => {
if (key in process.env) AppConstants[key] = process.env[key]
})
AppConstants.VPN_PROMO_BLOCKED_LOCALES = AppConstants.VPN_PROMO_BLOCKED_LOCALES?.split(',')
AppConstants.AD_UNIT_TOTAL = fs.readdirSync(path.join(__dirname, 'views/partials/ad-units')).length
module.exports = Object.freeze(AppConstants)

Просмотреть файл

@ -1,64 +0,0 @@
'use strict'
const HIBP = require('../hibp')
const DB = require('../db/DB')
const { changePWLinks } = require('../lib/changePWLinks')
const { getAllEmailsAndBreaches } = require('./user')
async function getBreachDetail (req, res) {
const allBreaches = req.app.locals.breaches
const breachName = req.params.breachName
const featuredBreach = HIBP.getBreachByName(allBreaches, breachName)
if (!featuredBreach) {
return res.redirect('/')
}
const affectedEmails = []
if (req.session && req.session.user) {
const user = await DB.getSubscriberById(req.session.user.id)
req.session.user = user
const allEmailsAndBreaches = await getAllEmailsAndBreaches(req.session.user, allBreaches)
for (const verifiedEmail of allEmailsAndBreaches.verifiedEmails) {
for (const breach of verifiedEmail.breaches) {
if (breach.Name === breachName) {
affectedEmails.push({
emailAddress: verifiedEmail.email,
recencyIndex: breach.recencyIndex,
isResolved: breach.IsResolved
})
}
}
}
}
const changePWLink = getChangePWLink(featuredBreach)
res.render('breach-detail', {
title: req.fluentFormat('home-title'),
featuredBreach,
changePWLink,
affectedEmails
})
}
function getChangePWLink (breach) {
if (!breach.DataClasses.includes('passwords')) {
return ''
}
if (changePWLinks.hasOwnProperty(breach.Name)) {
return changePWLinks[breach.Name]
}
if (breach.Domain) {
return 'https://www.' + breach.Domain
}
return ''
}
module.exports = {
getBreachDetail
}

Просмотреть файл

@ -1,55 +0,0 @@
'use strict'
const fs = require('fs')
const path = require('path')
const AppConstants = require('../app-constants')
const mozlog = require('../log')
const { version, homepage, supportedLocales } = require('../package.json')
const log = mozlog('controllers.dockerflow')
const versionJsonPath = path.join(__dirname, '..', 'version.json')
// If the version.json file already exists (e.g., created by circle + docker),
// don't need to generate it
if (!fs.existsSync(versionJsonPath)) {
log.info('generating')
let commit
try {
commit = require('git-rev-sync').short()
} catch (err) {
log.error('generating', { err })
}
const versionJson = {
commit,
source: homepage,
version,
languages: supportedLocales
}
fs.writeFileSync(versionJsonPath, JSON.stringify(versionJson, null, 2) + '\n')
}
function vers (req, res) {
if (AppConstants.NODE_ENV === 'heroku') {
/* eslint-disable no-process-env */
return res.json({
commit: process.env.HEROKU_SLUG_COMMIT,
version: process.env.HEROKU_SLUG_COMMIT,
source: homepage,
languages: '*'
})
/* eslint-enable no-process-env */
}
return res.sendFile(versionJsonPath)
}
function heartbeat (req, res) {
return res.send('OK')
}
module.exports = {
vers,
heartbeat
}

Просмотреть файл

@ -1,142 +0,0 @@
'use strict'
const EmailUtils = require('../email-utils')
const AppConstants = require('../app-constants')
const path = require('path')
const { readFile } = require('fs/promises')
const partials = ['alert', 'report', 'email_verify', 'email-monthly-unresolved']
const mockBreaches = ['Dropbox', 'Apollo', 'Adobe']
const mockBreachStats = {
passwords: {
count: 1,
numResolved: 0
},
numBreaches: {
count: 2,
numResolved: 0,
numUnresolved: 2
},
monitoredEmails: {
count: 2
}
}
async function getEmailMockup (req, res) {
if (!['dev', 'heroku', 'stage'].includes(AppConstants.NODE_ENV)) return res.sendStatus(404)
if (!req.query.partial) {
return res.redirect('/email-l10n/?partial=email_verify&type=email_verify') // default when no specific partial requested
} else if (!partials.includes(req.query.partial)) {
return res.sendStatus(404) // requested partial is not in partials list
}
const emailStr = await readFile(path.resolve('views/layouts/email-2022.hbs'), { encoding: 'utf-8' })
const emailStyle = emailStr.substring(emailStr.indexOf('<style>'), emailStr.indexOf('</style>') + 8)
const { unsafeBreachesForEmail, emailSubject, breachAlert, unsubscribeUrl, heading, subheading } = getPartialData(req.query.type, req.app.locals.breaches)
const utmCampaign = req.query.type
res.render('email_l10n', {
layout: 'email_l10n_mockups.hbs',
unsafeBreachesForEmail,
supportedLocales: req.supportedLocales,
whichPartial: `email_partials/${req.query.partial}`,
partialType: req.query.type,
breachedEmail: req.user?.primary_email || 'breachedEmail@testing.com',
breachAlert,
emailSubject: req.fluentFormat(emailSubject),
heading: req.fluentFormat(heading),
subheading: req.fluentFormat(subheading),
ctaHref: EmailUtils.getEmailCtaHref(utmCampaign, 'dashboard-cta'),
utmCampaign,
emailStyle,
unsubscribeUrl,
csrfToken: req.csrfToken(),
breachStats: mockBreachStats
})
}
function getPartialData (partialType, breaches) {
const unsafeBreachesForEmail = []
let emailSubject, breachAlert, unsubscribeUrl, heading, subheading
switch (partialType) {
case 'email_verify':
emailSubject = 'email-subject-verify'
heading = 'email-verify-heading'
subheading = 'email-verify-subhead'
break
case 'noBreaches':
emailSubject = 'email-subject-no-breaches'
heading = 'email-breach-summary'
break
case 'singleBreach':
emailSubject = 'email-subject-found-breaches'
heading = 'email-breach-summary'
unsafeBreachesForEmail.push(breaches.find(breach => breach.Name === mockBreaches[0]))
break
case 'multipleBreaches':
emailSubject = 'email-subject-found-breaches'
heading = 'email-breach-summary'
mockBreaches.forEach(name => {
unsafeBreachesForEmail.push(breaches.find(breach => breach.Name === name))
})
break
case 'alert':
emailSubject = 'breach-alert-subject'
heading = 'email-spotted-new-breach'
breachAlert = breaches.find(breach => breach.Name === 'LinkedIn')
break
case 'email-monthly-unresolved':
emailSubject = 'email-unresolved-heading'
heading = 'email-unresolved-heading'
subheading = 'email-unresolved-subhead'
unsubscribeUrl = 'fakeunsubscribe.test.com'
break
}
return { unsafeBreachesForEmail, emailSubject, breachAlert, unsubscribeUrl, heading, subheading }
}
async function sendTestEmail (req, res) {
const { unsafeBreachesForEmail, emailSubject, breachAlert, heading, subheading } = getPartialData(req.body.partialType, req.app.locals.breaches)
const supportedLocales = req.supportedLocales
const unsubscribeUrl = EmailUtils.getMonthlyUnsubscribeUrl(req.user, 'monthly-unresolved', 'unsubscribe-cta')
const utmCampaign = req.body.partialType
const context = {
whichPartial: req.body.whichPartial,
supportedLocales,
heading: req.fluentFormat(heading),
subheading: req.fluentFormat(subheading),
breachedEmail: req.user.primary_email,
unsafeBreachesForEmail,
breachAlert,
breachStats: req.user.breach_stats,
unsubscribeUrl,
ctaHref: EmailUtils.getEmailCtaHref(utmCampaign, 'dashboard-cta'),
utmCampaign
}
await EmailUtils.sendEmail(req.body.recipientEmail, req.fluentFormat(emailSubject), 'email-2022', context)
res.send(`
<h2>Email sent!</h2>
<a href='/email-l10n/'>Go Back</a> | <a href='/user/logout'>Sign Out</a>
`)
}
function notFound (req, res) {
res.status(404)
res.render('subpage', {
analyticsID: 'error',
headline: req.fluentFormat('error-headline'),
subhead: req.fluentFormat('home-not-found')
})
}
module.exports = {
getEmailMockup,
notFound,
sendTestEmail
}

Просмотреть файл

@ -1,141 +0,0 @@
'use strict'
const { negotiateLanguages, acceptedLanguages } = require('fluent-langneg')
const AppConstants = require('../app-constants')
const DB = require('../db/DB')
const EmailUtils = require('../email-utils')
const HIBP = require('../hibp')
const { LocaleUtils } = require('../locale-utils')
const mozlog = require('../log')
const log = mozlog('controllers.hibp')
// Get addresses and language from either subscribers or
// email_addresses fields
function getAddressesAndLanguageForEmail (recipient) {
const signupLanguage = recipient.signup_language
if (recipient.hasOwnProperty('email') && recipient.email) {
// email_addresses record, check all_emails_to_primary
if (recipient.all_emails_to_primary) {
return {
recipientEmail: recipient.primary_email,
breachedEmail: recipient.email,
signupLanguage
}
}
return {
recipientEmail: recipient.email,
breachedEmail: recipient.email,
signupLanguage
}
}
return {
recipientEmail: recipient.primary_email,
breachedEmail: recipient.primary_email,
signupLanguage
}
}
/**
* Whenever a breach is detected on the HIBP side, HIBP sends a request to this endpoint.
* A breach notification contains the following parameters:
* - breachName
* - hashPrefix
* - hashSuffixes
* More about how account identities are anonymized: https://blog.mozilla.org/security/2018/06/25/scanning-breached-accounts-k-anonymity/
*/
async function notify (req, res) {
if (!req.token || req.token !== AppConstants.HIBP_NOTIFY_TOKEN) {
const errorMessage = 'HIBP notify endpoint requires valid authorization token.'
throw new Error(errorMessage)
}
if (!['breachName', 'hashPrefix', 'hashSuffixes'].every(req.body.hasOwnProperty, req.body)) {
throw new Error('Breach notification requires breachName, hashPrefix, and hashSuffixes.')
}
const reqBreachName = req.body.breachName.toLowerCase()
const reqHashPrefix = req.body.hashPrefix.toLowerCase()
let breachAlert = HIBP.getBreachByName(req.app.locals.breaches, reqBreachName)
if (!breachAlert) {
// If breach isn't found, try to reload breaches from HIBP
await HIBP.loadBreachesIntoApp(req.app)
breachAlert = HIBP.getBreachByName(req.app.locals.breaches, reqBreachName)
if (!breachAlert) {
// If breach *still* isn't found, we have a real error
throw new Error('Unrecognized breach: ' + reqBreachName)
}
}
if (breachAlert.IsSpamList || breachAlert.IsFabricated || !breachAlert.IsVerified || breachAlert.Domain === '') {
log.info(`${breachAlert.Name} is fabricated, a spam list, not associated with a website, or unverified. \n Breach Alert not sent.`)
return res.status(200).json(
{ info: 'Breach loaded into database. Subscribers not notified.' }
)
}
const hashes = req.body.hashSuffixes.map(suffix => reqHashPrefix + suffix.toLowerCase())
const subscribers = await DB.getSubscribersByHashes(hashes)
const emailAddresses = await DB.getEmailAddressesByHashes(hashes)
const recipients = subscribers.concat(emailAddresses)
log.info('notification', { length: recipients.length, breachAlertName: breachAlert.Name })
const utmID = 'breach-alert'
const notifiedRecipients = []
for (const recipient of recipients) {
log.info('notify', { recipient })
// Get subscriber ID from "subscriber_id" property (if email_addresses record)
// or from "id" property (if subscribers record)
const subscriberId = recipient.subscriber_id || recipient.id
const { recipientEmail, breachedEmail, signupLanguage } = getAddressesAndLanguageForEmail(recipient)
const ctaHref = EmailUtils.getEmailCtaHref(utmID, 'dashboard-cta', subscriberId)
const requestedLanguage = signupLanguage ? acceptedLanguages(signupLanguage) : ''
const supportedLocales = negotiateLanguages(
requestedLanguage,
req.app.locals.AVAILABLE_LANGUAGES,
{ defaultLocale: 'en' }
)
const subject = LocaleUtils.fluentFormat(supportedLocales, 'breach-alert-subject')
const heading = LocaleUtils.fluentFormat(supportedLocales, 'email-spotted-new-breach')
const template = 'email-2022'
if (!notifiedRecipients.includes(breachedEmail)) {
await EmailUtils.sendEmail(
recipientEmail, subject, template,
{
breachedEmail,
recipientEmail,
subscriberId,
supportedLocales,
breachAlert,
SERVER_URL: AppConstants.SERVER_URL,
unsubscribeUrl: EmailUtils.getUnsubscribeUrl(recipient, utmID),
ctaHref,
utmCampaign: utmID,
whichPartial: 'email_partials/alert',
heading
}
)
notifiedRecipients.push(breachedEmail)
}
}
log.info('notified', { length: notifiedRecipients.length })
res.status(200)
res.json(
{ info: 'Notified subscribers of breach.' }
)
}
async function breaches (req, res, next) {
res.append('Last-Modified', req.app.locals.mostRecentBreachDateTime)
res.json(req.app.locals.breaches)
}
module.exports = {
notify,
breaches
}

Просмотреть файл

@ -1,169 +0,0 @@
'use strict'
const AppConstants = require('../app-constants')
const DB = require('../db/DB')
const HIBP = require('../hibp')
const { scanResult } = require('../scan-results')
const {
generatePageToken,
getExperimentFlags,
getUTMContents,
setAdUnitCookie
} = require('./utils')
const EXPERIMENTS_ENABLED = (AppConstants.EXPERIMENT_ACTIVE === '1')
function _getFeaturedBreach (allBreaches, breachQueryValue) {
if (!breachQueryValue) {
return null
}
const lowercaseBreachValue = breachQueryValue.toLowerCase()
return HIBP.getBreachByName(allBreaches, lowercaseBreachValue)
}
async function home (req, res) {
const formTokens = {
pageToken: AppConstants.PAGE_TOKEN_TIMER > 0 ? generatePageToken(req) : '',
csrfToken: req.csrfToken()
}
const adUnitNum = setAdUnitCookie(req, res)
let featuredBreach = null
let scanFeaturedBreach = false
if (req.session.user && !req.query.breach) {
return res.redirect('/user/dashboard')
}
// Rewrites the /share/{COLOR} links to /
if (req.session.redirectHome) {
req.session.redirectHome = false
return res.redirect('/')
}
// Note - If utmOverrides get set, they are unenrolled from the experiment
const utmOverrides = getUTMContents(req)
const experimentFlags = getExperimentFlags(req, EXPERIMENTS_ENABLED)
if (req.params && req.params.breach) {
req.query.breach = req.params.breach
}
if (req.query.breach) {
featuredBreach = _getFeaturedBreach(req.app.locals.breaches, req.query.breach)
if (!featuredBreach) {
return notFound(req, res)
}
const scanRes = await scanResult(req)
if (scanRes.doorhangerScan) {
return res.render('scan', Object.assign(scanRes, formTokens))
}
scanFeaturedBreach = true
return res.render('monitor', {
title: req.fluentFormat('home-title'),
featuredBreach,
scanFeaturedBreach,
pageToken: formTokens.pageToken,
csrfToken: formTokens.csrfToken,
experimentFlags,
utmOverrides,
adUnit: `ad-units/ad-unit-${adUnitNum}`
})
}
res.render('monitor', {
title: req.fluentFormat('home-title'),
featuredBreach,
scanFeaturedBreach,
pageToken: formTokens.pageToken,
csrfToken: formTokens.csrfToken,
experimentFlags,
utmOverrides,
adUnit: `ad-units/ad-unit-${adUnitNum}`
})
}
function getAllBreaches (req, res) {
return res.render('top-level-page', {
title: 'Firefox Monitor',
whichPartial: 'top-level/all-breaches'
})
}
function getSecurityTips (req, res) {
return res.render('top-level-page', {
title: req.fluentFormat('home-title'),
whichPartial: 'top-level/security-tips'
})
}
function getAboutPage (req, res) {
return res.render('about', {
title: req.fluentFormat('about-firefox-monitor')
})
}
function getBentoStrings (req, res) {
const localizedBentoStrings = {
bentoButtonTitle: req.fluentFormat('bento-button-title'),
bentoHeadline: req.fluentFormat('fx-makes-tech'),
bentoBottomLink: req.fluentFormat('made-by-mozilla'),
fxDesktop: req.fluentFormat('fx-desktop'),
fxMobile: req.fluentFormat('fx-mobile'),
fxMonitor: req.fluentFormat('fx-monitor'),
pocket: req.fluentFormat('pocket'),
mozVPN: 'Mozilla VPN',
mobileCloseBentoButtonTitle: req.fluentFormat('mobile-close-bento-button-title')
}
return res.json(localizedBentoStrings)
}
function _addToWaitlistsJoined (user, waitlist) {
if (!user.waitlists_joined) {
return { [waitlist]: { notified: false } }
}
user.waitlists_joined[waitlist] = { notified: false }
return user.waitlists_joined
}
function addEmailToWaitlist (req, res) {
if (!req.user) {
return res.redirect('/')
}
const user = req.user
const updatedWaitlistsJoined = _addToWaitlistsJoined(user, 'remove_data')
DB.setWaitlistsJoined({ user, updatedWaitlistsJoined })
return res.json('email-added')
}
function testSentry (req, res) {
if (!req.user || !req.user.primary_email.endsWith('@mozilla.com')) {
return res.redirect('/')
}
throw new Error('Successfully tested exception handling')
}
function notFound (req, res) {
res.status(404)
res.render('subpage', {
analyticsID: 'error',
headline: req.fluentFormat('error-headline'),
subhead: req.fluentFormat('home-not-found')
})
}
module.exports = {
addEmailToWaitlist,
getAboutPage,
getAllBreaches,
getBentoStrings,
getSecurityTips,
home,
notFound,
testSentry
}

Просмотреть файл

@ -1,40 +0,0 @@
// IP location data includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com.
// For testing, you can compare IPs to the corresponding json, eg: https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json
'use strict'
const AppConstants = require('../app-constants')
const { readLocationData } = require('../ip-location-service')
async function getIpLocation (req, res) {
let clientIp
switch (AppConstants.NODE_ENV) {
case 'dev':
clientIp = req.headers['test-ip'] || '216.160.83.56' // fallback an IP that exists in limited/local GeoLite2 test DB
break
case 'heroku':
case 'stage':
clientIp = req.headers['test-ip'] || req.ip // Use "test-ip" header if available, fallback to original IP
break
default:
clientIp = req.ip
}
if (clientIp === req.session.locationData?.clientIp) {
return res.status(200).json(req.session.locationData) // return cached data
}
const locationData = await readLocationData(clientIp, req.supportedLocales)
if (locationData) {
req.session.locationData = Object.assign(locationData, { clientIp }) // cache new location data, adding clientIP key
return res.status(200).json(req.session.locationData)
}
return res.status(200).json({ clientIp })
}
module.exports = {
getIpLocation
}

Просмотреть файл

@ -1,114 +0,0 @@
'use strict'
const { URL } = require('url')
const crypto = require('crypto')
const AppConstants = require('../app-constants')
const DB = require('../db/DB')
const EmailUtils = require('../email-utils')
const { FXA, FxAOAuthClient } = require('../lib/fxa')
const { FluentError } = require('../locale-utils')
const HIBP = require('../hibp')
const mozlog = require('../log')
const sha1 = require('../sha1-utils')
const log = mozlog('controllers.oauth')
function init (req, res, next, client = FxAOAuthClient) {
// Set a random state string in a cookie so that we can verify
// the user when they're redirected back to us after auth.
const state = crypto.randomBytes(40).toString('hex')
req.session.state = state
const url = new URL(client.code.getUri({ state }))
const fxaParams = new URL(req.url, AppConstants.SERVER_URL)
req.session.utmContents = {}
url.searchParams.append('access_type', 'offline')
url.searchParams.append('action', 'email')
for (const param of fxaParams.searchParams.keys()) {
url.searchParams.append(param, fxaParams.searchParams.get(param))
}
res.redirect(url)
}
async function confirmed (req, res, next, client = FxAOAuthClient) {
if (!req.session.state) {
throw new FluentError('oauth-invalid-session')
}
if (req.session.state !== req.query.state) {
throw new FluentError('oauth-invalid-session')
}
const fxaUser = await client.code.getToken(req.originalUrl, { state: req.session.state })
// Clear the session.state to clean up and avoid any replays
req.session.state = null
log.debug('fxa-confirmed-fxaUser', fxaUser)
const fxaProfileData = await FXA.getProfileData(fxaUser.accessToken)
log.debug('fxa-confirmed-profile-data', fxaProfileData)
const email = JSON.parse(fxaProfileData).email
const existingUser = await DB.getSubscriberByEmail(email)
req.session.user = existingUser
const returnURL = new URL('/user/dashboard', AppConstants.SERVER_URL)
const originalURL = new URL(req.originalUrl, AppConstants.SERVER_URL)
for (const [key, value] of originalURL.searchParams.entries()) {
if (key.startsWith('utm_')) returnURL.searchParams.append(key, value)
}
// Check if user is signing up or signing in,
// then add new users to db and send email.
if (!existingUser || existingUser.fxa_refresh_token === null) {
// req.session.newUser determines whether or not we show "fxa_new_user_bar" in template
req.session.newUser = true
const signupLanguage = req.headers['accept-language']
const verifiedSubscriber = await DB.addSubscriber(email, signupLanguage, fxaUser.accessToken, fxaUser.refreshToken, fxaProfileData)
// duping some of user/verify for now
let unsafeBreachesForEmail = []
unsafeBreachesForEmail = await HIBP.getBreachesForEmail(
sha1(email),
req.app.locals.breaches,
true
)
const utmID = 'report'
const reportSubject = unsafeBreachesForEmail.length ? req.fluentFormat('email-subject-found-breaches') : req.fluentFormat('email-subject-no-breaches')
await EmailUtils.sendEmail(
email,
reportSubject,
'email-2022',
{
supportedLocales: req.supportedLocales,
breachedEmail: email,
recipientEmail: email,
date: req.fluentFormat(new Date()),
unsafeBreachesForEmail,
ctaHref: EmailUtils.getEmailCtaHref(utmID, 'dashboard-cta'),
utmCampaign: utmID,
unsubscribeUrl: EmailUtils.getUnsubscribeUrl(verifiedSubscriber, utmID),
whichPartial: 'email_partials/report',
heading: req.fluentFormat('email-breach-summary')
}
)
req.session.user = verifiedSubscriber
return res.redirect(returnURL.pathname + returnURL.search)
}
// Update existing user's FxA data
await DB._updateFxAData(existingUser, fxaUser.accessToken, fxaUser.refreshToken, fxaProfileData)
res.redirect(returnURL.pathname + returnURL.search)
}
module.exports = {
init,
confirmed
}

Просмотреть файл

@ -1,104 +0,0 @@
'use strict'
const crypto = require('crypto')
const AppConstants = require('../app-constants')
const { FluentError } = require('../locale-utils')
// const { generatePageToken } = require("./utils");
const mozlog = require('../log')
const { scanResult } = require('../scan-results')
const sha1 = require('../sha1-utils')
const log = mozlog('controllers.scan')
function _decryptPageToken (encryptedPageToken) {
let decipher
if (encryptedPageToken.slice(24, 25) === '.') {
// iv becomes 24 characters long when represented as base64: ceil(16 / 3) * 4 = 24
const iv = Buffer.from(encryptedPageToken.slice(0, 24), 'base64')
const key = crypto.pbkdf2Sync(AppConstants.COOKIE_SECRET, iv.toString(), 10000, 32, 'sha512')
decipher = crypto.createDecipheriv('aes-256-cbc', key, iv)
encryptedPageToken = encryptedPageToken.slice(25)
} else {
// TODO: replace deprecated api
// eslint-disable-next-line n/no-deprecated-api
decipher = crypto.createDecipher('aes-256-cbc', AppConstants.COOKIE_SECRET)
}
const decryptedPageToken = Buffer.concat([
decipher.update(Buffer.from(encryptedPageToken, 'base64')),
decipher.final()
]).toString('utf8')
return JSON.parse(decryptedPageToken)
}
function _validatePageToken (pageToken, req) {
const requestIP = req.headers['x-real-ip'] || req.ip
const pageTokenIP = pageToken.ip
if (pageToken.ip !== requestIP) {
log.error('_validatePageToken', { msg: 'IP mis-match', pageTokenIP, requestIP })
return false
}
if (Date.now() - new Date(pageToken.date) >= AppConstants.PAGE_TOKEN_TIMER * 1000) {
log.error('_validatePageToken', { msg: 'expired pageToken' })
return false
}
/* TODO: block on scans-per-ip instead of scans-per-timespan
if (req.session.scans.length > 5) {
console.log("too many scans this session");
return res.render("error");
}
if (!req.session.scans.includes(emailHash)) {
console.log(`adding ${emailHash} to session scans`);
req.session.scans.push(emailHash);
}
*/
return pageToken
}
async function post (req, res) {
const emailHash = req.body.emailHash
const encryptedPageToken = req.body.pageToken
let validPageToken = false
// for #688: use a page token to check that scans come from real pages
if (AppConstants.PAGE_TOKEN_TIMER > 0) {
if (!encryptedPageToken) {
throw new FluentError('error-scan-page-token')
}
const decryptedPageToken = _decryptPageToken(encryptedPageToken)
validPageToken = _validatePageToken(decryptedPageToken, req)
if (!validPageToken) {
throw new FluentError('error-scan-page-token')
}
}
if (!emailHash || emailHash === sha1('')) {
return res.redirect('/')
}
const scanRes = await scanResult(req)
const formTokens = {
pageToken: encryptedPageToken,
csrfToken: req.csrfToken()
}
if (req.session.user && scanRes.selfScan && !req.body.featuredBreach) {
return res.redirect('/user/dashboard')
}
res.render('scan', Object.assign(scanRes, formTokens))
}
function get (req, res) {
res.redirect('/')
}
module.exports = {
post,
get
}

Просмотреть файл

@ -1,95 +0,0 @@
'use strict'
const MessageValidator = require('sns-validator')
const DB = require('../db/DB')
const mozlog = require('../log')
const validator = new MessageValidator()
const log = mozlog('controllers.ses')
async function notification (req, res) {
const message = JSON.parse(req.body)
return new Promise((resolve, reject) => {
validator.validate(message, async (err, message) => {
if (err) {
log.error('notification', { err })
const body = 'Access denied. ' + err.message
res.status(401).send(body)
return reject(body)
}
await handleNotification(message)
res.status(200).json(
{ status: 'OK' }
)
return resolve('OK')
})
})
}
async function handleNotification (notification) {
log.info('received-SNS', { id: notification.MessageId })
const message = JSON.parse(notification.Message)
if (message.hasOwnProperty('eventType')) {
await handleSESMessage(message)
}
if (message.hasOwnProperty('event')) {
await handleFxAMessage(message)
}
}
async function handleFxAMessage (message) {
switch (message.event) {
case 'delete':
await handleDeleteMessage(message)
break
default:
log.info('unhandled-event', { event: message.event })
}
}
async function handleDeleteMessage (message) {
await DB.deleteSubscriberByFxAUID(message.uid)
}
async function handleSESMessage (message) {
switch (message.eventType) {
case 'Bounce':
await handleBounceMessage(message)
break
case 'Complaint':
await handleComplaintMessage(message)
break
default:
log.info('unhandled-eventType', { type: message.eventType })
}
}
async function handleBounceMessage (message) {
const bounce = message.bounce
if (bounce.bounceType === 'Permanent') {
return await removeSubscribersFromDB(bounce.bouncedRecipients)
}
}
async function handleComplaintMessage (message) {
const complaint = message.complaint
return await removeSubscribersFromDB(complaint.complainedRecipients)
}
async function removeSubscribersFromDB (recipients) {
for (const recipient of recipients) {
await DB.removeEmail(recipient.emailAddress)
}
}
module.exports = {
notification,
handleNotification,
handleBounceMessage,
handleComplaintMessage,
removeSubscribersFromDB
}

Просмотреть файл

@ -1,633 +0,0 @@
'use strict'
const AppConstants = require('../app-constants')
const isemail = require('isemail')
const DB = require('../db/DB')
const EmailUtils = require('../email-utils')
const { FluentError, LocaleUtils } = require('../locale-utils')
const { FXA } = require('../lib/fxa')
const HIBP = require('../hibp')
const { resultsSummary } = require('../scan-results')
const sha1 = require('../sha1-utils')
const EXPERIMENTS_ENABLED = (AppConstants.EXPERIMENT_ACTIVE === '1')
const {
getExperimentFlags,
getUTMContents,
setAdUnitCookie
} = require('./utils')
const FXA_MONITOR_SCOPE = 'https://identity.mozilla.com/apps/monitor'
async function removeEmail (req, res) {
const emailId = req.body.emailId
const sessionUser = req.user
const existingEmail = await DB.getEmailById(emailId)
if (existingEmail.subscriber_id !== sessionUser.id) {
throw new FluentError('error-not-subscribed')
}
DB.removeOneSecondaryEmail(emailId)
res.redirect('/user/preferences')
}
async function resendEmail (req, res) {
const emailId = req.body.emailId
const sessionUser = req.user
const existingEmail = await DB.getEmailById(emailId)
if (!existingEmail || !existingEmail.subscriber_id) {
throw new FluentError('user-verify-token-error')
}
if (existingEmail.subscriber_id !== sessionUser.id) {
// TODO: more specific error message?
throw new FluentError('user-verify-token-error')
}
const unverifiedEmailAddressRecord = await DB.resetUnverifiedEmailAddress(emailId)
const email = unverifiedEmailAddressRecord.email
await EmailUtils.sendEmail(
email,
req.fluentFormat('email-subject-verify'),
'email-2022',
{
recipientEmail: email,
supportedLocales: req.supportedLocales,
ctaHref: EmailUtils.getVerificationUrl(unverifiedEmailAddressRecord),
utmCampaign: 'email_verify',
unsubscribeUrl: EmailUtils.getUnsubscribeUrl(unverifiedEmailAddressRecord, 'account-verification-email'),
whichPartial: 'email_partials/email_verify',
heading: req.fluentFormat('email-verify-heading'),
subheading: req.fluentFormat('email-verify-subhead')
}
)
// TODO: what should we return to the client?
return res.json('Resent the email')
}
async function updateCommunicationOptions (req, res) {
const sessionUser = req.user
// 0 = Send breach alerts to the email address found in brew breach.
// 1 = Send all breach alerts to user's primary email address.
const allEmailsToPrimary = (Number(req.body.communicationOption) === 1)
const updatedSubscriber = await DB.setAllEmailsToPrimary(sessionUser, allEmailsToPrimary)
req.session.user = updatedSubscriber
return res.json('Comm options updated')
}
async function resolveBreach (req, res) {
const sessionUser = req.user
// TODO: verify that req.body.emailAddressId belongs to sessionUser
const updatedSubscriber = await DB.setResolvedBreach({
subscriber: sessionUser,
emailAddresses: req.body.emailAddressId,
recencyIndex: req.body.recencyIndex
})
req.session.user = updatedSubscriber
return res.json('Breach marked as resolved.')
}
function _checkForDuplicateEmail (sessionUser, email) {
email = email.toLowerCase()
if (email === sessionUser.primary_email.toLowerCase()) {
throw new FluentError('user-add-duplicate-email')
}
for (const secondaryEmail of sessionUser.email_addresses) {
if (email === secondaryEmail.email.toLowerCase()) {
throw new FluentError('user-add-duplicate-email')
}
}
}
async function add (req, res) {
const sessionUser = await req.user
const email = req.body.email
if (!email || !isemail.validate(email)) {
throw new FluentError('user-add-invalid-email')
}
if (sessionUser.email_addresses.length >= AppConstants.MAX_NUM_ADDRESSES) {
throw new FluentError('user-add-too-many-emails')
}
_checkForDuplicateEmail(sessionUser, email)
const unverifiedSubscriber = await DB.addSubscriberUnverifiedEmailHash(
req.session.user, email
)
await EmailUtils.sendEmail(
email,
req.fluentFormat('email-subject-verify'),
'email-2022',
{
breachedEmail: email,
recipientEmail: email,
supportedLocales: req.supportedLocales,
ctaHref: EmailUtils.getVerificationUrl(unverifiedSubscriber),
utmCampaign: 'email_verify',
unsubscribeUrl: EmailUtils.getUnsubscribeUrl(unverifiedSubscriber, 'account-verification-email'),
whichPartial: 'email_partials/email_verify',
heading: req.fluentFormat('email-verify-heading'),
subheading: req.fluentFormat('email-verify-subhead')
}
)
// send users coming from the dashboard back to the dashboard
// and set req.session.lastAddedEmail to show a confirmation message.
if (req.headers.referer.endsWith('/dashboard')) {
req.session.lastAddedEmail = email
return res.redirect('/user/dashboard')
}
res.redirect('/user/preferences')
}
function getResolvedBreachesForEmail (user, email) {
if (user.breaches_resolved === null) {
return []
}
return user.breaches_resolved.hasOwnProperty(email) ? user.breaches_resolved[email] : []
}
function addResolvedOrNot (foundBreaches, resolvedBreaches) {
const annotatedBreaches = []
if (AppConstants.BREACH_RESOLUTION_ENABLED !== '1') {
return foundBreaches
}
for (const breach of foundBreaches) {
const IsResolved = !!resolvedBreaches.includes(breach.recencyIndex)
annotatedBreaches.push(Object.assign({ IsResolved }, breach))
}
return annotatedBreaches
}
function addRecencyIndex (foundBreaches) {
const annotatedBreaches = []
// slice() the array to make a copy so before reversing so we don't
// reverse foundBreaches in-place
const oldestToNewestFoundBreaches = foundBreaches.slice().reverse()
oldestToNewestFoundBreaches.forEach((annotatingBreach, index) => {
const foundBreach = foundBreaches.find(foundBreach => foundBreach.Name === annotatingBreach.Name)
annotatedBreaches.push(Object.assign({ recencyIndex: index }, foundBreach))
})
return annotatedBreaches.reverse()
}
async function bundleVerifiedEmails (options) {
const { user, email, recordId, recordVerified, allBreaches } = options
const lowerCaseEmailSha = sha1(email.toLowerCase())
const foundBreaches = await HIBP.getBreachesForEmail(lowerCaseEmailSha, allBreaches, true, false)
const foundBreachesWithRecency = addRecencyIndex(foundBreaches)
const resolvedBreaches = getResolvedBreachesForEmail(user, email)
const foundBreachesWithResolutions = addResolvedOrNot(foundBreachesWithRecency, resolvedBreaches)
const filteredAnnotatedFoundBreaches = HIBP.filterBreaches(foundBreachesWithResolutions)
const emailEntry = {
email,
breaches: filteredAnnotatedFoundBreaches,
primary: email === user.primary_email,
id: recordId,
verified: recordVerified
}
return emailEntry
}
async function getAllEmailsAndBreaches (user, allBreaches) {
const monitoredEmails = await DB.getUserEmails(user.id)
let verifiedEmails = []
const unverifiedEmails = []
verifiedEmails.push(await bundleVerifiedEmails({ user, email: user.primary_email, recordId: user.id, recordVerified: user.primary_verified, allBreaches }))
for (const email of monitoredEmails) {
if (email.verified) {
verifiedEmails.push(await bundleVerifiedEmails({ user, email: email.email, recordId: email.id, recordVerified: email.verified, allBreaches }))
} else {
unverifiedEmails.push(email)
}
}
verifiedEmails = getNewBreachesForEmailEntriesSinceDate(verifiedEmails, user.breaches_last_shown)
return { verifiedEmails, unverifiedEmails }
}
function getNewBreachesForEmailEntriesSinceDate (emailEntries, date) {
for (const emailEntry of emailEntries) {
const newBreachesForEmail = emailEntry.breaches.filter(breach => breach.AddedDate >= date)
for (const newBreachForEmail of newBreachesForEmail) {
newBreachForEmail.NewBreach = true // add "NewBreach" property to the new breach.
emailEntry.hasNewBreaches = newBreachesForEmail.length // add the number of new breaches to the email
}
}
return emailEntries
}
async function getDashboard (req, res) {
const user = req.user
const allBreaches = req.app.locals.breaches
const { verifiedEmails, unverifiedEmails } = await getAllEmailsAndBreaches(user, allBreaches)
const utmOverrides = getUTMContents(req)
const supportedLocalesIncludesEnglish = req.supportedLocales.includes('en')
const experimentFlags = getExperimentFlags(req, EXPERIMENTS_ENABLED)
let lastAddedEmail = null
req.session.user = await DB.setBreachesLastShownNow(user)
if (req.session.lastAddedEmail) {
lastAddedEmail = req.session.lastAddedEmail
req.session.lastAddedEmail = null
}
const adUnitNum = setAdUnitCookie(req, res)
if (!req.session.statsUpdated) {
// update user's breach stats in DB once per session without blocking render
DB.updateBreachStats(user.id, resultsSummary(verifiedEmails))
req.session.statsUpdated = true
}
res.render('dashboards', {
title: req.fluentFormat('Firefox Monitor'),
csrfToken: req.csrfToken(),
lastAddedEmail,
verifiedEmails,
unverifiedEmails,
supportedLocalesIncludesEnglish,
whichPartial: 'dashboards/breaches-dash',
experimentFlags,
utmOverrides,
adUnit: `ad-units/ad-unit-${adUnitNum}`
})
}
async function _verify (req) {
const verifiedEmailHash = await DB.verifyEmailHash(req.query.token)
let unsafeBreachesForEmail = []
unsafeBreachesForEmail = await HIBP.getBreachesForEmail(
sha1(verifiedEmailHash.email.toLowerCase()),
req.app.locals.breaches,
true
)
const utmID = 'report'
const reportSubject = unsafeBreachesForEmail.length ? req.fluentFormat('email-subject-found-breaches') : req.fluentFormat('email-subject-no-breaches')
await EmailUtils.sendEmail(
verifiedEmailHash.email,
reportSubject,
'email-2022',
{
breachedEmail: verifiedEmailHash.email,
recipientEmail: verifiedEmailHash.email,
supportedLocales: req.supportedLocales,
unsafeBreachesForEmail,
ctaHref: EmailUtils.getEmailCtaHref(utmID, 'dashboard-cta'),
utmCampaign: utmID,
unsubscribeUrl: EmailUtils.getUnsubscribeUrl(verifiedEmailHash, utmID),
whichPartial: 'email_partials/report',
heading: req.fluentFormat('email-breach-summary')
}
)
}
async function verify (req, res) {
if (!req.query.token) {
throw new FluentError('user-verify-token-error')
}
const existingEmail = await DB.getEmailByToken(req.query.token)
if (!existingEmail) {
throw new FluentError('error-not-subscribed')
}
const sessionUser = await req.user
if (sessionUser && existingEmail.subscriber_id !== sessionUser.id) {
// TODO: more specific error message?
// e.g., "This email verification token is not valid for this account"
throw new FluentError('user-verify-token-error')
}
if (!existingEmail.verified) {
await _verify(req)
}
if (sessionUser) {
res.redirect('/user/dashboard')
return
}
res.render('subpage', {
title: 'Email Verified',
whichPartial: 'subpages/confirm',
email: existingEmail.email
})
}
// legacy /user/unsubscribe controller for pre-FxA unsubscribe links
async function getUnsubscribe (req, res) {
if (!req.query.token) {
throw new FluentError('user-unsubscribe-token-error')
}
const subscriber = await DB.getSubscriberByToken(req.query.token)
if (subscriber && subscriber.fxa_profile_json !== null) {
// Token is for a primary email address of an FxA subscriber:
// redirect to preferences to remove Firefox Monitor
return res.redirect('/user/preferences')
}
const emailAddress = await DB.getEmailByToken(req.query.token)
if (!subscriber && !emailAddress) {
// Unknown token:
// throw error
throw new FluentError('error-not-subscribed')
}
// Token is for an old pre-FxA subscriber, or a secondary email address:
// render the unsubscribe page
res.render('subpage', {
title: req.fluentFormat('user-unsubscribe-title'),
whichPartial: 'subpages/unsubscribe',
token: req.query.token,
hash: req.query.hash
})
}
async function getRemoveFxm (req, res) {
const sessionUser = req.user
res.render('subpage', {
title: req.fluentFormat('remove-fxm'),
subscriber: sessionUser,
whichPartial: 'subpages/remove_fxm',
csrfToken: req.csrfToken()
})
}
async function postRemoveFxm (req, res) {
const sessionUser = req.user
await DB.removeSubscriber(sessionUser)
await FXA.revokeOAuthTokens(sessionUser)
req.session.destroy()
res.redirect('/')
}
function _updateResolvedBreaches (options) {
const {
user,
affectedEmail,
isResolved,
recencyIndexNumber
} = options
// TODO: clarify the logic here. maybe change the endpoint to PUT /breach-resolution
// with the new resolution value ?
const userBreachesResolved = user.breaches_resolved === null ? {} : user.breaches_resolved
if (isResolved === 'false') {
if (Array.isArray(userBreachesResolved[affectedEmail])) {
userBreachesResolved[affectedEmail].push(recencyIndexNumber)
return userBreachesResolved
}
userBreachesResolved[affectedEmail] = [recencyIndexNumber]
return userBreachesResolved
}
userBreachesResolved[affectedEmail] = userBreachesResolved[affectedEmail].filter(el => el !== recencyIndexNumber)
return userBreachesResolved
}
async function postResolveBreach (req, res) {
const sessionUser = req.user
const { affectedEmail, recencyIndex, isResolved } = req.body
const recencyIndexNumber = Number(recencyIndex)
const affectedEmailIsSubscriberRecord = sessionUser.primary_email === affectedEmail
const affectedEmailInEmailAddresses = sessionUser.email_addresses.filter(ea => ea.email === affectedEmail)
if (!affectedEmailIsSubscriberRecord && !affectedEmailInEmailAddresses) {
return res.json('Error: affectedEmail is not valid for this subscriber')
}
const updatedResolvedBreaches = _updateResolvedBreaches({
user: sessionUser,
affectedEmail,
isResolved,
recencyIndexNumber
})
const updatedSubscriber = await DB.setBreachesResolved(
{ user: sessionUser, updatedResolvedBreaches }
)
req.session.user = updatedSubscriber
// return res.json("Breach marked as resolved.");
// Currently we're sending { affectedEmail, recencyIndex, isResolved, passwordsExposed } in req.body
// Not sure if we need all of these or need to send other/additional values?
if (isResolved === 'true') {
// the user clicked "Undo" so mark the breach as unresolved
return res.redirect('/')
}
const allBreaches = req.app.locals.breaches
const { verifiedEmails } = await getAllEmailsAndBreaches(req.session.user, allBreaches)
const userBreachStats = resultsSummary(verifiedEmails)
const numTotalBreaches = userBreachStats.numBreaches.count
const numResolvedBreaches = userBreachStats.numBreaches.numResolved
DB.updateBreachStats(sessionUser.id, userBreachStats)
const localizedModalStrings = {
headline: '',
progressMessage: '',
progressStatus: req.fluentFormat('progress-status', {
numResolvedBreaches,
numTotalBreaches
}
),
headlineClassName: ''
}
switch (numResolvedBreaches) {
case 1:
localizedModalStrings.headline = req.fluentFormat('confirmation-1-subhead')
localizedModalStrings.progressMessage = req.fluentFormat('confirmation-1-body')
localizedModalStrings.headlineClassName = 'overlay-resolved-first-breach'
break
case 2:
localizedModalStrings.headline = req.fluentFormat('confirmation-2-subhead')
localizedModalStrings.progressMessage = req.fluentFormat('confirmation-2-body')
localizedModalStrings.headlineClassName = 'overlay-take-that-hackers'
break
case 3:
localizedModalStrings.headline = req.fluentFormat('confirmation-3-subhead')
// TO CONSIDER: The "confirmation-3-body" string contains nested markup.
// We'll either have to remove it (requiring a string change), or we will have
// to inject it into the template using innerHTML (scaryish).
// Defaulting to the generic progressMessage for now.
localizedModalStrings.progressMessage = req.fluentFormat('generic-confirmation-message', {
numUnresolvedBreaches: numTotalBreaches - numResolvedBreaches
})
localizedModalStrings.headlineClassName = 'overlay-another-breach-resolved'
break
case numTotalBreaches:
localizedModalStrings.headline = req.fluentFormat('confirmation-2-subhead')
localizedModalStrings.progressMessage = req.fluentFormat('progress-complete')
localizedModalStrings.headlineClassName = 'overlay-marked-as-resolved'
break
default:
if (numResolvedBreaches > 3) {
localizedModalStrings.headline = req.fluentFormat('confirmation-2-subhead')
localizedModalStrings.progressMessage = req.fluentFormat('confirmation-2-body')
localizedModalStrings.headlineClassName = 'overlay-marked-as-resolved'
}
break
}
res.json(localizedModalStrings)
}
async function postUnsubscribe (req, res) {
const { token, emailHash } = req.body
if (!token || !emailHash) {
throw new FluentError('user-unsubscribe-token-email-error')
}
// legacy unsubscribe link page uses removeSubscriberByToken
const unsubscribedUser = await DB.removeSubscriberByToken(token, emailHash)
if (!unsubscribedUser) {
const emailAddress = await DB.getEmailByToken(token)
if (!emailAddress) {
throw new FluentError('error-not-subscribed')
}
await DB.removeOneSecondaryEmail(emailAddress.id)
return res.redirect('/user/preferences')
}
await FXA.revokeOAuthTokens(unsubscribedUser)
req.session.destroy()
res.redirect('/')
}
async function getUnsubscribeMonthly (req, res) {
if (!req.query.token) return res.redirect('/')
await DB.updateMonthlyEmailOptout(req.query.token)
return res.send(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script type="text/javascript" src="/dist/app.js" defer></script>
<title>${LocaleUtils.fluentFormat(req.supportedLocales, 'home-title')}</title>
</head>
<body>
<h2>${LocaleUtils.fluentFormat(req.supportedLocales, 'changes-saved')}</h2>
</body>
</html>
`)
}
async function getPreferences (req, res) {
const user = req.user
const allBreaches = req.app.locals.breaches
const { verifiedEmails, unverifiedEmails } = await getAllEmailsAndBreaches(user, allBreaches)
res.render('dashboards', {
title: 'Firefox Monitor',
whichPartial: 'dashboards/preferences',
csrfToken: req.csrfToken(),
verifiedEmails,
unverifiedEmails
})
}
// This endpoint returns breach stats for Firefox clients to display
// in about:protections
//
// Firefox sends a signed JWT in the Authorization header. We verify this JWT
// with the FXA verification endpoint before we return breach stats.
//
// To test this endpoint, see the "Test Firefox Integration" section of the README.
async function getBreachStats (req, res) {
if (!req.token) {
return res.status(401).json({
errorMessage: 'User breach stats requires an FXA OAuth token passed in the Authorization header.'
})
}
const fxaResponse = await FXA.verifyOAuthToken(req.token)
if (fxaResponse.name === 'HTTPError') {
return res.status(fxaResponse.response.statusCode).json({
errorMessage: 'Could not verify FXA OAuth token. FXA returned message: ' + fxaResponse.response.statusMessage
})
}
if (!fxaResponse.body.scope.includes(FXA_MONITOR_SCOPE)) {
return res.status(401).json({
errorMessage: 'The provided token does not include Monitor scope.'
})
}
const user = await DB.getSubscriberByFxaUid(fxaResponse.body.user)
if (!user) {
return res.status(404).json({
errorMessage: 'Cannot find Monitor subscriber for that FXA.'
})
}
const allBreaches = req.app.locals.breaches
const { verifiedEmails } = await getAllEmailsAndBreaches(user, allBreaches)
const breachStats = resultsSummary(verifiedEmails)
const baseStats = {
monitoredEmails: breachStats.monitoredEmails.count,
numBreaches: breachStats.numBreaches.count,
passwords: breachStats.passwords.count
}
const resolvedStats = {
numBreachesResolved: breachStats.numBreaches.numResolved,
passwordsResolved: breachStats.passwords.numResolved
}
const returnStats = (req.query.includeResolved === 'true') ? Object.assign(baseStats, resolvedStats) : baseStats
return res.json(returnStats)
}
function logout (req, res) {
// Growth Experiment
if (EXPERIMENTS_ENABLED && req.session.experimentFlags) {
// Persist experimentBranch across session reset
const sessionExperimentFlags = req.session.experimentFlags
req.session.destroy(() => {
req.session = { experimentFlags: sessionExperimentFlags }
})
// Return
res.redirect('/')
return
}
req.session.destroy()
res.redirect('/')
}
module.exports = {
FXA_MONITOR_SCOPE,
getPreferences,
getDashboard,
getBreachStats,
getAllEmailsAndBreaches,
add,
verify,
getUnsubscribe,
postUnsubscribe,
getUnsubscribeMonthly,
getRemoveFxm,
postRemoveFxm,
postResolveBreach,
logout,
removeEmail,
resendEmail,
updateCommunicationOptions,
resolveBreach
}

Просмотреть файл

@ -1,237 +0,0 @@
'use strict'
const crypto = require('crypto')
const uuidv4 = require('uuid/v4')
const mozlog = require('../log')
const log = mozlog('controllers.utils')
const AppConstants = require('../app-constants')
function generatePageToken (req) {
const pageToken = { ip: req.ip, date: new Date(), nonce: uuidv4() }
const iv = crypto.randomBytes(16) // AES uses block sizes of 16 bytes
const key = crypto.pbkdf2Sync(AppConstants.COOKIE_SECRET, iv.toString(), 10000, 32, 'sha512')
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv)
const encryptedPageToken = Buffer.concat([
cipher.update(JSON.stringify(pageToken)),
cipher.final()
])
return iv.toString('base64') + '.' + encryptedPageToken.toString('base64')
/* TODO: block on scans-per-ip instead of scans-per-timespan
if (req.session.scans === undefined){
console.log("session scans undefined");
req.session.scans = [];
}
req.session.numScans = req.session.scans.length;
*/
}
function hasUserSignedUpForWaitlist (user, waitlist) {
if (!user.waitlists_joined) {
return false
}
if (user.waitlists_joined.hasOwnProperty(waitlist)) {
return true
}
return false
}
function getTotalPercentage (variations) {
let percentage = 0
// calculate and store total percentage of variations
for (const v in variations) {
if (variations.hasOwnProperty(v) && typeof variations[v] === 'number') {
// multiply by 100 to allow for percentages to the hundredth
// (and avoid floating point math errors)
percentage += (variations[v] * 100)
}
}
const totalPercentage = percentage / 100
// Make sure totalPercent is between 0 and 100
if (totalPercentage === 0 || totalPercentage > 100) {
throw new Error(`The total percentage ${totalPercentage} is out of bounds!`)
}
return percentage
}
function chooseVariation (variations, sorterNum) {
const totalPercentage = getTotalPercentage(variations)
// make sure random number falls in the distribution range
let runningTotal
let choice
if (sorterNum <= totalPercentage) {
runningTotal = 0
// loop through all variations
for (const v in variations) {
// check if random number falls within current variation range
if (sorterNum <= (variations[v] + runningTotal)) {
// if so, we have found our variation
choice = v
break
}
// tally variation percentages for the next loop iteration
runningTotal += variations[v]
}
}
return choice
}
function unEnrollSession (session) {
session.excludeFromExperiment = true
session.experimentBranch = false
session.treatmentBranch = false
session.controlBranch = false
}
function setBranchVariable (branch, sessionExperimentFlags) {
if (branch === 'va') {
sessionExperimentFlags.controlBranch = true
sessionExperimentFlags.treatmentBranch = false
}
if (branch === 'vb') {
sessionExperimentFlags.treatmentBranch = true
sessionExperimentFlags.controlBranch = false
}
}
function getExperimentBranch (req, sorterNum = false, language = false, variations) {
const sessionExperimentFlags = req.session.experimentFlags
if (sessionExperimentFlags.excludeFromExperiment && !req.query.experimentBranch) {
log.debug('This session has already been excluded from the experiment')
unEnrollSession(sessionExperimentFlags)
return false
}
// If we cannot parse req.headers["accept-language"], we should not
// enroll users in the experiment.
if (language && !req.headers?.['accept-language']) {
log.debug('No headers or accept-language information present.')
unEnrollSession(sessionExperimentFlags)
return false
}
// If the user doesn't have the requested variant langauge selected as their primary language,
// we do not enroll them in the experiment.
if (language) {
if (!Array.isArray(language)) {
throw new Error('The language param is not an array')
}
const lang = req.headers['accept-language'].split(',')
// Check to make sure one of the experiment langauge(s) is the top-preferred language.
const firstLangMatch = (element) => lang[0].includes(element)
if (language && !language.some(firstLangMatch)) {
log.debug(`Preferred language is not [${language}] variant: ${lang[0]}`)
unEnrollSession(sessionExperimentFlags)
return false
}
}
// If URL param has experimentBranch entry, use that branch;
if (req.query.experimentBranch) {
if (!Object.keys(variations).includes(req.query.experimentBranch)) {
log.debug('The requested branch is unknown: ', req.query.experimentBranch)
unEnrollSession(sessionExperimentFlags)
return false
}
log.debug('This session has been set to the requested branch: ', req.query.experimentBranch)
sessionExperimentFlags.excludeFromExperiment = false
sessionExperimentFlags.experimentBranch = req.query.experimentBranch
setBranchVariable(sessionExperimentFlags.experimentBranch, sessionExperimentFlags)
return req.query.experimentBranch
}
// If user was already assigned a branch, stay in that branch;
if (sessionExperimentFlags.experimentBranch) {
log.debug('This session has already been assigned: ', sessionExperimentFlags.experimentBranch)
setBranchVariable(sessionExperimentFlags.experimentBranch, sessionExperimentFlags)
return sessionExperimentFlags.experimentBranch
}
if (sorterNum === false) {
sorterNum = Math.floor(Math.random() * 10000) + 1
sorterNum = sorterNum / 100
// sorterNum = Math.floor(Math.random() * 100);
log.debug('No coinflip number provided. Coinflip number is ', sorterNum)
} else {
log.debug('Coinflip number provided. Coinflip number is ', sorterNum)
}
const assignedCohort = chooseVariation(variations, sorterNum)
if (!assignedCohort) {
log.debug('This session has randomly been removed from the experiment')
sessionExperimentFlags.excludeFromExperiment = true
return false
}
log.debug(`This session has been randomly assigned to the ${assignedCohort} cohort.`)
sessionExperimentFlags.experimentBranch = assignedCohort
setBranchVariable(sessionExperimentFlags.experimentBranch, sessionExperimentFlags)
return assignedCohort
}
function getUTMContents (req) {
if (!req) {
throw new Error('No request available')
}
// If UTMs are set previously, set them again.
if (req.session.utmOverrides) {
return req.session.utmOverrides
}
req.session.utmOverrides = false
return false
}
function getExperimentFlags (req, EXPERIMENTS_ENABLED) {
if (!req) {
throw new Error('No request available')
}
if (req.session.experimentFlags && EXPERIMENTS_ENABLED) {
return req.session.experimentFlags
}
const experimentFlags = {
experimentBranch: false,
treatmentBranch: false,
controlBranch: false,
excludeFromExperiment: false
}
req.session.experimentFlags = experimentFlags
return experimentFlags
}
function setAdUnitCookie (req, res) {
const oldNum = parseInt(req.cookies?.adUnit) || Math.ceil(Math.random() * AppConstants.AD_UNIT_TOTAL)
const newNum = oldNum % AppConstants.AD_UNIT_TOTAL + 1
res.cookie('adUnit', newNum, { path: '/', sameSite: 'Lax', maxAge: 60 * 60 * 24 * 30, httpOnly: true })
return newNum
}
module.exports = {
generatePageToken,
hasUserSignedUpForWaitlist,
getExperimentBranch,
getExperimentFlags,
getUTMContents,
setAdUnitCookie
}

488
db/DB.js
Просмотреть файл

@ -1,488 +0,0 @@
'use strict'
const uuidv4 = require('uuid/v4')
const Knex = require('knex')
const { FluentError } = require('../locale-utils')
const AppConstants = require('../app-constants')
const { FXA } = require('../lib/fxa')
const HIBP = require('../hibp')
const getSha1 = require('../sha1-utils')
const mozlog = require('../log')
const knexConfig = require('./knexfile')
let knex = Knex(knexConfig)
const log = mozlog('DB')
const DB = {
async getSubscriberByToken (token) {
const res = await knex('subscribers')
.where('primary_verification_token', '=', token)
return res[0]
},
async getEmailByToken (token) {
const res = await knex('email_addresses')
.where('verification_token', '=', token)
return res[0]
},
async getEmailById (emailAddressId) {
const res = await knex('email_addresses')
.where('id', '=', emailAddressId)
return res[0]
},
async getSubscriberByTokenAndHash (token, emailSha1) {
const res = await knex.table('subscribers')
.first()
.where({
primary_verification_token: token,
primary_sha1: emailSha1
})
return res
},
async joinEmailAddressesToSubscriber (subscriber) {
if (subscriber) {
const emailAddressRecords = await knex('email_addresses').where({
subscriber_id: subscriber.id
})
subscriber.email_addresses = emailAddressRecords.map(
emailAddress => ({ id: emailAddress.id, email: emailAddress.email })
)
}
return subscriber
},
async getSubscriberById (id) {
const [subscriber] = await knex('subscribers').where({
id
})
const subscriberAndEmails = await this.joinEmailAddressesToSubscriber(subscriber)
return subscriberAndEmails
},
async getSubscriberByFxaUid (uid) {
const [subscriber] = await knex('subscribers').where({
fxa_uid: uid
})
const subscriberAndEmails = await this.joinEmailAddressesToSubscriber(subscriber)
return subscriberAndEmails
},
async getSubscriberByEmail (email) {
const [subscriber] = await knex('subscribers').where({
primary_email: email,
primary_verified: true
})
const subscriberAndEmails = await this.joinEmailAddressesToSubscriber(subscriber)
return subscriberAndEmails
},
async getEmailAddressRecordByEmail (email) {
const emailAddresses = await knex('email_addresses').where({
email, verified: true
})
if (!emailAddresses) {
return null
}
if (emailAddresses.length > 1) {
// TODO: handle multiple emails in separate(?) subscriber accounts?
log.warn('getEmailAddressRecordByEmail', { msg: 'found the same email multiple times' })
}
return emailAddresses[0]
},
async addSubscriberUnverifiedEmailHash (user, email) {
const res = await knex('email_addresses').insert({
subscriber_id: user.id,
email,
sha1: getSha1(email),
verification_token: uuidv4(),
verified: false
}).returning('*')
return res[0]
},
async resetUnverifiedEmailAddress (emailAddressId) {
const newVerificationToken = uuidv4()
const res = await knex('email_addresses')
.update({
verification_token: newVerificationToken,
updated_at: knex.fn.now()
})
.where('id', emailAddressId)
.returning('*')
return res[0]
},
async verifyEmailHash (token) {
const unverifiedEmail = await this.getEmailByToken(token)
if (!unverifiedEmail) {
throw new FluentError('Error message for this verification email timed out or something went wrong.')
}
const verifiedEmail = await this._verifyNewEmail(unverifiedEmail)
return verifiedEmail[0]
},
// TODO: refactor into an upsert? https://jaketrent.com/post/upsert-knexjs/
// Used internally, ideally should not be called by consumers.
async _getSha1EntryAndDo (sha1, aFoundCallback, aNotFoundCallback) {
const existingEntries = await knex('subscribers')
.where('primary_sha1', sha1)
if (existingEntries.length && aFoundCallback) {
return await aFoundCallback(existingEntries[0])
}
if (!existingEntries.length && aNotFoundCallback) {
return await aNotFoundCallback()
}
},
// Used internally.
async _addEmailHash (sha1, email, signupLanguage, verified = false) {
try {
return await this._getSha1EntryAndDo(sha1, async aEntry => {
// Entry existed, patch the email value if supplied.
if (email) {
const res = await knex('subscribers')
.update({
primary_email: email,
primary_sha1: getSha1(email.toLowerCase()),
primary_verified: verified,
updated_at: knex.fn.now()
})
.where('id', '=', aEntry.id)
.returning('*')
return res[0]
}
return aEntry
}, async () => {
// Always add a verification_token value
const verificationToken = uuidv4()
const res = await knex('subscribers')
.insert({
primary_sha1: getSha1(email.toLowerCase()),
primary_email: email,
signup_language: signupLanguage,
primary_verification_token: verificationToken,
primary_verified: verified
})
.returning('*')
return res[0]
})
} catch (e) {
throw new FluentError('error-could-not-add-email')
}
},
/**
* Add a subscriber:
* 1. Add a record to subscribers
* 2. Immediately call _verifySubscriber
* 3. For FxA subscriber, add refresh token and profile data
*
* @param {string} email to add
* @param {string} signupLanguage from Accept-Language
* @param {string} fxaAccessToken from Firefox Account Oauth
* @param {string} fxaRefreshToken from Firefox Account Oauth
* @param {string} fxaProfileData from Firefox Account
* @returns {object} subscriber knex object added to DB
*/
async addSubscriber (email, signupLanguage, fxaAccessToken = null, fxaRefreshToken = null, fxaProfileData = null) {
const emailHash = await this._addEmailHash(getSha1(email), email, signupLanguage, true)
const verified = await this._verifySubscriber(emailHash)
const verifiedSubscriber = Array.isArray(verified) ? verified[0] : null
if (fxaRefreshToken || fxaProfileData) {
return this._updateFxAData(verifiedSubscriber, fxaAccessToken, fxaRefreshToken, fxaProfileData)
}
return verifiedSubscriber
},
/**
* When an email is verified, convert it into a subscriber:
* 1. Subscribe the hash to HIBP
* 2. Update our subscribers record to verified
* 3. (if opted in) Subscribe the email to Fx newsletter
*
* @param {object} emailHash knex object in DB
* @returns {object} verified subscriber knex object in DB
*/
async _verifySubscriber (emailHash) {
// TODO: move this "up" into controllers/users ?
await HIBP.subscribeHash(emailHash.primary_sha1)
const verifiedSubscriber = await knex('subscribers')
.where('primary_email', '=', emailHash.primary_email)
.update({
primary_verified: true,
updated_at: knex.fn.now()
})
.returning('*')
return verifiedSubscriber
},
// Verifies new emails added by existing users
async _verifyNewEmail (emailHash) {
await HIBP.subscribeHash(emailHash.sha1)
const verifiedEmail = await knex('email_addresses')
.where('id', '=', emailHash.id)
.update({
verified: true
})
.returning('*')
return verifiedEmail
},
async getUserEmails (userId) {
const userEmails = await knex('email_addresses')
.where('subscriber_id', '=', userId)
.returning('*')
return userEmails
},
/**
* Update fxa_refresh_token and fxa_profile_json for subscriber
*
* @param {object} subscriber knex object in DB
* @param {string} fxaAccessToken from Firefox Account Oauth
* @param {string} fxaRefreshToken from Firefox Account Oauth
* @param {string} fxaProfileData from Firefox Account
* @returns {object} updated subscriber knex object in DB
*/
async _updateFxAData (subscriber, fxaAccessToken, fxaRefreshToken, fxaProfileData) {
const fxaUID = JSON.parse(fxaProfileData).uid
const updated = await knex('subscribers')
.where('id', '=', subscriber.id)
.update({
fxa_uid: fxaUID,
fxa_access_token: fxaAccessToken,
fxa_refresh_token: fxaRefreshToken,
fxa_profile_json: fxaProfileData
})
.returning('*')
const updatedSubscriber = Array.isArray(updated) ? updated[0] : null
if (updatedSubscriber) {
FXA.destroyOAuthToken({ refresh_token: subscriber.fxa_refresh_token })
}
return updatedSubscriber
},
async updateFxAProfileData (subscriber, fxaProfileData) {
await knex('subscribers').where('id', subscriber.id)
.update({
fxa_profile_json: fxaProfileData
})
return this.getSubscriberById(subscriber.id)
},
async setBreachesLastShownNow (subscriber) {
// TODO: turn 2 db queries into a single query (also see #942)
const nowDateTime = new Date()
const nowTimeStamp = nowDateTime.toISOString()
await knex('subscribers')
.where('id', '=', subscriber.id)
.update({
breaches_last_shown: nowTimeStamp
})
return this.getSubscriberByEmail(subscriber.primary_email)
},
async setAllEmailsToPrimary (subscriber, allEmailsToPrimary) {
const updated = await knex('subscribers')
.where('id', subscriber.id)
.update({
all_emails_to_primary: allEmailsToPrimary
})
.returning('*')
const updatedSubscriber = Array.isArray(updated) ? updated[0] : null
return updatedSubscriber
},
async setBreachesResolved (options) {
const { user, updatedResolvedBreaches } = options
await knex('subscribers')
.where('id', user.id)
.update({
breaches_resolved: updatedResolvedBreaches
})
return this.getSubscriberByEmail(user.primary_email)
},
async setWaitlistsJoined (options) {
const { user, updatedWaitlistsJoined } = options
await knex('subscribers')
.where('id', user.id)
.update({
waitlists_joined: updatedWaitlistsJoined
})
return this.getSubscriberByEmail(user.primary_email)
},
async removeSubscriber (subscriber) {
await knex('email_addresses').where({ subscriber_id: subscriber.id }).del()
await knex('subscribers').where({ id: subscriber.id }).del()
},
// This is used by SES callbacks to remove email addresses when recipients
// perma-bounce or mark our emails as spam
// Removes from either subscribers or email_addresses as necessary
async removeEmail (email) {
const subscriber = await this.getSubscriberByEmail(email)
if (!subscriber) {
const emailAddress = await this.getEmailAddressRecordByEmail(email)
if (!emailAddress) {
log.warn('removed-subscriber-not-found')
return
}
await knex('email_addresses')
.where({
email,
verified: true
})
.del()
return
}
// If the subscriber has more email_addresses, log the deletion failure
if (subscriber.email_addresses.length !== 0) {
log.error('removeEmail', {
msg: `Unable to delete subscriber ${subscriber.id} with ${subscriber.email_addresses.length} additional email(s).`
})
return
}
await knex('subscribers')
.where({
primary_verification_token: subscriber.primary_verification_token,
primary_sha1: subscriber.primary_sha1
})
.del()
},
async removeSubscriberByToken (token, emailSha1) {
const subscriber = await this.getSubscriberByTokenAndHash(token, emailSha1)
if (!subscriber) {
return false
}
// Delete subscriber's emails
// TODO issue 2744: Replace this code with a DB migration to add cascading deletion
await knex('email_addresses').where({ subscriber_id: subscriber.id }).del()
// Delete the subscriber
await knex('subscribers')
.where({
primary_verification_token: subscriber.primary_verification_token,
primary_sha1: subscriber.primary_sha1
})
.del()
return subscriber
},
async removeOneSecondaryEmail (emailId) {
await knex('email_addresses')
.where({
id: emailId
})
.del()
},
async getSubscribersByHashes (hashes) {
return await knex('subscribers').whereIn('primary_sha1', hashes).andWhere('primary_verified', '=', true)
},
async getEmailAddressesByHashes (hashes) {
return await knex('email_addresses')
.join('subscribers', 'email_addresses.subscriber_id', '=', 'subscribers.id')
.whereIn('email_addresses.sha1', hashes)
.andWhere('email_addresses.verified', '=', true)
},
async deleteUnverifiedSubscribers () {
const expiredDateTime = new Date(Date.now() - AppConstants.DELETE_UNVERIFIED_SUBSCRIBERS_TIMER * 1000)
const expiredTimeStamp = expiredDateTime.toISOString()
const numDeleted = await knex('subscribers')
.where('primary_verified', false)
.andWhere('created_at', '<', expiredTimeStamp)
.del()
log.info('deleteUnverifiedSubscribers', { msg: `Deleted ${numDeleted} rows.` })
},
async deleteSubscriberByFxAUID (fxaUID) {
await knex('subscribers').where('fxa_uid', fxaUID).del()
},
async deleteEmailAddressesByUid (uid) {
await knex('email_addresses').where({ subscriber_id: uid }).del()
},
async updateBreachStats (id, stats) {
await knex('subscribers')
.where('id', id)
.update({
breach_stats: stats
})
},
async updateMonthlyEmailTimestamp (email) {
const res = await knex('subscribers').update({ monthly_email_at: 'now' })
.where('primary_email', email)
.returning('monthly_email_at')
return res
},
async updateMonthlyEmailOptout (token) {
await knex('subscribers').update('monthly_email_optout', true).where('primary_verification_token', token)
},
getSubscribersWithUnresolvedBreachesQuery () {
return knex('subscribers')
.whereRaw('monthly_email_optout IS NOT TRUE')
.whereRaw("greatest(created_at, monthly_email_at) < (now() - interval '30 days')")
.whereRaw("(breach_stats #>> '{numBreaches, numUnresolved}')::int > 0")
},
async getSubscribersWithUnresolvedBreaches (limit = 0) {
let query = this.getSubscribersWithUnresolvedBreachesQuery()
.select('primary_email', 'primary_verification_token', 'breach_stats', 'signup_language')
if (limit) {
query = query.limit(limit).orderBy('created_at')
}
return await query
},
async getSubscribersWithUnresolvedBreachesCount () {
const query = this.getSubscribersWithUnresolvedBreachesQuery()
const count = parseInt((await query.count({ count: '*' }))[0].count)
return count
},
async createConnection () {
if (knex === null) {
knex = Knex(knexConfig)
}
},
async destroyConnection () {
if (knex !== null) {
await knex.destroy()
knex = null
}
}
}
module.exports = DB

Просмотреть файл

@ -1,28 +0,0 @@
'use strict'
const { parse } = require('pg-connection-string')
const AppConstants = require('../app-constants')
const connectionObj = parse(AppConstants.DATABASE_URL)
if (AppConstants.NODE_ENV === 'heroku') {
connectionObj.ssl = { rejectUnauthorized: false }
}
// For runtime, use DATABASE_URL
const RUNTIME_CONFIG = {
client: 'postgresql',
connection: connectionObj
}
// For tests, use test-DATABASE
const testConnectionObj = parse(AppConstants.DATABASE_URL.replace(/\/(\w*)$/, '/test-$1'))
const TESTS_CONFIG = {
client: 'postgresql',
connection: testConnectionObj
}
if (AppConstants.NODE_ENV === 'tests') {
module.exports = TESTS_CONFIG
} else {
module.exports = RUNTIME_CONFIG
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.timestamps(false, true)
})
}
exports.down = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.dropTimestamps()
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.boolean('fx_newsletter').defaultTo(false)
})
}
exports.down = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.dropColumn('fx_newsletter')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.string('signup_language')
})
}
exports.down = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.dropColumn('signup_language')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.index('created_at')
})
}
exports.down = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.dropIndex('created_at')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.index('email', 'subscribers_email_idx')
})
}
exports.down = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.dropIndex('email', 'subscribers_email_idx')
})
}

Просмотреть файл

@ -1,15 +0,0 @@
'use strict'
exports.up = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.string('fxa_refresh_token')
table.jsonb('fxa_profile_json')
})
}
exports.down = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.dropColumn('fxa_refresh_token')
table.dropColumn('fxa_profile_json')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.index('verified', 'subscribers_verified_idx')
})
}
exports.down = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.dropIndex('verified', 'subscribers_verified_idx')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.string('fxa_uid')
})
}
exports.down = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.dropColumn('fxa_uid')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.timestamp('breaches_last_shown')
})
}
exports.down = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.dropColumn('breaches_last_shown')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex, Promise) {
return knex.schema.table('email_addresses', table => {
table.timestamps(false)
})
}
exports.down = function (knex, Promise) {
return knex.schema.table('email_addresses', table => {
table.dropTimestamps()
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.boolean('all_emails_to_primary').defaultTo(false)
})
}
exports.down = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.dropColumn('all_emails_to_primary')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.string('fxa_access_token')
})
}
exports.down = function (knex, Promise) {
return knex.schema.table('subscribers', table => {
table.dropColumn('fxa_access_token')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex) {
return knex.schema.table('email_addresses', table => {
table.index('email', 'email_addresses_email_idx')
})
}
exports.down = function (knex) {
return knex.schema.table('email_addresses', table => {
table.dropIndex('email', 'email_addresses_email_idx')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex) {
return knex.schema.table('subscribers', table => {
table.jsonb('breaches_resolved')
})
}
exports.down = function (knex) {
return knex.schema.table('subscribers', table => {
table.dropColumn('breaches_resolved')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex) {
return knex.schema.table('subscribers', table => {
table.jsonb('waitlists_joined')
})
}
exports.down = function (knex) {
return knex.schema.table('subscribers', table => {
table.dropColumn('waitlists_joined')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex) {
return knex.schema.table('subscribers', (table) => {
table.integer('kid')
})
}
exports.down = function (knex) {
return knex.schema.table('subscribers', (table) => {
table.dropColumn('kid')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = (knex) => {
return knex.schema.createTable('removal_pilot', (table) => {
table.increments('id').primary()
table.string('name').unique()
table.integer('enrolled_users').defaultTo(0)
})
}
exports.down = (knex) => {
return knex.schema.dropTableIfExists('removal_pilot')
}

Просмотреть файл

@ -1,16 +0,0 @@
'use strict'
exports.up = function (knex) {
return knex('removal_pilot')
.del()
.then(function () {
// Inserts seed entries
return knex('removal_pilot').insert([
{ id: 1, name: 'round_01', enrolled_users: 0 }
])
})
}
exports.down = function (knex) {
return knex('removal_pilot').del()
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex) {
return knex.schema.table('subscribers', (table) => {
table.boolean('removal_optout').defaultTo(false)
})
}
exports.down = function (knex) {
return knex.schema.table('subscribers', (table) => {
table.dropColumn('removal_optout')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex) {
return knex.schema.table('subscribers', (table) => {
table.jsonb('breach_stats').defaultTo(null)
})
}
exports.down = function (knex) {
return knex.schema.table('subscribers', (table) => {
table.dropColumn('breach_stats')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex) {
return knex.schema.table('subscribers', (table) => {
table.timestamp('monthly_email_at')
})
}
exports.down = function (knex) {
return knex.schema.table('subscribers', (table) => {
table.dropColumn('monthly_email_at')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex) {
return knex.schema.table('subscribers', (table) => {
table.boolean('monthly_email_optout')
})
}
exports.down = function (knex) {
return knex.schema.table('subscribers', (table) => {
table.dropColumn('monthly_email_optout')
})
}

Просмотреть файл

@ -1,13 +0,0 @@
'use strict'
exports.up = function (knex) {
return knex.schema.table('subscribers', (table) => {
table.jsonb('breach_resolution').defaultTo(null)
})
}
exports.down = function (knex) {
return knex.schema.table('subscribers', (table) => {
table.dropColumn('breach_resolution')
})
}

Просмотреть файл

@ -1,108 +0,0 @@
'use strict'
const getSha1 = require('../../sha1-utils')
exports.TEST_SUBSCRIBERS = {
firefox_account: {
id: 12345,
primary_sha1: getSha1('firefoxaccount@test.com'),
primary_email: 'firefoxaccount@test.com',
primary_verification_token: '0e2cb147-2041-4e5b-8ca9-494e773b2cf1',
primary_verified: true,
fxa_access_token: '4a4792b89434153f1a6262fbd6a4510c00834ff842585fc4f4d972da158f0fc0',
fxa_refresh_token: '4a4792b89434153f1a6262fbd6a4510c00834ff842585fc4f4d972da158f0fc1',
fxa_uid: 12345,
fxa_profile_json: {},
breaches_last_shown: '2019-04-24 13:27:08.421-05',
breaches_resolved: { 'firefoxaccount@test.com': [0] }
},
all_emails_to_primary: {
id: 67890,
primary_sha1: getSha1('all_emails_to_primary@test.com'),
primary_email: 'all_emails_to_primary@test.com',
primary_verification_token: '0e2cb147-2041-4e5b-8ca9-494e773b2ca7',
primary_verified: true,
fxa_refresh_token: '4a4792b89434153f1a6262fbd6a4510c00834ff842585fc4f4d972da158f0fc2',
breaches_last_shown: '2019-04-24 13:27:08.421-05',
all_emails_to_primary: true
},
unverified_email: {
primary_sha1: getSha1('unverifiedemail@test.com'),
primary_email: 'unverifiedemail@test.com',
primary_verification_token: '0e2cb147-2041-4e5b-8ca9-494e773b2cf0',
primary_verified: false
},
verified_email: {
primary_sha1: getSha1('verifiedemail@test.com'),
primary_email: 'verifiedemail@test.com',
primary_verification_token: '54010800-6c3c-4186-971a-76dc92874941',
primary_verified: true,
signup_language: 'en-US;q=0.7,en;q=0.3'
},
has_breaches: {
id: 12346,
created_at: '2022-06-07 14:29:00.000-05',
primary_sha1: getSha1('has-breaches@example.com'),
primary_email: 'has-breaches@example.com',
primary_verification_token: 'c165711a-69d1-42f1-9850-ce74754f36de',
primary_verified: true,
fxa_access_token: '5a4792b89434153f1a6262fbd6a4510c00834ff842585fc4f4d972da158f0fc0',
fxa_refresh_token: '5a4792b89434153f1a6262fbd6a4510c00834ff842585fc4f4d972da158f0fc1',
fxa_uid: 12346,
fxa_profile_json: {},
breaches_last_shown: '2022-07-08 14:19:00.000-05',
breaches_resolved: { 'has-breaches@example.com': [1] },
breach_stats: {
passwords: {
count: 1,
numResolved: 0
},
numBreaches: {
count: 2,
numResolved: 1,
numUnresolved: 1
},
monitoredEmails: {
count: 1
}
},
monthly_email_at: '2022-08-07 14:22:00.000-05',
monthly_email_optout: false,
signup_language: 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7,*;q=0.5'
}
}
exports.TEST_EMAIL_ADDRESSES = {
firefox_account: {
id: 11111,
subscriber_id: 12345,
sha1: getSha1('firefoxaccount-secondary@test.com'),
email: 'firefoxaccount-secondary@test.com',
verification_token: '0e2cb147-2041-4e5b-8ca9-494e773b2cf2',
verified: true
},
unverified_email_on_firefox_account: {
id: 98765,
subscriber_id: 12345,
sha1: getSha1('firefoxaccount-tertiary@test.com'),
email: 'firefoxaccount-tertiary@test.com',
verification_token: '0e2cb147-2041-4e5b-8ca9-494e773b2cf3',
verified: false
},
all_emails_to_primary: {
id: 99999,
subscriber_id: 67890,
sha1: getSha1('secondary_sending_to_primary@test.com'),
email: 'secondary_sending_to_primary@test.com',
verification_token: '0e2cb147-2041-4e5b-8ca9-494e773b2cf4',
verified: true
},
has_breaches: {
id: 11112,
subscriber_id: 12346,
sha1: getSha1('has-breaches@example.com'),
email: 'has-breaches@example.com',
verification_token: 'c165711a-69d1-42f1-9850-ce74754f36df',
verified: true
}
}

Просмотреть файл

@ -1,132 +0,0 @@
'use strict'
const path = require('path')
const { URL } = require('url')
const AppConstants = require('./app-constants')
const nodemailer = require('nodemailer')
const hbs = require('nodemailer-express-handlebars')
const HBSHelpers = require('./template-helpers/')
const mozlog = require('./log')
const log = mozlog('email-utils')
const hbsOptions = {
viewEngine: {
extname: '.hbs',
layoutsDir: path.join(__dirname, '/views/layouts'),
defaultLayout: 'email-2022',
partialsDir: path.join(__dirname, '/views/partials'),
helpers: HBSHelpers.helpers
},
viewPath: path.join(__dirname, '/views/layouts'),
extName: '.hbs'
}
// The SMTP transport object. This is initialized to a nodemailer transport
// object while reading SMTP credentials, or to a dummy function in debug mode.
let gTransporter
const EmailUtils = {
async init (smtpUrl = AppConstants.SMTP_URL) {
// Allow a debug mode that will log JSON instead of sending emails.
if (!smtpUrl) {
log.info('smtpUrl-empty', { message: 'EmailUtils will log a JSON response instead of sending emails.' })
gTransporter = nodemailer.createTransport({ jsonTransport: true })
return Promise.resolve(true)
}
gTransporter = nodemailer.createTransport(smtpUrl)
const gTransporterVerification = await gTransporter.verify()
gTransporter.use('compile', hbs(hbsOptions))
return Promise.resolve(gTransporterVerification)
},
sendEmail (aRecipient, aSubject, aTemplate, aContext) {
if (!gTransporter) {
return Promise.reject(new Error('SMTP transport not initialized'))
}
const emailContext = Object.assign({
SERVER_URL: AppConstants.SERVER_URL,
layout: aTemplate
}, aContext)
return new Promise((resolve, reject) => {
const emailFrom = AppConstants.EMAIL_FROM
const mailOptions = {
from: emailFrom,
to: aRecipient,
subject: aSubject,
template: aTemplate,
context: emailContext,
headers: {
'x-ses-configuration-set': AppConstants.SES_CONFIG_SET
}
}
gTransporter.sendMail(mailOptions, (error, info) => {
if (error) {
reject(error)
return
}
if (gTransporter.transporter.name === 'JSONTransport') {
log.info('JSONTransport', { message: info.message.toString() })
}
resolve(info)
})
})
},
appendUtmParams (url, campaign, content) {
const utmParameters = {
utm_source: 'fx-monitor',
utm_medium: 'email',
utm_campaign: campaign,
utm_content: content
}
for (const param in utmParameters) {
url.searchParams.append(param, utmParameters[param])
}
return url
},
getEmailCtaHref (emailType, content, subscriberId = null) {
const subscriberParamPath = (subscriberId) ? `/?subscriber_id=${subscriberId}` : '/'
const url = new URL(subscriberParamPath, AppConstants.SERVER_URL)
return this.appendUtmParams(url, emailType, content)
},
getVerificationUrl (subscriber) {
if (!subscriber.verification_token) throw new Error('subscriber has no verification_token')
let url = new URL(`${AppConstants.SERVER_URL}/user/verify`)
url = this.appendUtmParams(url, 'verified-subscribers', 'account-verification-email')
url.searchParams.append('token', encodeURIComponent(subscriber.verification_token))
return url
},
getUnsubscribeUrl (subscriber, emailType) {
// TODO: email unsubscribe is broken for most emails
let url = new URL(`${AppConstants.SERVER_URL}/user/unsubscribe`)
const token = (Object.prototype.hasOwnProperty.call(subscriber, 'verification_token')) ? subscriber.verification_token : subscriber.primary_verification_token
const hash = (Object.prototype.hasOwnProperty.call(subscriber, 'sha1')) ? subscriber.sha1 : subscriber.primary_sha1
url.searchParams.append('token', encodeURIComponent(token))
url.searchParams.append('hash', encodeURIComponent(hash))
url = this.appendUtmParams(url, 'unsubscribe', emailType)
return url
},
getMonthlyUnsubscribeUrl (subscriber, campaign, content) {
// TODO: create new subscriptions section in settings to manage all emails and avoid one-off routes like this
if (!subscriber.primary_verification_token) throw new Error('subscriber has no primary verification_token')
let url = new URL('user/unsubscribe-monthly/', AppConstants.SERVER_URL)
url = this.appendUtmParams(url, campaign, content)
url.searchParams.append('token', encodeURIComponent(subscriber.primary_verification_token))
return url
}
}
module.exports = EmailUtils

Просмотреть файл

@ -1,16 +1,51 @@
const esbuild = require('esbuild')
const AppConstants = require('./app-constants')
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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/. */
// ESBuild is used to concat and compress the 'client' folder into the 'dist' folder (front-end only)
import esbuild from 'esbuild'
import { readdirSync } from 'node:fs'
import AppConstants from './src/appConstants.js'
const cssPartialDir = 'src/client/css/partials/'
const cssPartialPaths = readdirSync(cssPartialDir, { withFileTypes: true })
.filter(dirent => dirent.isFile())
.map(dirent => cssPartialDir + dirent.name)
const jsPartialDir = 'src/client/js/partials/'
const jsPartialPaths = readdirSync(jsPartialDir, { withFileTypes: true })
.filter(dirent => dirent.isFile())
.map(dirent => jsPartialDir + dirent.name)
esbuild.build({
logLevel: 'info',
bundle: true,
entryPoints: ['public/js/app.js', 'public/css/app.css'],
entryNames: '[name]',
external: ['./public/img/*', './public/fonts/*'],
outdir: 'public/dist',
entryPoints: ['src/client/js/index.js', 'src/client/css/index.css', ...cssPartialPaths, ...jsPartialPaths],
entryNames: '[dir]/[name]',
loader: { '.woff2': 'copy' },
assetNames: '[dir]/[name]',
external: ['*.webp', '*.svg'],
outdir: 'dist',
format: 'esm',
minify: AppConstants.NODE_ENV !== 'dev',
sourcemap: AppConstants.NODE_ENV !== 'dev',
splitting: true,
treeShaking: true
splitting: false, // see note below
treeShaking: true,
platform: 'neutral',
define: {
buildConstants: JSON.stringify({
NODE_ENV: AppConstants.NODE_ENV,
GA4_MEASUREMENT_ID: AppConstants.GA4_MEASUREMENT_ID
})
}
})
/*
ESBuild automatic code-splitting is disabled for the following reasons:
- As of this writing, ESBuild code-splitting is suggested to be experimental/WIP: https://esbuild.github.io/api/#splitting
- There is a known bug with ESBuild that loads chunks out of order: https://github.com/evanw/esbuild/issues/399
- The complete client bundle is currently only ~10kB transferred; splitting this down further is unlikely to result in significant load speed gains
- A ticket was completed (MNTOR-1171) to set up native/logical code-splitting depending on partial.
- see also https://github.com/mozilla/blurts-server/pull/2844
*/

192
hibp.js
Просмотреть файл

@ -1,192 +0,0 @@
// TODO: to be deprecated
'use strict'
const got = require('got')
const AppConstants = require('./app-constants')
const { FluentError } = require('./locale-utils')
const mozlog = require('./log')
const pkg = require('./package.json')
const HIBP_USER_AGENT = `${pkg.name}/${pkg.version}`
// When HIBP "re-names" a breach, it keeps its old 'Name' value but gets a new 'Title'
// We use 'Name' in Firefox (via Remote Settings), so we have to maintain our own mapping of re-named breaches.
const RENAMED_BREACHES = ['covve']
const RENAMED_BREACHES_MAP = {
covve: 'db8151dd'
}
const log = mozlog('hibp')
const HIBP = {
_addStandardOptions (options = {}) {
const hibpOptions = {
headers: {
'User-Agent': HIBP_USER_AGENT
},
responseType: 'json'
}
return Object.assign(options, hibpOptions)
},
async _throttledGot (url, reqOptions, tryCount = 1) {
let response
try {
response = await got(url, reqOptions)
return response
} catch (err) {
log.error('_throttledGot', { err })
if (err.statusCode === 404) {
// 404 can mean "no results", return undefined response; sorry calling code
return response
} else if (err.statusCode === 429) {
log.info('_throttledGot', { err: 'got a 429, tryCount: ' + tryCount })
if (tryCount >= AppConstants.HIBP_THROTTLE_MAX_TRIES) {
log.error('_throttledGot', { err })
throw new FluentError('error-hibp-throttled')
} else {
tryCount++
await new Promise(resolve => setTimeout(resolve, AppConstants.HIBP_THROTTLE_DELAY * tryCount))
return await this._throttledGot(url, reqOptions, tryCount)
}
} else {
throw new FluentError('error-hibp-connect')
}
}
},
async req (path, options = {}) {
const url = `${AppConstants.HIBP_API_ROOT}${path}`
const reqOptions = this._addStandardOptions(options)
return await this._throttledGot(url, reqOptions)
},
async kAnonReq (path, options = {}) {
// Construct HIBP url and standard headers
const url = `${AppConstants.HIBP_KANON_API_ROOT}${path}?code=${encodeURIComponent(AppConstants.HIBP_KANON_API_TOKEN)}`
const reqOptions = this._addStandardOptions(options)
return await this._throttledGot(url, reqOptions)
},
matchFluentID (dataCategory) {
return dataCategory.toLowerCase()
.replace(/[^-a-z0-9]/g, '-')
.replace(/-{2,}/g, '-')
.replace(/(^-|-$)/g, '')
},
formatDataClassesArray (dataCategories) {
const formattedArray = []
dataCategories.forEach(category => {
formattedArray.push(this.matchFluentID(category))
})
return formattedArray
},
async loadBreachesIntoApp (app) {
try {
const breachesResponse = await this.req('/breaches')
const breaches = []
for (const breach of breachesResponse.body) {
breach.DataClasses = this.formatDataClassesArray(breach.DataClasses)
breach.LogoPath = /[^/]*$/.exec(breach.LogoPath)[0]
breaches.push(breach)
}
app.locals.breaches = breaches
app.locals.breachesLoadedDateTime = Date.now()
app.locals.latestBreach = this.getLatestBreach(breaches)
app.locals.mostRecentBreachDateTime = app.locals.latestBreach.AddedDate
} catch (error) {
throw new FluentError('error-hibp-load-breaches')
}
log.info('done-loading-breaches', 'great success 👍')
},
async getBreachesForEmail (sha1, allBreaches, includeSensitive = false, filterBreaches = true) {
let foundBreaches = []
const sha1Prefix = sha1.slice(0, 6).toUpperCase()
const path = `/breachedaccount/range/${sha1Prefix}`
const response = await this.kAnonReq(path)
if (!response) {
return []
}
// Parse response body, format:
// [
// {"hashSuffix":<suffix>,"websites":[<breach1Name>,...]},
// {"hashSuffix":<suffix>,"websites":[<breach1Name>,...]},
// ]
for (const breachedAccount of response.body) {
if (sha1.toUpperCase() === sha1Prefix + breachedAccount.hashSuffix) {
foundBreaches = allBreaches.filter(breach => breachedAccount.websites.includes(breach.Name))
if (filterBreaches) {
foundBreaches = this.filterBreaches(foundBreaches)
}
// NOTE: DO NOT CHANGE THIS SORT LOGIC
// We store breach resolutions by recency indices,
// so that our DB does not contain any part of any user's list of accounts
foundBreaches.sort((a, b) => {
return new Date(b.AddedDate) - new Date(a.AddedDate)
})
break
}
}
if (includeSensitive) {
return foundBreaches
}
return foundBreaches.filter(
breach => !breach.IsSensitive
)
},
getBreachByName (allBreaches, breachName) {
breachName = breachName.toLowerCase()
if (RENAMED_BREACHES.includes(breachName)) {
breachName = RENAMED_BREACHES_MAP[breachName]
}
const foundBreach = allBreaches.find(breach => breach.Name.toLowerCase() === breachName)
return foundBreach
},
filterBreaches (breaches) {
return breaches.filter(
breach => !breach.IsRetired &&
!breach.IsSpamList &&
!breach.IsFabricated &&
breach.IsVerified &&
breach.Domain !== ''
)
},
getLatestBreach (breaches) {
let latestBreach = {}
let latestBreachDateTime = new Date(0)
for (const breach of breaches) {
if (breach.IsSensitive) {
continue
}
const breachAddedDate = new Date(breach.AddedDate)
if (breachAddedDate > latestBreachDateTime) {
latestBreachDateTime = breachAddedDate
latestBreach = breach
}
}
return latestBreach
},
async subscribeHash (sha1) {
const sha1Prefix = sha1.slice(0, 6).toUpperCase()
const path = '/range/subscribe'
const options = {
method: 'POST',
json: { hashPrefix: sha1Prefix }
}
return await this.kAnonReq(path, options)
}
}
module.exports = HIBP

Просмотреть файл

@ -1,54 +0,0 @@
'use strict'
const path = require('path')
const reader = require('@maxmind/geoip2-node').Reader
const AppConstants = require('./app-constants')
let locationDb, timestamp
async function openLocationDb () {
if (locationDb && isFresh()) return console.warn('Location database already open.')
try {
const dbPath = path.join(AppConstants.GEOIP_GEOLITE2_PATH, AppConstants.GEOIP_GEOLITE2_CITY_FILENAME)
locationDb = await reader.open(dbPath)
} catch (e) {
return console.warn('Could not open location database:', e.message)
}
timestamp = Date.now()
return true
}
async function readLocationData (ip, locales) {
let locationArr
if (!isFresh()) await openLocationDb()
try {
const data = locationDb.city(ip)
const countryName = data.country?.names[locales.find(locale => data.country?.names[locale])] // find valid locale key and return its value
const cityName = data.city?.names[locales.find(locale => data.city?.names[locale])]
const subdivisionName = data.subdivisions?.[0].isoCode
const subdivisionFiltered = /[A-z]{2,}/.test(subdivisionName) ? subdivisionName : null // return strings that are 2 or more letters, or null (avoid unfamiliar subdivisions like `E` or `09`)
locationArr = [cityName, subdivisionFiltered, countryName].filter(str => str) // [city name, state code, country code] with non-null items.
} catch (e) {
return console.warn('Could not read location from database:', e.message)
}
return {
shortLocation: locationArr.slice(0, 2).join(', '), // shows the first two location values from the ones available
fullLocation: locationArr.join(', ') // shows up to three location values from the ones available
}
}
function isFresh () {
if (Date.now() - timestamp < 259200000) return true // 1000ms * 60s * 60m * 24h * 3 elapsed time is less than 24hrs
return false
}
module.exports = {
openLocationDb,
readLocationData
}

Просмотреть файл

@ -1,18 +0,0 @@
'use strict'
const changePWLinks = {
MySpace: 'https://myspace.com/settings/profile/password',
LinkedIn: 'https://www.linkedin.com/psettings/change-password',
Dubsmash: 'https://dubsmash.com/reset-password',
Canva: 'https://www.canva.com/account',
MyFitnessPal: 'https://www.myfitnesspal.com/account/change_password',
Adobe: 'https://account.adobe.com/security',
Dropbox: 'https://www.dropbox.com/account/security',
Houzz: 'https://www.houzz.com/changePassword',
Evite: 'https://www.evite.com/settings/password',
CafePress: 'https://members.cafepress.com/account/profile.aspx'
}
module.exports = {
changePWLinks
}

Просмотреть файл

@ -1,108 +0,0 @@
'use strict'
const ClientOAuth2 = require('client-oauth2')
const got = require('got')
const { URL } = require('url')
const AppConstants = require('../app-constants')
const mozlog = require('../log')
const log = mozlog('fxa')
// This object exists instead of inlining the env vars to make it easy
// to abstract fetching API endpoints from the OAuth server (instead
// of specifying them in the environment) in the future.
const FxAOAuthUtils = {
get authorizationUri () { return AppConstants.OAUTH_AUTHORIZATION_URI },
get tokenUri () { return AppConstants.OAUTH_TOKEN_URI },
get profileUri () { return AppConstants.OAUTH_PROFILE_URI }
}
const FxAOAuthClient = new ClientOAuth2({
clientId: AppConstants.OAUTH_CLIENT_ID,
clientSecret: AppConstants.OAUTH_CLIENT_SECRET,
accessTokenUri: FxAOAuthUtils.tokenUri,
authorizationUri: FxAOAuthUtils.authorizationUri,
redirectUri: AppConstants.SERVER_URL + '/oauth/confirmed',
scopes: ['profile']
})
const FXA = {
async _postTokenRequest (path, token) {
const fxaTokenOrigin = new URL(AppConstants.OAUTH_TOKEN_URI).origin
const tokenUrl = `${fxaTokenOrigin}${path}`
const tokenBody = (typeof token === 'object') ? token : { token }
const tokenOptions = {
method: 'POST',
json: tokenBody,
responseType: 'json'
}
try {
const response = await got(tokenUrl, tokenOptions)
return response
} catch (e) {
log.error('_postTokenRequest', { stack: e.stack })
return e
}
},
async verifyOAuthToken (token) {
try {
const response = await this._postTokenRequest('/v1/verify', token)
return response
} catch (e) {
log.error('verifyOAuthToken', { stack: e.stack })
}
},
async destroyOAuthToken (token) {
try {
const response = await this._postTokenRequest('/v1/destroy', token)
return response
} catch (e) {
log.error('destroyOAuthToken', { stack: e.stack })
}
},
async revokeOAuthTokens (subscriber) {
await this.destroyOAuthToken({ token: subscriber.fxa_access_token })
await this.destroyOAuthToken({ refresh_token: subscriber.fxa_refresh_token })
},
async getProfileData (accessToken) {
try {
const data = await got(FxAOAuthUtils.profileUri,
{ headers: { Authorization: `Bearer ${accessToken}` } }
)
return data.body
} catch (e) {
log.warn('getProfileData', { stack: e.stack })
return e
}
},
async sendMetricsFlowPing (path) {
const fxaMetricsFlowUrl = new URL(path, AppConstants.FXA_SETTINGS_URL)
const fxaMetricsFlowOptions = {
method: 'GET',
headers: { Origin: AppConstants.SERVER_URL }
}
try {
log.info(`GETting ${fxaMetricsFlowUrl}, with options: ${JSON.stringify(fxaMetricsFlowOptions)}`)
const fxaResp = await got(fxaMetricsFlowUrl, fxaMetricsFlowOptions)
log.info('pinged FXA metrics flow.')
return fxaResp
} catch (e) {
log.error('sendMetricsFlowPing', { stack: e.stack })
return false
}
}
}
module.exports = {
FXA,
FxAOAuthClient
}

Просмотреть файл

@ -1,47 +0,0 @@
'use strict'
const got = require('got')
const AppConstants = require('../app-constants')
const BREACHES_COLLECTION = 'fxmonitor-breaches'
const FX_RS_COLLECTION = `${AppConstants.FX_REMOTE_SETTINGS_WRITER_SERVER}/buckets/main-workspace/collections/${BREACHES_COLLECTION}`
const FX_RS_RECORDS = `${FX_RS_COLLECTION}/records`
const FX_RS_WRITER_USER = AppConstants.FX_REMOTE_SETTINGS_WRITER_USER
const FX_RS_WRITER_PASS = AppConstants.FX_REMOTE_SETTINGS_WRITER_PASS
const RemoteSettings = {
async whichBreachesAreNotInRemoteSettingsYet (breaches) {
const fxRSRecords = await got(FX_RS_RECORDS, {
responseType: 'json',
username: FX_RS_WRITER_USER,
password: FX_RS_WRITER_PASS
})
const remoteSettingsBreachesSet = new Set(
fxRSRecords.body.data.map(b => b.Name)
)
return breaches.filter(({ Name }) => !remoteSettingsBreachesSet.has(Name))
},
async postNewBreachToBreachesCollection (data) {
// Create the record
return await got.post(FX_RS_RECORDS, {
username: FX_RS_WRITER_USER,
password: FX_RS_WRITER_PASS,
json: { data },
responseType: 'json'
})
},
async requestReviewOnBreachesCollection () {
return await got.patch(FX_RS_COLLECTION, {
username: FX_RS_WRITER_USER,
password: FX_RS_WRITER_PASS,
json: { data: { status: 'to-review' } },
responseType: 'json'
})
}
}
module.exports = RemoteSettings

Просмотреть файл

@ -1,108 +0,0 @@
// TODO: to be deprecated
'use strict'
const fs = require('fs')
const path = require('path')
// node.js needs Intl.PluralRules polyfill
require('intl-pluralrules')
const { FluentBundle } = require('fluent')
const mozlog = require('./log')
const { supportedLocales } = require('./package.json')
const log = mozlog('locale-utils')
const localesDir = 'locales'
const availableLanguages = []
const fluentBundles = {}
class FluentError extends Error {
constructor (fluentID = null, ...params) {
super(...params)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, FluentError)
}
this.fluentID = fluentID
this.message = fluentID
}
}
const LocaleUtils = {
init () {
let languageDirectories = supportedLocales.split(',')
if (supportedLocales === '*') {
languageDirectories = fs.readdirSync(localesDir).filter(item => {
return (!item.startsWith('.') && fs.lstatSync(path.join(localesDir, item)).isDirectory())
})
}
for (const lang of languageDirectories) {
try {
const langBundle = new FluentBundle(lang, { useIsolating: false })
const ftlFiles = fs.readdirSync(path.join(localesDir, lang)).filter(item => {
return (item.endsWith('.ftl'))
})
for (const file of ftlFiles) {
const langFTLSource = fs.readFileSync(path.join(localesDir, lang, file), 'utf8')
langBundle.addMessages(langFTLSource)
}
fluentBundles[lang] = langBundle
availableLanguages.push(lang)
} catch (e) {
log.error('loadFluentBundle', { stack: e.stack })
}
}
log.info('LocaleUtils.init', { availableLanguages })
return { availableLanguages, fluentBundles }
},
loadLanguagesIntoApp (app) {
app.locals.AVAILABLE_LANGUAGES = availableLanguages
app.locals.FLUENT_BUNDLES = fluentBundles
},
fluentFormat (supportedLocales, id, args = null, errors = null) {
for (const locale of supportedLocales) {
const bundle = fluentBundles[locale]
try {
const message = bundle.getMessage(id)
return bundle.format(message, args)
} catch (e) {
continue
}
}
return id
},
fluentFormatWithFallback (supportedLocales, id, fallbackId, args = null, errors = null) {
if (!fallbackId) {
log.error('fluentFormatWithFallback: No fallbackId')
return false
}
for (const locale of supportedLocales) {
const bundle = fluentBundles[locale]
if (bundle.hasMessage(id)) {
const message = bundle.getMessage(id)
return bundle.format(message, args)
}
// If first message id doesn't have translation, use fallback id
if (fallbackId && bundle.hasMessage(fallbackId)) {
const message = bundle.getMessage(fallbackId)
return bundle.format(message, args)
}
}
return id
}
}
module.exports = {
FluentError,
LocaleUtils
}

14
log.js
Просмотреть файл

@ -1,14 +0,0 @@
// TODO: to be deprecated
'use strict'
const mozlog = require('mozlog')
const AppConstants = require('./app-constants')
const log = mozlog({
app: 'fx-monitor',
level: AppConstants.MOZLOG_LEVEL,
fmt: AppConstants.MOZLOG_FMT
})
module.exports = log

Просмотреть файл

@ -1,267 +0,0 @@
// TODO: to be deprecated
'use strict'
const { URLSearchParams } = require('url')
const { negotiateLanguages, acceptedLanguages } = require('fluent-langneg')
const AppConstants = require('./app-constants')
const DB = require('./db/DB')
const { FXA } = require('./lib/fxa')
const { FluentError } = require('./locale-utils')
const mozlog = require('./log')
const HIBP = require('./hibp')
const log = mozlog('middleware')
// adds the request object to a res.local var
function addRequestToResponse (req, res, next) {
res.locals.req = req
next()
}
// picks available language by Accept-Language and assigns to request
function pickLanguage (req, res, next) {
res.vary('Accept-Language')
const requestedLanguage = acceptedLanguages(req.headers['accept-language'])
const supportedLocales = negotiateLanguages(
requestedLanguage,
req.app.locals.AVAILABLE_LANGUAGES,
{ defaultLocale: 'en' }
)
req.supportedLocales = supportedLocales
req.fluentFormat = (id, args = null, errors = null) => {
for (const locale of supportedLocales) {
const bundle = req.app.locals.FLUENT_BUNDLES[locale]
if (bundle.hasMessage(id)) {
const message = bundle.getMessage(id)
return bundle.format(message, args)
}
}
return id
}
next()
}
async function recordVisitFromEmail (req, res, next) {
if (req.query.utm_source && req.query.utm_source !== 'fx-monitor') {
next()
return
}
if (req.query.utm_medium && req.query.utm_medium !== 'email') {
next()
return
}
const breachDetailsRE = /breach-details\/(\w*)$/
const capturedMatch = req.path.match(breachDetailsRE)
// Send engagement ping to FXA if req.query contains a valid subscriber ID
if (req.query.subscriber_id && Number.isInteger(Number(req.query.subscriber_id))) {
const subscriber = await DB.getSubscriberById(req.query.subscriber_id)
if (!subscriber.fxa_uid || subscriber.fxa_uid === '') {
next()
return
}
const utmContent = (capturedMatch) ? `&utm_content=${capturedMatch[1]}` : ''
const fxaMetricsFlowPath = `metrics-flow?utm_source=${req.query.utm_source}&utm_medium=${req.query.utm_medium}${utmContent}&event_type=engage&uid=${subscriber.fxa_uid}&service=${AppConstants.OAUTH_CLIENT_ID}`
const fxaResult = await FXA.sendMetricsFlowPing(fxaMetricsFlowPath)
log.info(`fxaResult: ${fxaResult}`)
}
// If user is already signed in, proceed
if (req.session.user) {
next()
return
}
// If user is returning from FXA sign-in, proceed
if (req.path === '/oauth/confirmed') {
next()
return
}
// Redirect users who have clicked "Go to Dashboard" from an email and aren't signed in to the /oauth flow.
if (
req.query.utm_campaign && req.query.utm_campaign === 'go-to-dashboard-link'
) {
const oauthUrl = new URL('/oauth/init', AppConstants.SERVER_URL);
['utm_source', 'utm_campaign', 'utm_medium'].forEach(param => {
if (req.query[param]) {
oauthUrl.searchParams.append(param, req.query[param])
}
})
req.url = `${oauthUrl.pathname}/${oauthUrl.search}`
next()
return
}
next()
}
// Helps handle errors for all async route controllers
// See https://medium.com/@Abazhenov/using-async-await-in-express-with-node-8-b8af872c0016
function asyncMiddleware (fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next)
}
}
function logErrors (err, req, res, next) {
log.error('error', { stack: err.stack })
next(err)
}
function localizeErrorMessages (err, req, res, next) {
if (err instanceof FluentError) {
err.message = req.fluentFormat(err.fluentID)
err.locales = req.supportedLocales
}
next(err)
}
function clientErrorHandler (err, req, res, next) {
if (req.xhr || req.headers['content-type'] === 'application/json') {
res.status(500).send({ message: err.message })
} else {
next(err)
}
}
function errorHandler (err, req, res, next) {
res.status(500)
res.render('subpage', {
analyticsID: 'error',
headline: req.fluentFormat('error-headline'),
subhead: err.message
})
}
async function _getRequestSessionUser (req, res, next) {
if (req.session && req.session.user) {
// make sure the user object has all subscribers and email_addresses properties
return DB.getSubscriberById(req.session.user.id)
}
return null
}
async function requireSessionUser (req, res, next) {
const user = await _getRequestSessionUser(req)
if (!user) {
const queryParams = new URLSearchParams(req.query).toString()
return res.redirect(`/oauth/init?${queryParams}`)
}
const fxaProfileData = await FXA.getProfileData(user.fxa_access_token)
if (fxaProfileData.hasOwnProperty('name') && fxaProfileData.name === 'HTTPError') {
delete req.session.user
return res.redirect('/')
}
await DB.updateFxAProfileData(user, fxaProfileData)
req.session.user = user
req.user = user
next()
}
async function requireAdminUser (req, res, next) {
const user = await _getRequestSessionUser(req)
if (!user) {
const queryParams = new URLSearchParams(req.query).toString()
return res.redirect(`/oauth/init?${queryParams}`)
}
const fxaProfileData = await FXA.getProfileData(user.fxa_access_token)
const admins = AppConstants.ADMINS?.split(',') || []
const isAdmin = admins.includes(JSON.parse(fxaProfileData).email)
const hasFxaError = Object.prototype.hasOwnProperty.call(fxaProfileData, 'name') && fxaProfileData.name
if (hasFxaError) {
delete req.session.user
}
if (!isAdmin || hasFxaError) {
return res.sendStatus(401)
}
await DB.updateFxAProfileData(user, fxaProfileData)
req.session.user = user
req.user = user
next()
}
function getShareUTMs (req, res, next) {
// Step 1: See if the user needs to be redirected to the homepage or to a breach-detail page.
const generalShareUrls = [
'/share/orange', // Header
'/share/purple', // Footer
'/share/blue', // user/dashboard
'/share/'
]
if (generalShareUrls.includes(req.url)) {
// If not breach specific, redirect to "/"
req.session.redirectHome = true
}
// If user has no reference to experiment (default), add skip override
if (typeof (req.session.experimentFlags) === 'undefined') {
req.session.experimentFlags = {
excludeFromExperiment: true
}
}
const excludedFromExperiment = (req.session.experimentFlags.excludeFromExperiment)
// Excluse user from experiment if they don't have any experimentFlags set already.
if (excludedFromExperiment) {
// Step 2: Determine if user needs to have share-link UTMs set
const colors = [
'orange', // Header
'purple', // Footer
'blue' // user/dashboard
]
const urlArray = req.url.split('/')
const color = urlArray.slice(-1)[0]
req.session.utmOverrides = {
campaignName: 'shareLinkTraffic',
campaignTerm: 'default'
}
// Set Color Var in UTM
if (color.length && colors.includes(color)) {
req.session.utmOverrides.campaignTerm = color
}
if (color.length && !colors.includes(color)) {
const allBreaches = req.app.locals.breaches
const breachName = color
const featuredBreach = HIBP.getBreachByName(allBreaches, breachName)
if (featuredBreach) {
req.session.utmOverrides.campaignTerm = featuredBreach.Name
}
}
// Exclude share users
req.session.experimentFlags = {
excludeFromExperiment: true
}
}
next()
}
module.exports = {
addRequestToResponse,
pickLanguage,
recordVisitFromEmail,
asyncMiddleware,
logErrors,
localizeErrorMessages,
clientErrorHandler,
errorHandler,
requireSessionUser,
requireAdminUser,
getShareUTMs
}

15173
package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -1,136 +1,103 @@
{
"name": "blurts-server",
"description": "Firefox Monitor Server",
"name": "monitor",
"version": "1.0.0",
"bugs": {
"url": "https://github.com/mozilla/blurts-server/issues"
},
"dependencies": {
"@fluent/bundle": "^0.17.1",
"@fluent/langneg": "^0.6.2",
"@maxmind/geoip2-node": "^3.1.0",
"@sentry/node": "7.36.0",
"@sentry/tracing": "^7.36.0",
"body-parser": "^1.20.2",
"client-oauth2": "4.3.3",
"connect-redis": "^7.0.0",
"cookie-parser": "^1.4.6",
"csurf": "1.11.0",
"dotenv": "16.0.2",
"esbuild": "^0.17.8",
"express": "^4.18.2",
"express-bearer-token": "2.4.0",
"express-handlebars": "6.0.6",
"express-session": "1.17.1",
"fluent": "0.13.0",
"fluent-langneg": "0.2.0",
"git-rev-sync": "^3.0.2",
"got": "11.8.5",
"helmet": "4.2.0",
"intl-pluralrules": "1.3.1",
"isemail": "3.2.0",
"knex": "^2.2.0",
"mozlog": "3.0.2",
"nodemailer": "^6.7.8",
"nodemailer-express-handlebars": "5.0.0",
"pg": "^8.7.1",
"redis": "^4.6.5",
"sns-validator": "0.3.5",
"uuid": "3.4.0"
},
"devDependencies": {
"@types/express": "^4.17.17",
"@types/gtag.js": "^0.0.12",
"@typescript-eslint/eslint-plugin": "^5.57.0",
"@typescript-eslint/parser": "^5.57.0",
"coveralls": "3.1.1",
"eslint": "^8.15.0",
"eslint-config-standard": "^17.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-n": "^15.2.1",
"eslint-plugin-promise": "^6.0.1",
"jest": "^27.2.5",
"node-mocks-http": "1.11.0",
"nodemon": "^2.0.20",
"redis-mock": "^0.56.3",
"stylelint": "^14.9.1",
"stylelint-config-standard": "^26.0.0",
"typescript": "^5.0.2"
},
"description": "Firefox Monitor",
"type": "module",
"engines": {
"node": "18.12.x",
"npm": "8.x"
},
"homepage": "https://github.com/mozilla/blurts-server",
"license": "MPL-2.0",
"jest": {
"collectCoverageFrom": [
"**/*.js",
"!coverage/**/**.js",
"!db/seeds/**.js",
"!db/migrations/**.js",
"!public/**/**.js",
"!scripts/*.js",
"!loadtests/**/**.js",
"!.eslintrc.js"
"scripts": {
"prestart": "npm run build",
"start": "node src/app.js",
"dev": "nodemon src/app.js",
"build": "node esbuild & npm run copy:root & npm run copy:webp & npm run copy:png & npm run build:svg",
"build:svg": "svgo -f src/client/images/ -r -o dist/images",
"copy:root": "mkdir -p dist/ && cp src/client/*.* dist/",
"copy:webp": "mkdir -p dist/images/ && cp -r src/client/images/*.webp dist/images/",
"copy:png": "mkdir -p dist/images/email/ && cp -r src/client/images/email/*.png dist/images/email/",
"convert:webp": "sh src/scripts/webp.sh",
"db:migrate": "node -r dotenv/config node_modules/knex/bin/cli.js migrate:latest --knexfile src/db/knexfile.js",
"db:rollback": "node -r dotenv/config node_modules/knex/bin/cli.js migrate:rollback --knexfile src/db/knexfile.js",
"test": "NODE_OPTIONS=--loader=testdouble c8 ava",
"e2e": "playwright test src/e2e/",
"lint": "npm run lint:css && npm run lint:js",
"lint:css": "stylelint src/client/css/",
"lint:js": "eslint .",
"lint:ts": "tsc --noEmit",
"fix": "npm run fix:css && npm run fix:js",
"fix:js": "eslint . --fix",
"fix:css": "stylelint src/client/css/ --fix"
},
"nodemonConfig": {
"watch": [
"*",
".env"
],
"setupFilesAfterEnv": [
"./tests/jest.setup.js"
"ignore": [
"src/client/*"
],
"env": {
"LIVE_RELOAD": true
},
"ext": "js,css,json,ftl,env"
},
"ava": {
"files": [
"!src/e2e/"
]
},
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/mozilla/blurts-server.git"
},
"scripts": {
"start": "npm start -w src",
"start:old": "npm run build && node server.js",
"dev": "npm run dev -w src",
"dev:old": "nodemon server.js",
"build": "node esbuild.js",
"db:migrate": "knex migrate:latest --knexfile db/knexfile.js",
"db:rollback": "knex migrate:rollback --knexfile db/knexfile.js",
"docker:build": "docker build -t blurts-server .",
"docker:run": "docker run -p 6060:6060 blurts-server",
"lint": "npm run lint:css && npm run lint:js",
"lint:js": "eslint src",
"lint:ts": "tsc --noEmit",
"lint:css": "stylelint public/css/",
"lint:fluent": "moz-fluent-lint ./locales/en --config .github/linter_config.yml",
"fix": "npm run fix:css && npm run fix:js",
"fix:js": "eslint . --fix",
"fix:css": "stylelint public/css/ --fix",
"test:db:migrate": "NODE_ENV=tests knex migrate:latest --knexfile db/knexfile.js --env tests",
"test:tests": "NODE_ENV=tests HIBP_THROTTLE_DELAY=1000 HIBP_THROTTLE_MAX_TRIES=3 jest --runInBand --coverage tests/",
"test:coveralls": "cat ./coverage/lcov.info | coveralls",
"test:integration": "wdio tests/integration/wdio.conf.js",
"test:integration-headless": "MOZ_HEADLESS=1 wdio tests/integration/wdio.conf.js",
"test:integration-headless-ci": "MOZ_HEADLESS=1 ERROR_SHOTS=1 wdio tests/integration/wdio.conf.js",
"test:integration-docker": "MOZ_HEADLESS=1 wdio tests/integration/wdio.docker.js",
"test": "npm run test:db:migrate && npm run test:tests && (node scripts/is-coveralls-configured.js && npm run test:coveralls || echo 'Skipping coveralls.')"
},
"workspaces": [
"src"
],
"nodemonConfig": {
"events": {
"restart": "npm run build"
},
"watch": [
"./*",
".env",
".env-dist"
],
"ignore": [
"public/dist/*",
"tests/*"
],
"ext": "js,css,hbs,json,ftl"
},
"supportedLocales": "cak,cs,cy,da,de,el,en,en-CA,en-GB,es-AR,es-CL,es-ES,es-MX,fi,fr,fy-NL,gn,hu,kab,ia,id,it,ja,nb-NO,nl,nn-NO,pt-BR,pt-PT,ro,ru,sk,sl,sq,sv-SE,tr,uk,vi,zh-CN,zh-TW",
"homepage": "https://github.com/mozilla/blurts-server",
"license": "MPL-2.0",
"volta": {
"node": "18.12.1",
"npm": "8.19.3"
},
"dependencies": {
"@fluent/bundle": "^0.17.1",
"@fluent/langneg": "^0.6.2",
"@sentry/node": "^7.40.0",
"@sentry/tracing": "^7.38.0",
"client-oauth2": "^4.3.3",
"connect-redis": "^7.0.0",
"cookie-parser": "^1.4.6",
"csrf-csrf": "^2.2.2",
"dotenv": "^16.0.3",
"esbuild": "^0.17.8",
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"express-session": "^1.17.3",
"helmet": "^6.0.0",
"knex": "^2.4.2",
"mozlog": "^3.0.2",
"nodemailer": "^6.9.1",
"pg": "^8.9.0",
"redis": "^4.6.5",
"uuid": "^9.0.0"
},
"devDependencies": {
"@playwright/test": "^1.32.3",
"@types/express": "^4.17.17",
"@typescript-eslint/eslint-plugin": "^5.59.1",
"@typescript-eslint/parser": "^5.59.1",
"ava": "^5.1.0",
"c8": "^7.12.0",
"eslint": "^8.32.0",
"eslint-config-standard": "^17.0.0",
"eslint-plugin-check-file": "^2.2.0",
"eslint-plugin-header": "^3.1.1",
"eslint-plugin-jsdoc": "^40.0.0",
"node-mocks-http": "^1.12.1",
"nodemon": "^2.0.20",
"redis-mock": "^0.56.3",
"stylelint": "^15.6.0",
"stylelint-config-standard": "^33.0.0",
"svgo": "^3.0.2",
"testdouble": "^3.16.8",
"typescript": "^5.0.4"
}
}

Просмотреть файл

@ -13,12 +13,12 @@ dotenv.config()
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './e2e/specs',
testDir: 'src/e2e/specs',
/* Maximum time one test can run for. */
timeout: 60_000,
/* Global setup */
globalSetup: './e2e/globalSetup.js',
globalSetup: 'src/e2e/globalSetup.js',
/* Max time in milliseconds the whole test suite can to prevent CI breaking. */
globalTimeout: 360_000,
@ -101,7 +101,7 @@ export default defineConfig({
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
outputDir: './e2e/test-results/',
outputDir: 'src/e2e/test-results/',
/* Run your local dev server before starting the tests */
webServer: {

Просмотреть файл

@ -1,30 +0,0 @@
{
"name": "blurts-server",
"description": "The app powering monitor.firefox.com.",
"repository": {
"url": "https://github.com/mozilla/blurts-server",
"license": "MPL-2.0",
"tests": "https://travis-ci.org/mozilla/blurts-server/"
},
"participate": {
"home": "https://github.com/mozilla/blurts-server",
"docs": "https://github.com/mozilla/blurts-server/blob/master/README.md"
},
"bugs": {
"list": "https://github.com/mozilla/blurts-server/issues",
"report": "https://github.com/mozilla/blurts-server/issues/new",
"mentored": "https://github.com/mozilla/blurts-server/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22"
},
"urls": {
"prod": "https://monitor.firefox.com",
"stage": "https://stage.firefoxmonitor.nonprod.cloudops.mozgcp.net/",
"dev": "https://fx-breach-alerts.herokuapp.com/"
},
"keywords": [
"node",
"postgres",
"security",
"api",
"email"
]
}

Просмотреть файл

@ -1,24 +0,0 @@
@import 'includes/variables.css';
@import 'includes/fonts.css';
@import 'partials/all-breaches.css';
@import 'partials/main.css';
@import 'partials/articles.css';
@import 'partials/breach-cards.css';
@import 'partials/breach-detail.css';
@import 'partials/breach-stats.css';
@import 'partials/dashboard.css';
@import 'partials/email-card.css';
@import 'partials/feature-tip-group.css';
@import 'partials/footer-about.css';
@import 'partials/footer.css';
@import 'partials/forms.css';
@import 'partials/fx-bento.css';
@import 'partials/header.css';
@import 'partials/latest-breach.css';
@import 'partials/monitor.css';
@import 'partials/product-promos.css';
@import 'partials/scan-results.css';
@import 'partials/security-tips.css';
@import 'partials/sign-up-banner.css';
@import 'partials/subpage.css';
@import 'partials/vpn-banner.css';

Просмотреть файл

@ -1,39 +0,0 @@
@font-face {
font-family: Metropolis;
font-style: normal;
font-weight: 600;
src: local('Metropolis-SemiBold'), url('../../fonts/Metropolis/Metropolis-SemiBold.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: Inter;
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Inter-Regular'), url('../../fonts/Inter/Inter-Regular.woff2') format('woff2');
}
@font-face {
font-family: Inter;
font-style: italic;
font-weight: 400;
font-display: swap;
src: local('Inter-Italic'), url('../../fonts/Inter/Inter-Italic.woff2') format('woff2');
}
@font-face {
font-family: Inter;
font-style: normal;
font-weight: 700;
font-display: swap;
src: local('Inter-Bold'), url('../../fonts/Inter/Inter-Bold.woff2') format('woff2');
}
@font-face {
font-family: Inter;
font-style: italic;
font-weight: 700;
font-display: swap;
src: local('Inter-BoldItalic'), url('../../fonts/Inter/Inter-BoldItalic.woff2') format('woff2');
}

Просмотреть файл

@ -1,46 +0,0 @@
:root {
--take-back-control-gradient: linear-gradient(253deg, #2150c8, #712291);
--fxa-sign-up-gradient: linear-gradient(103deg, #374dcc, #9456f9 0%, #394cca 34%, #312a65 69%, #1d1133);
--ink-light: #2b1e44;
--ink: #20123a;
--ink-dark: #1d1133;
--padding: 16px;
--max-width: 1270px;
--headline: 40px;
--top-headline: 56px;
--border-color: #eee;
--text-light: #5e5e72;
--alert-red: #ff4f5e;
--purple: #9059ff;
--violet1: #c689ff;
--violet2: #ab71ff;
--violet3: #9059ff;
--violet4: #7542e5;
--violet5: #592acb;
--violet6: #45278d;
--purple1: #f565ff;
--purple5: #722291;
--purple6: #332869;
--purple7: #393473;
--pink4: #e31587;
--blue1: #0df;
--blue2: #0090ed;
--blue3: #0060df;
--blue4: #0250bb;
--blue5: #073072;
--orange1: #ffa266;
--green1: #54ffbd;
--orange5: #ff7139;
--orange6: #e25920;
--bg-light: #f9f7fd;
--paragraph-font-size: 16px;
--grey90a3: rgb(12 12 13 / 0.3);
--grey1: #f9f9fa;
--grey2: #ededf0;
--grey3: #cdcdd4;
--grey6: #5e5e72;
--grey8: #42425a;
--grey9: #202340;
--monitor-gradient: linear-gradient(-90deg, #ff9100 0%, #f10366 50%, #6173ff 100%);
--vpn-purple: #c5c8fb;
}

Просмотреть файл

@ -1,181 +0,0 @@
.all-breaches-main {
background: url('../../img/svg/ab-bg.svg') left top / 100% auto;
}
span.x-close-bg,
svg.x-close {
pointer-events: none;
}
.hide-breaches {
opacity: 0;
transition: opacity 0.15s ease;
}
.fuzzy-wrapper {
position: relative;
display: flex;
max-width: 320px;
margin: auto;
width: 100%;
align-items: center;
flex-direction: column;
}
button.fuzzy-find-show-breaches {
position: absolute;
right: 16px;
bottom: 0;
top: 0;
margin: auto;
min-width: 1.25rem;
min-height: 1.25rem;
width: 1.25rem;
height: 1.25rem;
border: none;
border-radius: 50%;
padding: 0;
opacity: 0;
transition: opacity 0.1s ease-in-out;
}
.show-all-breaches.btn-violet-primary {
margin: var(--padding) auto calc(var(--padding) * 3) auto;
}
#no-results-blurb {
font-size: 18px;
font-weight: 700;
text-align: center;
pointer-events: none;
display: none;
opacity: 0;
margin: 48px auto auto;
color: var(--purple7);
transition: all 0.15s ease-in-out;
}
#no-results-blurb.show {
display: inline-block;
opacity: 1;
transition: all 0.1s ease-in-out;
}
button.fuzzy-find-show-breaches.show {
opacity: 1;
transition: opacity 0.15s ease-in-out;
}
.x-close-bg {
background-color: var(--grey3);
border-radius: 50%;
display: flex;
height: 1.25rem;
width: 1.25rem;
align-items: center;
justify-content: center;
padding: 2px;
}
.x-close-bg svg path {
fill: var(--grey1);
}
.all-breaches-headline {
color: var(--ink);
margin: auto;
}
.fuzzy-form {
max-width: 320px;
background-color: rgb(255 255 255 / 0.05);
border: 1px solid rgb(255 255 255 / 0.1);
position: relative;
margin: 20px auto;
}
.fuzzy-find-input {
background-color: rgb(255 255 255 / 1);
border: 1px solid var(--grey90a3);
width: 100%;
display: inline-block;
border-radius: 4px;
text-indent: 36px;
}
.fuzzy-find-input,
.fuzzy-find-submit {
min-height: 50px;
}
input#fuzzy-find-input::placeholder,
input#fuzzy-find-input {
font-size: 16px;
color: #474747;
}
.fuzzy-find-submit {
position: absolute;
left: 8px;
top: 0;
bottom: 0;
margin: auto;
color: var(--grey2);
border: none;
display: flex;
align-items: center;
}
.fuzzy-find-input:focus,
.fuzzy-find-input:active,
.fuzzy-find-submit:active,
.fuzzy-find-submit:focus {
border: none;
outline: none;
box-shadow: none;
}
.search-icon {
pointer-events: none;
}
.breaches-loader {
position: absolute;
z-index: 101;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: rgb(255 255 255 / 1);
transition: all 0.1s ease;
}
.ab.breach-card {
max-width: 31%;
margin: 12px;
}
.all-breaches {
padding: var(--padding);
flex-flow: row wrap;
justify-content: flex-start;
}
@media screen and (max-width: 1000px) {
.ab.breach-card.three-up {
flex: 1 1 45%;
max-width: 45%;
}
}
@media screen and (max-width: 800px) {
.all-breaches {
padding: var(--padding);
}
.ab.breach-card.three-up {
max-width: 100%;
margin: 0.5rem auto;
flex: 1 1 100%;
}
}

Просмотреть файл

@ -1,124 +0,0 @@
.article-list-subhead {
font-weight: 500;
font-size: 18px;
line-height: 1.5;
padding-right: 0;
margin-bottom: 0;
}
.article {
margin-bottom: 1.5rem;
}
.article-list {
margin: 1.75rem 0;
}
li.article {
margin-bottom: 32px;
}
.article-link {
font-weight: 600;
font-size: 16px;
text-decoration: none;
color: rgb(255 255 255 / 1);
display: inline-block;
align-items: center;
}
.article-link:hover {
text-decoration: underline;
}
.article-link .arrow-head-right path {
fill: rgb(255 255 255 / 1);
}
.article-list-headline {
margin-bottom: var(--padding);
line-height: 1.1;
}
.article-list-headline,
.article-list-subhead {
color: var(--grey1);
}
.security-tips-link {
margin-left: 3.25rem;
display: flex;
font-size: 14px;
}
li.logo-title-wrapper.flx.article {
margin-left: 0.25rem;
}
.take-back-control-banner {
max-width: 900px;
}
.article-list-headline-wrapper {
padding: var(--padding);
}
.article-list,
.article-list-headline-wrapper {
max-width: 500px;
display: flex;
flex-direction: column;
margin: auto;
padding: var(--padding);
}
@media screen and (max-width: 1100px) {
.col-8.article-section-list-wrapper {
margin-top: 2rem;
}
.latest-breach-wrapper {
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.article-list-content-wrap {
padding: 0 0.5rem;
}
}
@media screen and (max-width: 900px) {
.articles {
margin-bottom: 1.25rem;
}
.icon-inline-wrapper {
margin-right: 1.15rem;
}
.take-back-control-banner {
flex-direction: column;
}
}
@media screen and (max-width: 600px) {
.article-list-headline {
font-size: var(--headline);
}
.security-tips-link {
margin-left: 2.25rem;
}
li.article {
margin-bottom: 24px;
}
.article-list-subhead {
padding-right: 10%;
}
li.logo-title-wrapper.flx.article {
margin-left: 0;
}
}

Просмотреть файл

@ -1,150 +0,0 @@
.breach-card {
--logo-dmns: 24px;
--logo-margin: 20px;
padding: 20px;
border-radius: 8px;
display: flex;
position: relative;
margin: 0.5rem;
color: var(--ink);
background-color: rgb(255 255 255 / 1);
}
.breach-key {
font-size: 12px;
margin-top: 8px;
color: #5b5b5b;
}
.breach-value {
font-size: 14px;
color: var(--ink);
font-weight: 500;
}
.breach-key,
.breach-value {
line-height: 1.5;
}
.breach-title {
padding-top: 2px;
font-size: 18px;
font-weight: 600;
font-family: Metropolis, sans-serif;
}
.breach-info-wrapper {
height: 100%;
justify-content: space-between;
}
.breach-logo {
max-height: var(--logo-dmns);
max-width: var(--logo-dmns);
}
.breach-logo-wrapper { /* reduce jumping on lazyload */
content: '';
display: block;
min-height: var(--logo-dmns);
min-width: var(--logo-dmns);
max-height: var(--logo-dmns);
max-width: var(--logo-dmns);
margin-right: var(--logo-margin);
}
.breach-card-link-wrap {
padding-top: 12px;
}
.breach-title,
.breach-value,
.breach-key {
display: block;
}
.two-up {
flex: 1 1 45%;
margin: var(--padding);
max-width: 46%; /* prevent stretching in scan results */
}
.scan-res-breaches .two-up {
flex: 1 1 47%;
margin: var(--padding) 8px;
max-width: 47%;
}
.three-up {
flex: 1 1 30%;
min-width: 260px;
}
.new-breach-card {
border: 2px solid var(--alert-red);
}
.resolved-breach-card {
border: 2px solid #20c3a2;
opacity: 0.6;
}
.resolved-breach-card span.breach-title {
padding-right: 20px;
}
.resolved-breach-indicator {
background-color: #20c3a2;
position: absolute;
right: 20px;
}
.breach-card-status {
display: flex;
margin: 0 0 0 auto;
color: white;
font-weight: 600;
font-size: 12px;
}
.breach-status-message {
margin: 0 0 auto;
padding: 2px 6px;
border-radius: 2px;
}
.new-breach-message {
background-color: var(--alert-red);
}
@media screen and (max-width: 1000px) {
.three-up {
max-width: 48%;
flex: 1 1 48%;
}
}
@media screen and (max-width: 800px) {
.breach-card {
margin: 0.75rem 0;
}
.email-card.active .breach-card.ec.two-up {
display: flex;
}
.two-up,
.scan-res-breaches .two-up,
.three-up {
max-width: 100%;
flex: 1 1 100%;
}
}
@media screen and (max-width: 500px) {
.breach-card {
margin: 0.5rem 0;
}
}

Просмотреть файл

@ -1,542 +0,0 @@
main.breach-detail {
padding-left: 24px;
padding-right: 24px;
color: var(--grey1);
}
.bg-split {
background-image: linear-gradient(rgb(255 255 255 / 0) 50%, #fff 0);
}
.breach-type:hover {
text-decoration: underline;
}
.delayed-reporting {
margin-top: 18px;
display: inline-block;
}
.detail-section {
padding: 60px 0;
}
.detail-section.bg-split {
padding: 0 0 20px;
}
.bg-split .mw-700 {
padding: 0;
}
.breach-detail-headline {
margin: auto auto 8px;
color: var(--ink);
max-width: 100%;
}
.breach-detail-logo-wrapper {
margin-bottom: var(--padding);
}
.breach-detail-logo.breach-logo {
max-width: 10rem;
max-height: 10rem;
}
.glyph {
display: flex;
margin-right: 16px;
}
.overview,
.overview p {
font-size: 16px;
}
.overview p {
margin: 20px 0 0;
}
.additional-data-types {
display: block;
margin: 0;
}
.additional-data-types-wrapper .source-info {
margin-left: 0;
}
.breach-type {
margin-top: 8px;
font-size: 14px;
color: var(--grey6);
}
.priority-data-classes-list {
width: 100%;
flex-wrap: wrap;
margin: 0;
}
.lower-priority-data-types {
margin-top: 0;
margin-bottom: 28px;
}
.priority-data-type {
margin-bottom: 28px;
}
.data-type {
font-weight: 600;
}
.data-type,
.data-action {
margin: 0;
}
/* recommendations */
.rec-img {
min-width: 52px;
height: 48px;
margin: 0 auto;
background-position: top center;
background-size: contain;
background-repeat: no-repeat;
}
.rec-pw-1 {
background-image: url('../../img/recommendation-icons/change-password.svg');
}
.rec-pw-2 {
background-image: url('../../img/recommendation-icons/update-passwords.svg');
}
.rec-pw-3 {
background-image: url('../../img/recommendation-icons/store-safe-place.svg');
}
.rec-pw-4 {
background-image: url('../../img/recommendation-icons/set-2FA.svg');
}
.rec-ssn {
background-image: url('../../img/recommendation-icons/review-credit.svg');
}
.rec-bank-acc {
background-image: url('../../img/recommendation-icons/monitor-bank.svg');
}
.rec-cc {
background-image: url('../../img/recommendation-icons/monitor-credit-cards.svg');
}
.rec-ip-non-us {
background-image: url('../../img/recommendation-icons/use-mask-location-service.svg');
}
.rec-ip-us {
background-image: url('../../img/recommendation-icons/use-mask-IP-service.svg');
}
.rec-hist-pw {
background-image: url('../../img/recommendation-icons/change-password.svg');
}
.rec-sec-qa {
background-image: url('../../img/recommendation-icons/unique-answers.svg');
}
.rec-phone-num {
background-image: url('../../img/recommendation-icons/avoid-sharing-phone.svg');
}
.rec-dob {
background-image: url('../../img/recommendation-icons/strengthen-pin-security.svg');
}
.rec-pins {
background-image: url('../../img/recommendation-icons/strengthen-pin-security.svg');
}
.rec-address {
background-image: url('../../img/recommendation-icons/avoid-address.svg');
}
.rec-gen-1 {
background-image: url('../../img/recommendation-icons/unique-strong-pwds.svg');
}
.rec-gen-2 {
background-image: url('../../img/recommendation-icons/store-safe-place.svg');
}
.rec-gen-3 {
background-image: url('../../img/recommendation-icons/avoid-personal-info.svg');
}
.rec-gen-4 {
background-image: url('../../img/recommendation-icons/update-regularly.svg');
}
.rec-email {
background-image: url('../../img/recommendation-icons/masked-email.svg');
}
.overflow-recs {
visibility: visible;
transition: all 0.3s ease;
}
button.fade-out {
opacity: 0;
padding-top: 0;
padding-bottom: 0;
min-height: 0;
border: none;
font-size: 0;
visibility: hidden;
transition: all 0.2s ease;
}
.affected-email {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block !important;
margin: 0;
align-self: center;
}
.affected-email-notification {
border-radius: 2px;
background-color: #ffffd1;
color: #89201f;
font-size: 14px;
margin: 60px auto;
}
.affected-email-message {
padding: 12px;
margin: 0;
vertical-align: middle;
display: inline-block;
}
.resolve-breaches-wrapper {
background-color: #ffffd1;
border-radius: 8px;
}
.affected-email-message::before {
content: '';
height: 20px;
width: 20px;
display: inline-block;
background-image: url('../../img/svg/notification.svg');
background-position: center center;
background-repeat: no-repeat;
background-size: contain;
margin-right: 10px;
vertical-align: middle;
}
.blue-link.what-to-do-next {
margin-left: 4px;
}
.resolve-headline {
padding: 24px 24px 12px;
color: var(--purple7);
font-size: 24px;
margin: 0 auto;
}
.resolution-message {
color: var(--grey8);
margin: 0;
}
.affected-emails-list-wrapper {
padding: 24px 32px;
line-height: 1.5;
position: relative;
}
.affected-emails-list-wrapper::before {
background: var(--monitor-gradient);
height: 4px;
width: 100%;
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
}
.resolution-message span {
font-style: italic;
}
.affected-emails-list {
max-width: 588px;
margin: auto;
}
.affected-email-list-item {
padding: 30px 40px;
border-bottom: 1px solid rgb(0 0 0 / 0.2);
color: var(--ink);
}
.affected-email-list-item:last-of-type {
border-bottom: 1px solid rgb(0 0 0 / 0);
}
.resolve-undo {
margin-top: 4px;
display: block;
}
.resolve-button {
margin-left: 8px;
position: relative;
transition: all 0.3s ease !important;
max-width: 200px !important;
}
.resolve-button::after {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
content: '';
display: block;
background-position: center center;
background-repeat: no-repeat;
background-size: 30px;
opacity: 0;
transition: all 0.5s ease !important;
}
.resolve-button.btn-blue-primary::after {
background-image: url('../../img/svg/new-loader.svg');
}
.resolve-button.btn-ghost::after {
background-image: url('../../img/svg/loader-dark.svg');
}
.resolve-button.btn-ghost.loading {
color: rgb(0 0 0 / 0.1);
}
.resolve-button.btn-ghost.loading span {
color: rgb(0 96 223 / 0.102);
}
.resolve-button.btn-blue-primary.loading {
color: rgb(255 255 255 / 0.1);
transition: all 0.3s ease;
}
.resolve-button.loading::after {
opacity: 1;
}
.breach-resolution-modal {
padding: var(--padding);
height: 100vh;
width: 100vw;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: rgb(255 255 255 / 0);
z-index: 1000;
display: none;
visibility: hidden;
transition: all 0.3s ease;
}
.breach-resolution-modal.modal-loading {
background-color: rgb(255 255 255 / 0.1);
display: flex;
visibility: visible;
transition: all 0.3s ease;
}
.breach-resolution-modal.modal-open {
background-color: rgb(255 255 255 / 1);
transition: all 0.3s ease;
}
.confirmation-modal-wrapper {
position: relative;
padding: 60px var(--padding);
border-radius: 8px;
margin: auto;
opacity: 0;
transition: all 0.2s ease;
}
.modal-open .confirmation-modal-wrapper {
opacity: 1;
transition: opacity 0.2s ease;
}
.modal-content {
max-width: 350px;
width: 100%;
margin: auto;
}
.modal-headline {
color: var(--purple7);
font-size: 24px;
line-height: 1.3;
}
.overlay-resolved-first-breach::before {
background-image: url('../../img/svg/resolution-overlays/resolved-first-breach.svg');
}
.overlay-take-that-hackers::before {
background-image: url('../../img/svg/resolution-overlays/take-that-hackers.svg');
}
.overlay-another-breach-resolved::before {
background-image: url('../../img/svg/resolution-overlays/another-breach-resolved.svg');
}
.overlay-marked-as-resolved::before {
background-image: url('../../img/svg/resolution-overlays/marked-as-resolved.svg');
}
.modal-headline::before {
height: 200px;
width: auto;
margin: 0 auto 20px;
background-position: center center;
background-size: contain;
background-repeat: no-repeat;
display: block;
content: '';
}
.modal-progress-message {
line-height: 1.5;
margin: 0 auto 24px;
}
.go-to-dash,
.modal-content .progress-header {
margin: 0 auto 32px;
}
.blue-link.return-to-breach-details {
margin: 0 auto;
border-top: none;
border-left: none;
border-right: none;
border-bottom: 1px solid rgb(2 80 187 / 0);
transition: all 0.3s ease;
}
.blue-link.return-to-breach-details:hover,
.blue-link.return-to-breach-details:focus {
text-decoration: none;
border-bottom-color: var(--blue4);
transition: all 0.3s ease;
}
.close-modal-btn {
position: absolute;
top: 0;
right: 0;
height: 24px;
width: 24px;
border: none;
background-image: url('../../img/x-close-gray.svg');
background-size: contain;
background-repeat: no-repeat;
background-position: center center;
border-radius: 50%;
}
@media screen and (max-width: 800px) {
.bg-split .mw-700 {
padding: 0 24px;
}
.modal-headline::before {
height: 200px;
}
.confirmation-modal-wrapper {
height: 100%;
}
.affected-email-notification {
margin: 120px auto 60px;
}
.affected-email-list-item {
padding-left: 0;
padding-right: 0;
}
}
@media screen and (max-width: 600px) {
.affected-email {
margin-bottom: 12px;
text-align: center;
white-space: wrap;
word-break: break-all;
max-width: 100%;
}
.modal-headline::before {
height: 150px;
}
.breach-recommendations {
text-align: center;
max-width: 400px;
}
.rec-img {
height: 52px;
width: 100%;
margin: 0 auto 20px;
background-position: bottom center;
}
.rec-pw-1,
.rec-pw-2,
.rec-gen-4,
.rec-hist-pw,
.rec-ip-non-us,
.rec-ip-us,
.rec-sec-qa {
height: 40px;
}
.modal-content .progress-header {
display: inline-block;
}
}
@media screen and (max-width: 500px) {
.affected-email-list-item {
flex-direction: column;
}
}

Просмотреть файл

@ -1,193 +0,0 @@
/* stylelint-disable-next-line selector-class-pattern */
.monitoredEmails {
background-color: var(--violet3);
}
/* stylelint-disable-next-line selector-class-pattern */
.numBreaches {
background-color: var(--violet4);
}
.passwords {
background-color: var(--violet5);
}
.breach-stat-number,
.stat-headline {
color: rgb(255 255 255 / 1);
justify-content: center;
width: 100%;
}
.breach-stat-number {
font-size: 56px;
line-height: 1.14;
min-width: 60px;
}
.breach-stat-row {
padding: 32px var(--padding);
max-width: 31.25%;
border-radius: 8px;
}
.stat-row-title-wrap {
display: flex;
flex-direction: column;
align-items: flex-start;
position: relative;
}
.stat-headline {
font-weight: 400;
font-size: 16px;
line-height: 1.33;
text-align: center;
}
.dash-stats {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-bottom: calc(var(--padding) * 4);
}
.resolved-breach-indicator,
.progress-header::before {
height: 24px;
min-width: 24px;
display: inline-block;
vertical-align: middle;
background-color: #20c3a2;
background-image: url('../../img/svg/white-check.svg');
background-repeat: no-repeat;
background-position: center center;
content: '';
border-radius: 50%;
}
.progress-header::before {
margin-right: 8px;
}
.progress-bar-wrapper {
margin-bottom: calc(var(--padding) * 4);
}
.progress-bar {
-webkit-appearance: none;
background: var(--monitor-gradient);
color: #fff;
border-radius: 4px;
height: 24px;
overflow: hidden;
margin-top: 8px;
margin-bottom: 8px;
width: 100%;
direction: rtl;
padding: 0;
border: none;
box-shadow: 0 0 0 1px var(--grey3);
}
/* removes black border on edge */
.progress-bar::-ms-fill {
border-color: currentcolor;
}
.progress-status-message {
color: var(--purple7);
margin: auto;
max-width: 640px;
align-items: center;
}
.progress-message-subhead {
margin-bottom: 8px;
}
.progress-message-body {
margin-top: 0;
margin-bottom: 0;
line-height: 1.5;
}
.breach-resolution-img {
height: 115px;
width: 120px;
min-width: 120px;
margin-right: 20px;
background-repeat: no-repeat;
background-position: center center;
background-size: contain;
}
.breach-resolution-intro {
background-image: url('../../img/svg/breach-resolution-intro.svg');
}
.breach-resolution-complete {
background-image: url('../../img/svg/breach-resolution-complete.svg');
}
progress[value]::-webkit-progress-bar {
background: transparent;
}
.progress-bar::-moz-progress-bar {
background-color: rgb(255 255 255 / 1);
transition: all 0.5s ease;
}
progress.progress-bar::-webkit-progress-value {
background-color: rgb(255 255 255 / 1);
box-shadow: 30px -9px 0 9px white;
transition: all 0.5s ease;
}
.percent-complete {
align-self: center;
}
@media screen and (max-width: 800px) {
.dash-stats {
flex-direction: column;
}
.breach-stat-row {
max-width: 100%;
padding: 16px;
margin-bottom: 16px;
}
.stat-row-title-wrap {
flex-direction: row;
align-items: center;
}
.stat-headline {
text-align: left;
}
.breach-stat-number {
width: auto;
font-size: 30px;
}
}
@media screen and (max-width: 600px) {
.img-placeholder {
margin-bottom: 20px;
}
.progress-status-message {
flex-direction: column;
text-align: center;
}
}
@media screen and (max-width: 550px) {
.percent-complete {
display: none;
}
}

Просмотреть файл

@ -1,191 +0,0 @@
.welcome-back {
font-size: 18px;
line-height: 1.5;
color: var(--grey8);
margin: auto;
}
.dashboard.clear-header {
padding-top: calc(var(--header-height) + 80px);
position: relative;
background-color: rgb(255 255 255 / 1);
}
h2.pref-headline {
font-size: 32px;
margin-bottom: 0;
}
h3.pref-section-headline {
margin: 60px 0 32px;
}
h3.pref-section-headline.remove {
margin-bottom: 12px;
}
h2.pref-headline.breach-summary {
margin-top: calc(var(--padding) * 5);
margin-bottom: 24px;
}
.dashboard-summary {
position: relative;
}
.dashboard-summary::before {
content: '';
background: #eee url('../../img/svg/scan-res-bg.svg') top center / cover;
position: absolute;
width: 100vw;
height: calc(100% + var(--header-height));
top: calc(-1 * var(--header-height));
left: calc(50% - 50vw);
z-index: -1;
}
.pref {
width: 100%;
max-width: 600px;
display: flex;
flex-direction: column;
align-self: center;
}
.pref .email-add {
margin-bottom: 0;
}
.pref-remove {
margin-bottom: 60px;
}
.preferences {
background-size: auto;
padding-bottom: 40px;
}
.email-pref {
font-size: 14px;
margin: 40px 0 12px;
font-weight: 600;
color: #686869;
}
.email-pref.fxa-primary-email {
margin-top: 0;
}
.email-verification-required {
color: var(--alert-red);
display: flex;
align-items: center;
}
.email-verification-required::before {
content: '';
background-image: url('../../img/svg/data-compromised.svg');
background-repeat: no-repeat;
background-size: 100%;
margin-right: 8px;
width: 12px;
height: 12px;
display: inline-block;
}
.trash-can {
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
pointer-events: none;
margin: auto;
}
.link-header-wrapper {
margin: 80px 0 60px;
}
.email-cards {
width: 100%;
margin-bottom: 80px;
}
span.dashboard-email-sent {
background-color: var(--green1);
color: #084036;
padding: 0.5rem 0.75rem;
margin: calc(var(--padding) + 0.5rem) auto 0.5rem auto;
font-size: 14px;
}
p.confirm-submit {
color: #686869;
font-size: 14px;
margin: 0 auto;
letter-spacing: 0.01em;
}
span.email-confirmed {
font-size: 18px;
margin: 0 auto 24px;
line-height: 1.5;
}
.dashboard-add-email {
padding: var(--padding) var(--padding) calc(var(--padding) * 3) var(--padding);
}
.pref input.email-add-submit {
font-size: 14px;
}
.dash-take-back-wrapper {
padding: 60px 0;
width: 100vw;
}
@media screen and (max-width: 900px) {
.pref {
padding: 0 24px;
}
}
@media screen and (max-width: 800px) {
.email-cards {
margin-bottom: 40px;
}
.link-header-wrapper {
margin-bottom: 24px;
}
.email-cards .e-info {
flex-direction: column;
align-items: flex-start;
}
/* .dashboard.clear-header {
padding-top: 156px;
} */
.pref .email-add {
margin-bottom: 8px;
}
}
@media screen and (max-width: 600px) {
h2.pref-headline {
font-size: 2rem;
}
.welcome-back {
font-size: 14px;
}
.pref {
padding-left: 24px;
padding-right: 24px;
}
}

Просмотреть файл

@ -1,446 +0,0 @@
.email-card.preferences .e-info {
border-bottom: none;
padding-bottom: 0;
flex-direction: column;
width: auto;
}
.e-info-content {
flex-wrap: wrap;
flex: 1;
}
.preferences .e-info-content {
flex-direction: column;
}
.email-card {
width: 100%;
}
.email-card.breaches-dash {
padding-top: 0;
padding-bottom: 0;
margin-bottom: 60px;
}
.email-card.breaches-dash:last-of-type,
.breaches-dash.zero-breaches,
.breaches-dash.zero-unresolved-breaches {
margin-bottom: 32px;
}
.email-card.preferences {
padding: 20px;
background-color: rgb(255 255 255 / 1);
border: 1px solid #e8e8e8;
margin: 0 0 16px;
border-radius: 4px;
}
.email-card.primary-email-card.preferences {
margin-bottom: 0;
}
.e-info {
display: inline-flex;
align-items: center;
flex-flow: row wrap;
border-bottom: 2px solid #e8e8e8;
padding-bottom: 12px;
max-width: 100%;
width: 100%;
}
.preferences .e-info {
align-items: flex-start;
max-width: 90%;
}
.preferences .e-num-breaches {
margin-top: 4px;
font-size: 14px;
color: var(--grey8);
}
.preferences .e-address {
font-size: 16px;
}
.preferences .e-address::after {
display: none;
}
.e-address {
font-size: 20px;
font-weight: 600;
color: var(--purple6);
max-width: 100%;
overflow-wrap: break-word;
}
.e-address span.light {
font-weight: 300;
color: var(--grey6);
font-size: 0.75rem;
}
.e-num-breaches {
font-size: 20px;
display: block;
color: var(--purple6);
}
.e-address::after {
content: '-';
margin: auto 6px;
font-weight: 400;
}
.breach-title-wrapper.ec {
border-bottom: 1px solid var(--border-color);
}
/* email breach lists */
.e-breach-list {
display: flex;
justify-content: space-between;
flex-flow: row wrap;
visibility: hidden;
margin: auto;
max-width: 780px;
opacity: 0;
max-height: 0;
transition: all 0.2s ease;
}
.e-breach-list .breach-card {
max-height: 0;
transition: all 0.2s ease;
}
.active .e-breach-list .breach-card {
max-height: 10000px;
transition: all 0.2s ease;
}
.active .show-additional-breaches.hide .breach-card {
display: none;
max-height: 0;
}
.show-remaining-breaches-wrapper {
width: 100%;
}
.show-remaining-breaches.btn-violet-secondary {
margin: 16px auto auto;
}
.show-remaining-breaches.hide {
display: none;
}
.email-card.active .e-breach-list {
visibility: visible;
opacity: 1;
max-height: 1000000px;
padding-top: 16px;
transition: all 0.3s ease;
}
.email-card.zero-unresolved-breaches .e-breach-list,
.email-card.active .hide-additional-breaches.hide .breach-card,
.email-card.inactive .hide-additional-breaches.hide .breach-card {
display: none;
}
.breach-card.resolved-breach-card {
visibility: hidden;
display: none;
}
.email-card.zero-unresolved-breaches.show-resolved-breach-cards.active .e-breach-list,
.show-resolved-breach-cards .breach-card.resolved-breach-card {
visibility: visible;
opacity: 0.6;
display: flex !important;
transition: all 0.25s ease;
}
.hide-resolved-message,
.show-resolved-breach-cards span.show-resolved-message {
visibility: hidden;
display: none;
}
.show-resolved-breach-cards span.hide-resolved-message {
display: flex;
visibility: visible;
}
button.toggle-resolved-breaches {
margin: auto 0 auto auto;
border: 0;
font-size: 14px;
color: #42435a;
pointer-events: all;
}
.e-toggle-info-wrapper {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
pointer-events: none;
position: relative;
}
.svg-wrap {
border-width: 0;
border-radius: 0.25rem;
padding: 0.5rem 0 0;
pointer-events: all;
text-align: left;
}
/* preferences buttons/options */
.remove-email .x-close {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
pointer-events: none;
}
.remove-email .x-close path {
fill: var(--ink);
}
.button-resend-wrapper {
margin-top: 0.5rem;
align-items: center;
}
.change-primary-email,
.remove-fxm,
.resend-email {
pointer-events: all;
transition: all 0.15s ease;
}
.resend-email,
.change-primary-email {
font-size: 14px;
}
.resend-email {
min-height: 0;
text-align: left;
padding: 0;
border-width: 0;
transition: opacity 0.15s ease-in-out;
}
.sending {
margin-left: 16px;
font-size: 12px;
background: #3fe1b0;
color: rgb(255 255 255 / 1);
padding: 2px 4px;
border-radius: 4px;
font-weight: 600;
}
.change-primary-email:hover,
.change-primary-email:focus,
.toggle.svg-wrap:focus,
.toggle.svg-wrap:hover,
.resend-email:hover,
.resend-email:focus {
outline: none;
border: none;
text-decoration: underline;
box-shadow: none;
text-align: left;
pointer-events: all;
}
form.remove-email:hover svg path {
fill: #cf434f;
transition: fill 0.15s ease-in-out;
}
.toggle {
height: 100%;
transition: all 0.1s ease;
width: 100%;
}
.show-email-breaches.toggle {
pointer-events: none;
display: none;
right: 0;
left: 0;
}
svg.toggle-down {
border-radius: 50%;
margin: auto;
right: 4px;
transition: all 0.2s ease;
pointer-events: none;
}
.email-card .toggle,
svg.toggle-down {
position: absolute;
top: 0;
bottom: 0;
}
svg.toggle-down path {
fill: var(--grey6);
}
.email-card.active .toggle-down {
transform: rotate(180deg);
transition: all 0.1s ease;
}
form.remove-email {
pointer-events: all;
padding: 0;
}
form.remove-email,
input.remove-email-submit {
border: none !important;
border-radius: 50% !important;
height: 1.5rem !important;
width: 1.5rem !important;
position: relative;
pointer-events: all;
cursor: pointer;
}
form.remove-email svg path {
fill: var(--alert-red);
transition: fill 0.15s ease-in-out;
}
input.remove-email-submit {
background-color: transparent !important;
}
@media screen and (max-width: 1100px) {
.e-address {
font-size: 16px;
}
.e-num-breaches {
font-size: 14px;
}
}
@media screen and (max-width: 800px) {
.e-info-content {
flex-direction: column;
width: 100%;
}
.email-card {
padding: 20px;
background-color: rgb(255 255 255 / 1);
border-radius: 8px;
border: 1px solid #e8e8e8;
}
.email-card.breaches-dash:last-of-type,
.email-card.breaches-dash {
margin: 8px 0;
padding: 20px;
}
.dash-attribution {
margin-top: 8px;
}
.e-info {
border-bottom: none;
padding-bottom: 0;
}
.breaches-dash:not(.zero-breaches) .e-address {
max-width: 85%;
}
.e-num-breaches {
margin-top: 4px;
color: var(--grey8);
}
.breach-card.resolved-breach-card {
display: flex;
visibility: visible;
}
.toggle-resolved-breaches,
.ec.breach-card,
.active .show-additional-breaches.hide .ec.breach-card,
.active .show-remaining-breaches.hide,
.e-address::after,
.show-remaining-breaches {
display: none;
}
.email-card.zero-unresolved-breaches .e-breach-list,
.email-card.zero-unresolved-breaches.active .e-breach-list,
.active .show-remaining-breaches {
display: flex;
}
.toggle.show-email-breaches {
pointer-events: all;
display: flex;
}
.ec.breach-card {
flex: 1 1 100%;
}
}
@media screen and (max-width: 600px) {
.e-address,
.radio-label {
font-size: 1rem;
}
.email-card.new-breaches {
margin-top: 0;
}
.e-num-breaches,
.resend-email,
.change-primary-email {
font-size: 0.85rem;
}
.button-resend-wrapper,
.e-num-breaches {
margin-top: 0.35rem;
}
.change-primary-email {
width: 100%;
margin: var(--padding) 0 0 0;
text-align: center;
}
.resend-email:hover {
background-color: transparent;
}
}

Просмотреть файл

@ -1,207 +0,0 @@
.feature-tip-group {
min-width: 96%;
flex-direction: row;
justify-content: space-between;
}
.gradient-container {
padding-left: var(--padding);
padding-right: var(--padding);
}
.gradient-inset {
width: 100%;
border-radius: 8px;
box-shadow: 0 0 12px 0 rgb(28 28 29 / 0.4);
padding: calc(var(--padding) * 2) 0;
background-color: rgb(255 255 255 / 0.95);
}
.feature-tip-content-wrapper {
max-width: 320px;
display: inline-flex;
}
.feature-tip-content {
flex-direction: column;
}
.feat-img {
height: 4rem;
width: 8rem;
margin: var(--padding) auto;
transition: all 0.2s ease;
background-position: center center;
background-repeat: no-repeat;
background-size: contain;
}
.facebook-notes .feature-tip-content-wrapper:hover .feat-img,
.breach-detail .feature-tip-content-wrapper:hover .feat-img {
transform: scale(1.1);
transition: all 0.2s ease-in-out;
}
.feat-img.advice {
background-image: url('../../img/svg/pictogram-advice.svg');
}
.feat-img.email {
background-image: url('../../img/svg/pictogram-email.svg');
}
.feat-img.alert {
background-image: url('../../img/svg/pictogram-alert.svg');
}
.feature-tip-headline {
margin: auto auto var(--padding) auto;
font-size: 32px;
line-height: 1.13;
color: #393473;
}
.feature-tip-link,
.feature-subhead {
line-height: 1.5;
margin-top: 0;
}
.feature-tip-link {
margin-right: 24px;
}
.feature-title {
color: var(--ink);
margin-bottom: 8px;
font-weight: 700;
font-size: 24px;
}
.feature-subhead {
font-size: 18px;
font-weight: 300;
color: #42425a;
}
.cta2 {
margin-right: 24px;
}
.facebook-notes .feature-tip-group,
.breach-detail .feature-tip-group {
margin-top: 40px;
flex-direction: column !important;
align-items: center !important;
}
.facebook-notes .feature-tip-content-wrapper,
.breach-detail .feature-tip-content-wrapper {
flex-direction: row;
align-items: flex-start;
margin: 0 auto 40px 0;
max-width: 100%;
}
.facebook-notes .feature-tip-content,
.breach-detail .feature-tip-content {
text-align: left;
margin-left: 28px;
max-width: 100%;
}
.facebook-notes .feat-img,
.breach-detail .feat-img {
margin: 0;
height: 56px;
max-width: 56px;
min-width: 56px;
}
.facebook-notes .feature-title,
.breach-detail .feature-title {
font-size: 18px;
}
.facebook-notes .feature-subhead,
.breach-detail .feature-subhead {
margin-bottom: 0.5rem;
font-size: 16px;
}
.gradient-container .feature-button .feature-tip-content {
margin: auto;
}
@media screen and (max-width: 1100px) {
.feature-tip-headline {
max-width: 600px;
word-break: break-word;
}
.feature-tip-group {
flex-direction: column;
}
.feature-tip-content-wrapper {
flex-direction: row;
align-items: flex-start;
max-width: 600px;
margin: var(--padding) auto;
}
.feature-tip-content {
text-align: left;
max-width: 350px;
margin-left: var(--padding);
}
.feat-img {
width: 7rem;
margin: 0 auto;
}
}
@media screen and (max-width: 800px) {
.feature-tip-content-wrapper {
margin-bottom: var(--padding);
flex-direction: column;
}
.feature-tip-group {
min-width: 0;
}
.feature-tip-content {
text-align: center;
margin: auto;
}
.feat-img {
margin: 1rem auto;
}
}
@media screen and (max-width: 600px) {
.facebook-notes .feature-tip-content,
.breach-detail .feature-tip-content {
margin: auto;
text-align: center;
}
.recommendation-cta {
display: block;
margin-top: 12px;
}
.facebook-notes .feature-tip-content-wrapper,
.breach-detail .feature-tip-content-wrapper {
margin: 0 auto 60px;
text-align: center;
flex-direction: column;
}
.overflow-recs .feature-tip-content-wrapper:last-of-type {
margin-bottom: 20px;
}
}

Просмотреть файл

@ -1,54 +0,0 @@
.how-fxm-headline {
margin-top: 0;
}
.how-fxm-works {
flex-direction: row;
}
.about-fxm {
color: white;
background: var(--ink) url('../../img/landing/background-noodle-right.svg') no-repeat top right / auto 100%;
}
.fx-monitor-svg {
background-image: url('../../img/svg/fx-monitor.svg');
background-repeat: no-repeat;
background-position: bottom left;
background-size: contain;
width: 3rem;
height: 6rem;
margin-bottom: var(--padding);
}
.fxm-card-wrap {
flex: 1;
padding: var(--padding);
margin: 0 auto var(--padding) auto;
}
.about-cta.btn-transparent.btn-small {
margin: 0 auto;
}
@media screen and (max-width: 1100px) {
.about-row {
justify-content: center;
}
.about-row .content-wrap {
flex: 1 1 66.6667%;
max-width: 66.6667%;
margin: auto;
}
.how-fxm-works {
flex-direction: column;
}
}
@media screen and (max-width: 800px) {
.about-row .content-wrap {
max-width: 100%;
}
}

Просмотреть файл

@ -1,53 +0,0 @@
footer {
width: 100%;
margin: auto auto 0;
background-color: var(--ink-dark);
padding-top: 32px;
padding-bottom: 32px;
}
.footer-link {
color: rgb(255 255 255 / 1);
font-size: 16px;
font-weight: 600;
}
.footer-link-wrapper {
margin-left: 60px;
}
.footer-link-wrapper.moz-link {
margin: auto auto auto 0;
}
svg.mozilla-logo {
min-width: 124px;
max-width: 124px;
}
@media screen and (max-width: 990px) {
.footer-link-wrapper {
margin-left: 20px;
}
}
@media screen and (max-width: 860px) {
footer ul.row-full-width {
flex-direction: column;
}
.footer-link-wrapper,
.footer-link {
width: 100%;
margin-left: 0;
}
.footer-link {
padding: 0.75rem 0;
}
.mozilla-logo-wrapper {
padding: 0;
margin-bottom: 0.5rem;
}
}

Просмотреть файл

@ -1,360 +0,0 @@
/* FORMS */
form,
.form-wrap {
width: 100%;
}
input:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px rgb(255 255 255 / 0) inset;
}
.form-group.loading-data .loader {
display: block;
opacity: 0.58;
width: 25px;
height: auto;
visibility: visible;
}
.form-desc {
max-width: 260px;
z-index: 1;
font-size: 12px;
line-height: 1.5;
margin: 12px auto;
}
input[type='submit'],
input[type='email'] {
height: 48px;
width: 100%;
border-style: solid;
border-width: 1px;
-webkit-appearance: none;
-moz-appearance: none;
border-radius: 4px;
}
.go-to-dash:focus,
.close-modal-btn:focus,
input[type='text']:focus,
input[type='radio']:focus ~ .checkmark,
input:focus,
.radio-button-group:focus {
border: 1px solid rgb(40 97 205);
box-shadow: 0 0 0 1px rgb(63 97 217), 0 0 0 4px rgb(63 97 217 / 0.3);
outline: none;
}
.email-scan {
flex-direction: column;
max-width: 320px;
margin: auto;
}
.input-group,
.input-group-button {
position: relative;
width: 100%;
max-width: 100%;
margin-left: 0;
margin-right: 0;
}
.form-group .error-message {
display: block;
background: rgb(215 0 34 / 1);
border-radius: 3px;
color: rgb(255 255 255 / 1);
padding: 5px 12px;
position: absolute;
bottom: -22px;
left: -1px;
z-index: 1;
font-size: 13px;
opacity: 0;
pointer-events: none;
}
.form-group .error-message::before {
opacity: 0;
background: rgb(215 0 34 / 1);
top: -7px;
content: '';
height: 16px;
position: absolute;
text-indent: -999px;
-ms-transform: rotate(45deg);
transform: rotate(45deg);
white-space: nowrap;
width: 16px;
z-index: -1;
}
.form-group.invalid .error-message,
.form-group.invalid .error-message::before {
opacity: 1;
}
.form-group.invalid input[type='email'] {
border: 1px solid var(--red50);
box-shadow: 0 0 0 1px rgb(255 0 55 / 0.514), 0 0 0 4px rgb(255 0 55 / 0.3);
}
.loader {
z-index: 1;
position: absolute;
visibility: hidden;
margin: auto;
left: 0;
top: 0;
bottom: 0;
right: 0;
opacity: 1;
pointer-events: none;
}
input::placeholder {
color: #737373;
}
.form-group.loading-data .button,
.form-group.loading-data input::placeholder,
.form-group.loading-data input {
color: rgb(0 0 0 / 0) !important;
}
input[type='email'] {
font-size: 16px;
text-indent: var(--padding);
background-color: rgb(255 255 255 / 1);
color: var(--ink);
border: 1px solid var(--grey90a3);
margin-bottom: 12px;
transition: all 0.1s ease-out;
}
input[type='submit'] {
font-size: 16px;
border-width: 2px;
background-color: var(--blue3);
color: rgb(255 255 255 / 1);
font-weight: 600;
border-color: var(--blue3);
letter-spacing: 0.01em;
cursor: pointer;
transition: all 0.15s ease;
}
input[type='submit']:hover {
background-color: var(--blue4);
border-color: var(--blue4);
transition: all 0.15s ease;
}
input[type='submit']:active {
background-color: var(--blue5);
border-color: var(--blue5);
transition: all 0.15s ease;
}
form.invalid input[type='submit']:focus {
border: 2px solid var(--red50);
box-shadow: 0 0 0 1px rgb(255 0 55 / 0.514), 0 0 0 4px rgb(255 0 55 / 0.3);
}
input.email-add {
color: var(--ink);
border-color: var(--grey90a3);
background-color: rgb(255 255 255 / 1);
}
input.unsub {
padding: 0;
cursor: pointer;
}
/* radio buttons */
input[type='radio'] {
opacity: 0;
}
.checkmark {
background-color: rgb(255 255 255 / 1);
height: 24px;
width: 24px;
border-radius: 100%;
display: flex;
justify-content: center;
align-items: center;
position: absolute;
margin: auto;
border: 1px solid var(--grey90a3);
transition: background-color 0.15s ease-in-out;
}
.radio-container:hover .checkmark::after {
opacity: 1;
background-color: rgb(12 12 13 / 0.04);
transition: background-color 0.15s ease-in-out;
}
input[type='radio'] ~ .checkmark::after {
position: absolute;
content: '';
display: block;
height: 12px;
width: 12px;
opacity: 0;
flex: 1;
border-radius: 100%;
background-color: rgb(245 245 245 / 0);
pointer-events: none;
transition: all 0.2s ease;
}
input[type='radio']:checked ~ .checkmark::after {
opacity: 1;
background-color: var(--violet3);
transition: all 0.4s ease;
}
input[type='radio']:checked:hover ~ .checkmark::after {
opacity: 1;
background-color: var(--violet3);
transition: all 0.2s ease;
}
input[type='radio']:focus ~ .checkmark {
border: 1px solid var(--grey90a3);
box-shadow: 0 0 0 1px rgb(12 12 13 / 0), 0 0 0 3px rgb(12 12 13 / 0.05);
}
.add-new-email-form {
display: flex;
}
.add-new-email-form .input-group {
width: 100%;
margin-right: 1rem;
}
.add-new-email-form .input-group-button {
width: auto;
max-width: auto;
}
.add-new-email-form input::placeholder {
color: #737373;
}
.dashboard-add-email .add-new-email-form {
flex-direction: column;
max-width: 320px;
margin: auto;
}
.radio-container input[type='radio'] {
width: 0;
margin: 0;
}
.radio-container {
margin-bottom: 24px;
display: flex;
}
.radio-container:last-of-type {
margin-bottom: 0;
}
.radio-label {
padding-left: 32px;
font-weight: 400;
color: var(--grey8);
font-size: 16px;
line-height: 1.5;
padding-top: 2px;
}
.create-fxa-checkbox {
display: none;
}
.create-fxa-wrapper {
display: flex;
align-items: center;
width: 100%;
margin: 4px auto 16px;
}
.create-fxa-wrapper p {
text-align: left;
color: var(--grey3);
margin: 0;
padding: 0 0 0 12px;
font-size: 14px;
line-height: 18px;
}
.create-fxa-checkbox-wrapper {
position: relative;
}
.create-fxa-checkbox-input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.create-fxa-checkbox-checkmark:hover {
background: #cfcfd8;
}
.create-fxa-checkbox-checkmark {
position: relative;
height: 24px;
width: 24px;
background-color: rgb(255 255 255 / 1);
border-radius: 4px;
cursor: pointer;
display: block;
}
.create-fxa-checkbox-checkmark::after {
content: '';
width: 100%;
height: 100%;
display: none;
left: 0;
top: 0;
border-radius: 4px;
background-size: 100% auto;
background-repeat: no-repeat;
background-image: url('../../img/svg/purple-check.svg');
}
.create-fxa-checkbox-input:checked ~ .create-fxa-checkbox-checkmark::after {
display: block;
}
@media screen and (max-width: 800px) {
button.internal-link,
input.transparent-button,
input[type='submit'],
input::placeholder {
font-size: 15px;
}
.radio-label {
font-size: 0.9rem;
}
.add-new-email-form {
flex-direction: column;
}
}

Просмотреть файл

@ -1,388 +0,0 @@
firefox-apps {
--background-color: rgb(249 249 250);
--bento-hover-bg-color: rgb(97 90 115);
--bento-click-bg-color: rgb(142 137 154);
--bento-button-height: 25px;
--z-index: 10000;
--app-icon-height: 16px;
--text-color: rgb(32 18 58);
--bento-padding: 24px;
display: flex;
margin: 0 32px;
position: relative;
text-align: center;
z-index: var(--z-index);
font-size: 12px;
width: var(--bento-button-height);
min-height: var(--bento-button-height);
color: var(--text-color);
background: url('../../img/fx-bento-sprites.png');
background-position-x: -215.5px;
background-position-x: -224px;
background-size: auto var(--bento-button-height);
border-radius: 2px;
box-shadow: 0 0 0 4px rgb(255 255 255 / 0);
}
firefox-apps.fx-bento-open,
firefox-apps:hover {
box-shadow: 0 0 0 4px var(--bento-hover-bg-color);
background-color: var(--bento-hover-bg-color);
}
firefox-apps:focus,
firefox-apps:focus-within {
box-shadow: 0 0 0 4px var(--bento-hover-bg-color);
background-color: var(--bento-hover-bg-color);
}
firefox-apps:active {
box-shadow: 0 0 0 4px var(--bento-click-bg-color);
background-color: var(--bento-click-bg-color);
}
.fx-bento-hide-vpn .moz-vpn {
display: none;
}
.fx-bento-hide-overflow {
position: relative;
overflow: hidden;
border-radius: 8px;
}
.fx-bento-content-wrapper {
position: absolute;
top: calc(var(--bento-button-height) + 10px);
min-width: 260px;
z-index: calc(var(--z-index) + 1);
box-shadow: 0 7px 12px -3px rgb(28 28 29 / 0.502);
right: -18px;
background: var(--background-color);
border-radius: 8px;
transform: translateY(0);
display: none;
border: 1px solid transparent;
}
.fx-bento-content {
display: flex;
flex-direction: column;
padding: var(--bento-padding) 0 0 0;
}
.fx-bento-enable-scrolling {
overflow-y: scroll;
}
.active .fx-bento-content-wrapper {
display: block;
height: auto;
transform: translateY(12px);
animation: fxBentoAppear ease 0.3s;
-webkit-animation: fxbentoappear ease 0.3s;
-moz-animation: fxbentoappear ease 0.3s;
}
.active .fx-bento-content-wrapper::after {
display: block;
content: '';
height: 12px;
width: 12px;
position: absolute;
top: -7px;
right: 23px;
margin: auto;
transform: rotate(45deg);
border-top-left-radius: 1px;
background-color: rgb(249 249 250);
border-top: 1px solid transparent;
border-left: 1px solid transparent;
z-index: -1;
}
.fx-bento-logo {
background: url('../../img/fx-bento-sprites.png');
background-repeat: no-repeat;
background-size: cover;
height: 40px;
width: 40px;
margin: auto auto 8px;
background-position-x: -90px;
}
.fx-bento-headline {
font-size: 16px;
font-family: Metropolis, sans-serif;
line-height: 20px;
font-weight: 600;
margin-right: auto;
margin-left: auto;
max-width: 270px;
display: block;
padding-left: 20px;
padding-right: 20px;
}
.fx-bento-bottom-link {
color: rgb(0 96 223);
text-decoration: underline;
padding: 10px var(--bento-padding);
margin: 14px auto;
width: 100%;
}
a.fx-bento-app-link {
background-color: rgb(255 255 255 / 0);
text-align: left;
}
.fx-bento-app-link-span {
display: block;
padding: 10px var(--app-icon-height) 10px calc(var(--app-icon-height) + 32px);
color: var(--text-color);
position: relative;
margin-right: auto;
margin-left: auto;
max-width: 410px;
pointer-events: none;
}
.fx-bento-app-link-span::before {
display: block;
position: absolute;
left: var(--bento-padding);
top: 0;
bottom: 0;
margin: auto;
height: var(--app-icon-height);
min-height: var(--app-icon-height);
width: var(--app-icon-height);
background: url('../../img/fx-bento-sprites.png');
background-size: auto var(--app-icon-height);
content: '';
}
.fx-bento-app-link-span.pocket::before {
background-position-x: -18px;
}
.fx-bento-app-link-span.fx-monitor::before {
background-position-x: -88px;
}
.fx-bento-app-link-span.fx-mobile::before {
background-position-x: -104px;
}
.fx-bento-app-link-span.moz-vpn::before {
background: url('../../img/svg/logos/vpn-logo.svg') !important;
background-position: center !important;
background-size: var(--app-icon-height) !important;
background-repeat: no-repeat !important;
}
.fx-bento-link:hover,
.fx-bento-link:focus {
background-color: rgb(230 230 230);
opacity: 1 !important;
}
.fx-bento-button {
width: 100%;
border: 1px solid transparent;
pointer-events: all;
}
.fx-bento-button:hover,
.fx-bento-button:focus,
.fx-bento-button:active {
outline: 0;
box-shadow: none;
border: 1px solid transparent;
}
.fx-bento-button:hover::-moz-focus-inner,
.fx-bento-button:focus::-moz-focus-inner {
border: 0;
outline: 0;
}
button.fx-bento-mobile-close {
display: none;
position: absolute;
right: var(--app-icon-height);
top: var(--app-icon-height);
height: var(--bento-padding);
min-height: var(--bento-padding);
width: var(--bento-padding);
max-width: var(--bento-padding);
min-width: var(--bento-padding);
background-color: rgb(55 54 111);
background-image: url('../../img/fx-bento-sprites.png');
background-size: auto 20px;
background-repeat: no-repeat;
background-position-x: -150.5px;
padding: 0 !important;
border: none !important;
border-radius: 50%;
background-position-y: 2px;
}
.fx-bento-app-link:first-of-type {
margin-top: var(--app-icon-height);
}
.fx-bento-fade-out {
opacity: 0;
transform: translateY(0) !important;
pointer-events: none !important;
}
a.fx-bento-link,
a.fx-bento-link:hover,
a.fx-bento-link:focus,
.fx-bento-button,
.fx-bento-button:hover,
.fx-bento-button:focus,
.fx-bento-fade-out,
firefox-apps,
firefox-apps:hover,
firefox-apps:focus-within {
transition: 0.2s ease-in-out;
}
body.hide-bento firefox-apps {
background: none !important;
visibility: hidden !important;
width: 0;
}
body.hide-bento firefox-apps > button {
visibility: hidden !important;
}
@media screen and (max-width: 900px) {
firefox-apps {
margin: 0 var(--bento-padding);
}
.fx-bento-button {
transform: scale(0.9);
}
}
@media screen and (max-width: 500px) {
firefox-apps {
--app-icon-height: var(--bento-padding);
--mobile-clickable-close-area: 12vh;
position: inherit;
margin: 0 var(--app-icon-height);
}
button.fx-bento-mobile-close {
display: block;
}
.fx-bento-hide-overflow {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
max-height: 100vh;
overflow-y: scroll;
overflow-x: scroll;
}
.fx-bento-mobile-close::after {
content: '';
display: block;
position: fixed;
right: 0;
top: 0;
left: 0;
height: var(--mobile-clickable-close-area);
}
.fx-bento-content-wrapper {
top: 0;
right: 0;
left: 0;
width: 100%;
min-height: 100vh;
border-radius: 0;
transform: translateY(0) !important;
}
.fx-bento-logo {
height: 48px;
width: 48px;
min-height: 48px;
margin-top: var(--mobile-clickable-close-area);
background-position-x: -108px;
}
.fx-bento-headline {
font-size: 20px;
line-height: var(--bento-padding);
padding-left: 0;
padding-right: 0;
}
.fx-bento-bottom-link {
margin-bottom: auto;
margin-top: var(--bento-padding);
}
.fx-bento-bottom-link,
.fx-bento-app-link {
font-size: 16px;
}
.active .fx-bento-content-wrapper {
transform: translateY(0) !important;
animation: none !important;
-webkit-animation: none !important;
}
.active .fx-bento-content-wrapper::after {
display: none;
}
a.fx-bento-app-link {
line-height: 23px;
}
.fx-bento-app-link-span {
max-width: 342px;
padding-left: calc(var(--app-icon-height) + 40px);
}
.fx-bento-app-link-span.pocket::before {
background-position-x: -26px;
}
.fx-bento-app-link-span.fx-monitor::before {
background-position-x: -132px;
}
.fx-bento-app-link-span.fx-mobile::before {
background-position-x: -157px;
margin-left: 1px;
}
}
@keyframes fxbentoappear {
0% {
opacity: 0;
transform: translateY(0);
}
100% {
opacity: 1;
transform: translateY(12px);
}
}

Просмотреть файл

@ -1,532 +0,0 @@
header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 2;
}
.desktop-menu:hover .active-link::after {
opacity: 0;
}
.active-link:hover::after {
opacity: 1;
}
.recruitment-banner,
.csat-banner {
background-color: var(--violet1);
padding: 12px 48px;
text-align: center;
}
.recruitment-banner a {
color: inherit;
}
.csat-banner p {
margin: 0;
font-size: 14px;
}
.csat-banner label {
margin: 6px 3px 0;
color: #56198f;
border-color: #7639af;
cursor: pointer;
transition: opacity 0.5s ease-out;
}
.csat-banner[disabled] label,
.csat-banner[hidden] label {
pointer-events: none;
cursor: default;
opacity: 0.3;
}
.csat-banner label.selected {
opacity: 1;
background-color: #d89eff;
}
.csat-banner label input {
display: none;
}
.csat-banner [name='csat-close-btn'] {
position: absolute;
top: 12px;
right: 12px;
border: 0;
cursor: pointer;
}
.csat-banner button img {
pointer-events: none;
width: 24px;
}
.nps-bookend {
display: inline-block;
margin-right: 1rem;
}
#navigation-wrapper {
background-color: var(--ink-dark);
box-shadow: 0 0 5px 0 #20123a1c;
transition: box-shadow 0.2s ease;
}
.show-shadow #navigation-wrapper {
box-shadow: 0 5px 10px -5px black;
}
.show-nav-bars {
transform: translateY(0);
-webkit-transform: translateY(0);
opacity: 1;
visibility: visible;
pointer-events: all;
transition: all 0.3s ease;
}
.hide-nav-bars {
transform: translateY(-20px);
-webkit-transform: translateY(-20px);
overflow: hidden;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: all 0.3s ease;
}
.sign-in.btn-white {
white-space: nowrap;
}
.fxm-branding {
position: relative;
}
.fx-monitor-logo-wrapper,
.bento-sign-up {
flex: 1 1 30%;
}
/* Fx-Monitor logo */
.sprite.fx-monitor-logo {
background: url('../../img/fx-bento-sprites.png') -220px 0;
min-width: 40px;
height: 40px;
}
.fx-monitor-logotype {
background: url('../../img/svg/fx-monitor-logotype.svg');
width: 100%;
max-width: 212px;
margin-left: 8px;
background-position: left;
background-size: contain;
background-repeat: no-repeat;
height: 28px;
}
/* navigation */
nav {
flex: 1;
justify-content: flex-end;
}
.nav-link {
background-color: rgb(255 255 255 / 0);
padding-right: calc(16px + 1vw);
padding-left: calc(16px + 1vw);
justify-content: center;
color: rgb(255 255 255 / 1);
font-size: 16px;
}
.nav-link span {
position: relative;
pointer-events: none;
}
.active-link {
font-weight: 700;
}
.nav-link span::after {
height: 2px;
background: var(--monitor-gradient);
opacity: 0;
display: block;
content: '';
position: absolute;
bottom: -8px;
left: 0;
right: 0;
width: 0;
margin: auto;
border-radius: 5px;
transition: all 0.2s ease;
}
.active-link span::after,
.active-link-underline span::after {
width: 100%;
opacity: 1 !important;
transition: all 0.1s ease;
}
.drop-down-menu,
.mobile-menu .active-link-underline::after {
display: none;
}
.signed-in-as-wrap {
display: flex;
position: relative;
margin-bottom: 8px;
}
.signed-in-as-wrap::after {
content: '';
height: 1.5px;
background: var(--monitor-gradient);
width: 100%;
display: block;
position: absolute;
bottom: 0;
}
.signed-in-as {
padding: 20px;
color: rgb(32 18 58);
font-size: 12px;
line-height: 1;
}
.nav-user-email {
display: block;
color: rgb(32 18 58);
font-weight: 600;
padding-top: 8px;
font-size: 14px;
}
/* signed-in user avatar */
.avatar-wrapper {
border-radius: 50%;
display: inline-flex;
justify-content: center;
align-items: center;
position: relative;
overflow: hidden;
box-shadow: 0 0 0 1px #fff0, 0 0 0 3px #4f42ff00;
border: 2px solid rgb(255 255 255 / 1);
pointer-events: all;
}
.avatar-wrapper:hover {
box-shadow: 0 0 0 2px rgb(63 96 217 / 0.8), 0 0 0 6px rgb(63 97 217 / 0.2);
transition: all 0.2s ease;
}
.avatar-wrapper:focus,
.avatar-wrapper:focus-within {
border: 2px solid rgb(255 255 255 / 1);
box-shadow: 0 0 0 2px rgb(63 97 217), 0 0 0 4px rgb(63 97 217 / 0.3);
outline: none;
}
.fxa-menu-wrapper {
cursor: pointer;
position: relative;
user-select: none;
}
img.avatar,
.fxa-menu-wrapper,
.fxa-menu {
pointer-events: none !important;
}
.avatar-wrapper,
.avatar {
height: 42px;
width: 42px;
}
/* signed-in user menu */
.fxa-menu {
padding-top: 3.25rem;
position: absolute;
top: 0;
right: -4px;
z-index: 2;
pointer-events: none !important;
transition: all 0.15s ease-in-out;
}
.fxa-menu-links {
background-color: rgb(249 249 250);
box-shadow: 0 7px 12px -3px rgb(28 28 29 / 0.502);
display: none;
flex-direction: column;
padding-bottom: 8px;
visibility: hidden;
border-radius: 8px;
transform: translateY(0);
transition: all 0.15s ease;
}
.fxa-menu-links::before {
border-top-left-radius: 1px;
content: '';
display: block;
position: absolute;
top: -5px;
right: 20px;
height: 12px;
width: 12px;
background-color: rgb(249 249 250);
opacity: 1;
margin: auto;
z-index: 2;
transform: rotate(45deg);
visibility: hidden;
}
.menu-open .fxa-menu-links::before {
visibility: visible;
}
.menu-open .fxa-menu-links,
.avatar-wrapper:hover .fxa-menu-links {
display: flex;
visibility: visible;
transform: translateY(8px);
animation: fxaMenuAppear ease 0.3s;
-webkit-animation: fxamenuappear ease 0.3s;
-moz-animation: fxamenuappear ease 0.3s;
transition: all 0.25s ease;
}
.menu-open .fxa-menu-links {
pointer-events: all;
}
.fxa-menu-link {
min-width: 280px;
padding: 10px 20px;
color: rgb(32 18 58);
font-size: 12px;
background-color: rgb(255 255 255 / 0);
transition: all 0.15s ease;
}
.fxa-menu-link:focus,
.fxa-menu-link:active {
background-color: rgb(238 238 238 / 0.502);
box-shadow: none;
}
.fxa-menu-link:hover {
background-color: rgb(155 155 165 / 0.15);
transition: all 0.15s ease;
}
@media screen and (max-width: 1200px) {
.bento-sign-up {
flex: 0 1 auto;
}
.desktop-menu {
justify-content: flex-end;
}
.nav-link {
padding-right: 0;
}
}
@media screen and (max-width: 800px) {
.bento-sign-up {
margin-left: 0;
}
.desktop-menu {
flex: 1 1 auto;
width: auto;
}
.fx-monitor-logotype {
height: 25px;
}
.desktop-menu .nav-link,
.active-link span::after,
.active-link-underline span::after {
display: none;
}
.mobile-nav {
visibility: visible;
background-color: var(--ink);
box-shadow: 0 2px 2px -1px #0202024d;
transition: all 0.2s ease;
padding: 0;
flex-flow: column wrap;
position: relative;
cursor: pointer;
}
.mobile-menu a.nav-link {
color: rgb(255 255 255 / 0.9);
padding: 12px 16px;
border-radius: 4px;
}
.mobile-menu .nav-link:hover {
background-color: var(--ink);
}
.mobile-menu {
margin: auto;
border-radius: 0;
flex-flow: column wrap;
visibility: hidden;
padding: 0 1.75rem;
max-height: 0;
position: absolute;
top: 0;
left: 0;
right: 0;
background-color: var(--ink-light);
z-index: 0;
color: rgb(255 255 255 / 0);
-webkit-user-select: none;
transition: all 0.25s ease;
}
.mobile-menu-open .mobile-menu {
top: 3rem;
max-height: 1000px;
visibility: visible;
color: rgb(255 255 255 / 1);
padding-top: 1rem;
padding-bottom: 1rem;
transition: all 0.15s ease;
}
.nav-link.drop-down-menu,
.mobile-menu-open .nav-link {
display: block;
}
.nav-link.drop-down-menu {
-webkit-user-select: none;
pointer-events: none;
padding: 1rem 2.75rem;
background-color: var(--ink-light);
width: 100%;
z-index: 1;
border-bottom: 1px solid var(--ink-light);
transition: border-bottom 0.15s ease;
}
.mobile-menu-open .drop-down-menu {
border-bottom: 1px solid var(--ink);
transition: border-bottom 0.15s ease;
}
.mobile-menu-open svg.toggle-down {
transform: rotate(180deg);
transition: transform 0.15s ease;
}
.drop-down-menu svg.toggle-down {
opacity: 0.9;
right: 2.75rem;
}
.drop-down-menu .toggle-down path {
fill: rgb(255 255 255 / 1);
}
.nav-link {
padding-left: 2.75rem;
padding-right: 2.75rem;
text-align: left;
width: 100%;
}
}
@media screen and (max-width: 600px) {
header,
header.show-shadow {
background-color: transparent;
box-shadow: none;
}
#navigation-wrapper {
background-color: var(--ink-dark);
}
.drop-down-menu svg.toggle-down {
right: 2rem;
}
.hide-nav-bars .mobile-nav {
visibility: hidden;
}
.show-nav-bars .mobile-nav {
visibility: visible;
}
.fxa-menu-link,
.nav-user-email {
font-size: 16px;
}
.fx-monitor-logo-wrapper {
flex: 1 1 100%;
}
.mobile-menu {
padding: 1rem 0.75rem;
}
.nav-link.drop-down-menu,
.mobile-menu-open .nav-link {
display: block;
}
.nav-link.drop-down-menu {
padding: 16px 25px;
}
.fxa-menu-link {
min-width: auto;
}
}
@media screen and (max-width: 450px) {
.fx-monitor-logotype {
display: none;
}
}
@keyframes fxamenuappear {
0% {
opacity: 0;
transform: translateY(0);
}
100% {
opacity: 1;
transform: translateY(8px);
}
}

Просмотреть файл

@ -1,83 +0,0 @@
.latest-breach-info,
.latest-breach {
margin: auto;
}
.latest-breach-info {
margin-top: -32px;
}
.breach-card.latest-breach {
box-shadow: 0 12px 12px -3px #1c1c1d80;
border-top-left-radius: 0;
border-top-right-radius: 0;
border: 0;
padding: 20px 40px 24px;
}
.breach-card.latest-breach:hover {
transform: scale(1); /* prevent lower half of card from scaling up */
}
.latest-breach .breach-logo-wrapper {
max-width: 48px;
margin: 0 20px 0 0;
}
.latest-breach .breach-logo {
max-height: 48px;
max-width: 48px;
width: 48px;
}
.latest-breach .breach-title {
margin-bottom: 8px;
}
.lb-info .breach-key,
.lb-info .breach-value {
display: inline !important;
font-size: 14px;
}
.lb-info {
margin-bottom: 8px;
}
.latest-breach-headline,
.latest-breach {
max-width: 500px;
}
.latest-breach-headline {
color: var(--purple6);
background-color: rgb(255 255 255 / 0.95);
border-top-right-radius: 8px;
border-top-left-radius: 8px;
font-weight: 600;
margin: 0 auto;
padding: 16px 16px 12px;
box-shadow: 0 7px 12px 0 #1c1c1d80;
font-size: 14px;
text-align: center;
}
@media screen and (max-width: 600px) {
.latest-breach-info {
padding: 0 calc(var(--padding) + 0.75rem);
}
.breach-card.latest-breach {
padding: 16px 20px;
}
.latest-breach .breach-logo-wrapper {
min-width: 36px;
}
.latest-breach .breach-logo {
max-width: 36px;
max-height: 36px;
width: 36px;
}
}

Просмотреть файл

@ -1,710 +0,0 @@
* {
box-sizing: border-box;
}
.show-mobile {
display: none;
visibility: hidden;
}
body {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
background-color: #f9f7fd;
scroll-behavior: smooth;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Open Sans', 'Helvetica Neue', sans-serif;
font-weight: 400;
font-size: 15px;
overflow-y: scroll;
overflow-x: hidden;
min-height: 100vh;
width: 100vw;
-webkit-overflow-scrolling: touch;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.fxa-signup-gradient {
background: var(--fxa-sign-up-gradient);
}
.take-back-control-gradient {
background: var(--take-back-control-gradient);
}
.fx-monitor {
color: var(--grey1);
}
p,
.paragraph {
font-size: var(--paragraph-font-size);
}
.medium {
font-weight: 500;
}
.demi {
font-weight: 600;
}
.bold {
font-weight: 700;
}
.overflow-break {
max-width: 100%;
overflow-wrap: break-word;
}
.desc {
max-width: 320px;
}
.ff-met {
font-family: Metropolis, sans-serif;
}
h1,
h2 {
font-family: Metropolis, sans-serif;
}
h3,
.nav-link,
button,
input[type='submit'] {
font-family: Metropolis, sans-serif;
font-weight: 600;
}
h1,
h2,
h3,
.section-headline {
margin-top: 0;
}
.section-headline {
font-size: 24px;
font-weight: 700;
}
.small-section-headline {
font-size: 20px;
margin-bottom: 16px;
}
.top-headline {
font-size: var(--top-headline);
font-weight: 700;
line-height: 1.14;
}
.mw-360 {
max-width: 360px;
}
.mw-500 {
max-width: 500px;
}
.mw-550 {
max-width: 550px;
}
.mw-860 {
width: 100%;
max-width: 860px;
padding-left: 36px;
padding-right: 36px;
margin: auto;
}
.headline {
font-size: var(--headline);
margin: 0 auto 0.55rem 0;
}
.subhead {
font-weight: 400;
line-height: 1.5;
max-width: 600px;
padding-right: 5%;
font-size: 16px;
margin: 0 auto var(--padding) 0;
}
a {
text-decoration: none;
}
button.text-link {
border: 0;
padding: 0;
text-align: left;
}
.blue-link {
color: var(--blue3);
font-family: Metropolis, sans-serif;
font-weight: 600;
}
.text-link:hover,
.blue-link:hover {
text-decoration: underline;
color: var(--blue4);
}
.blue-link:active {
color: var(--blue5);
}
div:focus,
input:focus {
border: 2px solid #4f42ff14;
box-shadow: 0 0 0 1px #4f42ff6e, 0 0 0 4px #4f42ff1f;
outline: none;
}
button:focus,
a:focus {
outline: none;
}
button {
cursor: pointer;
justify-content: center;
text-align: center;
background: transparent;
border-style: solid;
}
.btn-white {
border-color: rgb(255 255 255 / 1);
color: rgb(255 255 255 / 1);
}
.btn-white:hover,
.btn-white:focus {
color: #cfcfd8;
border-color: #cfcfd8;
}
.btn-white:active {
color: #9f9fad;
border-color: #9f9fad;
}
.btn-red {
border-color: var(--alert-red);
color: var(--alert-red);
}
.btn-red:hover {
border-color: #cf434f;
color: #cf434f;
}
.btn-violet-secondary {
color: var(--violet4);
border-color: var(--violet4);
}
.btn-violet-secondary:hover,
.btn-violet-secondary:focus {
color: var(--violet5);
border-color: var(--violet5);
}
.btn-violet-secondary:active {
color: var(--violet6);
border-color: var(--violet6);
}
.btn-blue-primary:active,
.btn-transparent:active,
.btn-violet-primary:active,
input[type='submit']:active {
box-shadow:
0 2px 8px 0 rgb(14 13 26 / 0.12),
0 3px 1px -2px rgb(7 48 114 / 0.12),
0 2px 4px 0 rgb(34 0 51 / 0.04),
0 0 0 1px rgb(32 18 58 / 0.04);
}
button.btn-ghost {
border: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
min-height: 48px;
align-self: center;
color: var(--purple7);
min-width: 200px;
}
.btn-blue-primary,
button.btn-violet-primary {
border-radius: 8px;
min-height: 48px;
font-size: 16px;
max-width: 320px;
font-weight: 600;
width: 100%;
color: rgb(255 255 255 / 1);
padding-right: var(--padding);
padding-left: var(--padding);
}
.btn-blue-primary {
border-radius: 4px;
background-color: #0060df;
border: 1px solid #0060df;
align-self: center;
}
.btn-blue-primary:hover {
background-color: var(--blue4);
border-color: var(--blue4);
}
.btn-violet-primary {
background-color: var(--violet4);
border: 1px solid var(--violet4);
}
.btn-violet-primary:hover {
background-color: var(--violet5);
border-color: var(--violet5);
}
.btn-violet-primary:active {
background-color: var(--violet6);
border-color: var(--violet6);
}
.btn-transparent {
display: inline-flex;
align-content: center;
border-style: solid;
border-width: 2px;
font-family: Metropolis, sans-serif;
font-weight: 600;
align-items: center;
}
a.btn-transparent:hover {
text-decoration: none;
}
.btn-small {
font-size: 14px;
border-radius: 4px;
min-height: 32px;
padding: 4px 12px;
}
.btn-big {
font-size: 16px;
border-radius: 8px;
min-height: 40px;
padding: 8px 16px;
}
a,
button,
a:hover,
button:hover,
a:focus,
button:focus,
a:active,
button:active {
transition: all 0.15s ease-in-out;
}
ul {
padding: 0;
display: flex;
flex-direction: column;
}
ul li {
display: inline-flex;
align-items: center;
}
.container {
justify-content: center;
flex-direction: column;
padding-top: calc(var(--padding) * 2);
padding-bottom: calc(var(--padding) * 2);
position: relative;
}
.row {
justify-content: space-between;
padding: 0.75rem;
max-width: var(--max-width);
}
.row-full-width {
align-items: center;
flex-direction: row;
justify-content: space-between;
padding: 20px 36px;
}
.row,
.row-full-width {
margin: auto;
}
.container,
.row-full-width,
.row {
width: 100%;
}
.container,
.row-full-width,
.row,
.col-6,
.col-12,
.flx,
.flx-cntr,
.flx-row {
display: flex;
}
.cntr {
align-content: center;
align-items: center;
}
.flx-cntr {
align-items: center;
}
.flx-col {
flex-direction: column;
}
.flx-row {
flex-direction: row;
}
.flx-end {
justify-content: flex-end;
}
.flx-auto {
flex: 1 1 auto;
}
.space-between {
justify-content: space-between;
}
.margin-zero {
margin: 0;
}
.col-6 {
flex: 1 1 50%;
max-width: 50%;
min-width: 300px;
}
.col-8 {
flex: 1 1 66.6667%;
min-width: 66.6667%;
max-width: 66.6667%;
}
.mw-700 {
padding: 0 24px;
max-width: 744px;
width: 100%;
overflow-wrap: break-word;
}
.col-9 {
flex: 1 1 75%;
min-width: 75%;
max-width: 75%;
}
.col-12 {
flex: 1 1 calc(100% - var(--padding));
width: 100%;
flex-direction: column;
padding: var(--padding);
margin-right: auto;
margin-left: auto;
}
.col-6,
.col-8,
.col-9 {
padding: var(--padding);
flex-direction: column;
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.padding-top-zero {
padding-top: 0;
}
.no-vertical-padding {
padding-top: 0;
padding-bottom: 0;
}
.col {
flex-direction: column;
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.txt-light {
color: var(--grey8);
line-height: 1.5;
}
.txt-cntr {
text-align: center;
}
.txt-purple7 {
color: var(--purple7);
}
.jst-cntr {
justify-content: center;
align-items: center;
}
.bg-white {
background-color: rgb(255 255 255 / 1);
}
.sr {
background-image: url('../../img/svg/scan-res-bg.svg');
background-position: top center;
background-attachment: fixed;
background-size: cover;
}
.bg-light {
background-color: #f9f7fd;
}
.bg-dark {
background-color: var(--ink-dark);
color: rgb(255 255 255 / 1);
}
.drop-shadow {
box-shadow: 0 0 1px 1px #607d8b08, 1px 1px 10px #4341571a;
transition: all 0.15s ease-in-out;
}
.drop-shadow:hover {
box-shadow: 0 0 1px 1px #607d8b12, 1px 1px 12px #4341574d;
transition: all 0.15s ease-in-out;
}
.source-info {
display: block;
max-width: 320px;
margin-left: auto;
margin-right: auto;
}
.source-info a,
.source-info {
color: var(--text-light);
font-size: 14px;
}
.source-info a {
font-weight: 600;
}
.source-info a:hover {
color: var(--grey8);
}
.source-info a:active {
color: var(--grey9);
}
/* SPRITES */
div.sprite {
background-repeat: no-repeat !important;
background-size: cover !important;
}
.title-wrapper {
display: inline-block;
}
.logo-wrapper {
width: 1.5rem;
min-height: 10px;
margin-right: 0.5rem;
display: inline-flex;
}
.arrow-head-right {
margin-left: 4px;
vertical-align: text-bottom;
}
.show {
visibility: visible;
opacity: 1;
transition: opacity 0.1s ease, visibility 0.1s ease 0.2s, max-height 0.1s ease 0.2s, padding 0.1s ease 0.2s;
}
.hide {
padding: 0;
opacity: 0;
max-height: 0;
visibility: hidden;
transition: opacity 0.1s ease, visibility 0.1s ease 0.2s, max-height 0.1s ease 0.2s, padding 0.1s ease 0.2s;
}
.hide > * {
padding: 0;
max-height: 0;
visibility: hidden;
margin: 0 auto !important;
}
.clear-header {
padding-top: calc(var(--header-height) + 80px);
}
.toggle-parent.active .toggle svg {
transform: rotate(180deg);
}
.toggle-parent.inactive .toggle-child {
display: none;
}
.nav-link:hover,
.footer-link:hover {
color: #cfcfd8;
}
.nav-link:active,
.footer-link:active {
color: #9f9fad;
}
@media screen and (max-width: 1200px) {
body {
--max-width: 1000px;
}
}
@media screen and (max-width: 800px) {
body {
--max-width: 600px;
--top-headline: 43px;
--headline: 36px;
}
.hide-mobile {
display: none !important;
visibility: hidden;
}
.show-mobile {
display: flex;
visibility: visible;
}
.mw-700 {
max-width: var(--max-width);
}
.col-6 {
max-width: 100%;
padding: 0 var(--padding);
}
.col-8,
.col-9 {
max-width: 100%;
width: 100%;
margin: auto;
}
.mw-860 {
max-width: 600px;
}
.row {
flex-direction: column;
}
.row-9 {
max-width: 75%;
width: 75%;
display: flex;
padding: 0 var(--padding);
}
}
@media screen and (max-width: 600px) {
.col-6,
.col-12 {
margin: auto;
width: 100%;
}
.row-full-width {
padding: 20px 24px;
}
.mw-860 {
padding-left: 24px;
padding-right: 24px;
}
.arrow-head-right {
margin-left: 0.15rem;
}
}
@media screen and (max-width: 500px) {
body {
--top-headline: 36px;
--headline: 32px;
}
.col-6 {
min-width: auto;
}
body.bento-open {
position: fixed;
}
.headline {
margin-bottom: 16px;
}
.subhead {
padding-right: 3%;
}
}

Просмотреть файл

@ -1,361 +0,0 @@
.monitor-homepage {
min-height: 90vh;
background-color: var(--ink-dark);
position: relative;
padding-bottom: 0;
}
.featured-breach h2.landing-headline {
font-size: 40px;
max-width: 600px;
margin-bottom: 0;
}
.monitor-landing {
background-image: url('../../img/landing/background-noodle-top2.svg');
background-repeat: no-repeat;
background-size: 100% auto;
background-position: bottom center;
padding: 2rem 0.75rem;
}
.landing-bottom-bar {
background-image: url('../../img/landing/background-noodle-middle.svg'), linear-gradient(224deg, #2150c8, #712291);
background-repeat: no-repeat;
background-size: 100% auto;
background-position: top center;
position: relative;
padding-top: 0;
}
.landing-bottom-bar .take-back-control-banner {
margin: 88px auto 40px;
}
/* sensitive featured breach */
.landing-bottom-bar.sensitive-breach,
.gradient-container.sensitive-breach,
.row.sensitive-breach {
padding-top: 0;
}
.sb-top-wrapper.row {
position: relative;
width: 100%;
max-width: var(--max-width);
z-index: 1;
margin-top: -32px;
}
.sb-top {
background-color: rgb(255 255 255 / 0.95);
border-top-right-radius: 8px;
border-top-left-radius: 8px;
min-height: 4rem;
width: 100%;
}
.sensitive-breach .gradient-inset {
border-top-right-radius: 0;
border-top-left-radius: 0;
}
.landing-sensitive-info {
color: var(--grey1);
text-align: left;
max-width: 800px;
max-width: var(--max-width);
margin-bottom: 4rem;
}
.sb-callout-body {
font-size: 18px;
line-height: 1.5;
color: var(--grey2);
}
.sensitive-breach .feat-headline-wrapper {
padding-top: 0;
padding-bottom: 0;
}
h3.sb-callout {
font-size: 24px;
margin: 3rem auto 0;
font-weight: 700;
}
.sb-callout,
.sb-callout-body {
max-width: 600px;
}
.landing-content {
text-align: center;
}
.landing-content .form-desc {
color: var(--grey2);
margin-bottom: calc(var(--padding) * 3);
}
.landing-subhead {
padding: 0;
margin: 0 auto calc(var(--padding) * 2) auto;
max-width: 560px;
font-size: 24px;
line-height: 1.17;
}
.landing-headline,
.landing-subhead {
color: var(--grey1);
}
.landing-headline {
margin: auto auto 22px;
max-width: 750px;
}
.landing-content form {
max-width: 320px;
margin: auto;
}
.landing-headline .bold {
color: var(--violet2);
}
[data-ad-unit] {
width: min(960px, calc(100% - 60px));
margin: 80px auto;
text-align: center;
color: black;
border-radius: 8px;
}
[data-ad-unit] > * + * {
margin-top: 24px;
}
[data-ad-unit].dark {
color: white;
}
[data-ad-unit].light {
color: black;
}
[data-ad-unit] h2 {
color: inherit;
font-size: 32px;
margin: 0;
}
[data-ad-unit] video {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
}
[data-ad-unit='3'] {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 30px;
padding: 30px;
background-color: #6e008b;
}
[data-ad-unit='3'] > article {
flex: 0 1 360px;
margin: auto;
}
[data-ad-unit='3'] > article h2 {
font-size: 70px;
line-height: 1;
color: white;
word-break: break-word;
}
[data-ad-unit='3'] > article h2::first-line {
font-size: 100px;
}
[data-ad-unit='3'] > article p {
color: white;
}
[data-ad-unit='3'] > figure {
flex: 1 1 360px;
position: relative;
margin: 0;
min-height: 360px;
}
[data-ad-unit='3'] > figure img {
object-fit: contain;
height: 100%;
width: 75%;
}
[data-ad-unit='3'] > figure i {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1 / 1;
border-radius: 50%;
padding: 12px;
font-size: min(2vw, 13px);
font-weight: bold;
font-style: normal;
color: black;
background-color: #c688ff;
word-break: break-word;
}
[data-ad-unit='3'] > figure i:nth-of-type(1) {
top: 10px;
box-shadow: 0 0 24px #6e008b;
width: clamp(15%, 140px, 30%);
}
[data-ad-unit='3'] > figure i:nth-of-type(2) {
bottom: 20px;
box-shadow: 0 0 12px #6e008b;
width: clamp(15%, 120px, 25%);
}
[data-ad-unit='3'] > figure i:nth-of-type(3) {
right: 0;
top: 30px;
box-shadow: 0 0 6px #6e008b;
width: clamp(15%, 140px, 35%);
}
[data-ad-unit='4'],
[data-ad-unit='6'] {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 30px;
padding: 30px;
background-color: white;
}
[data-ad-unit='4'] > *,
[data-ad-unit='6'] > * {
flex: 1 1 325px;
margin: auto;
}
[data-ad-unit='4'] article,
[data-ad-unit='6'] article {
text-align: left;
}
[data-ad-unit='4'] figure img,
[data-ad-unit='6'] figure img {
width: min(100%, 400px);
}
[data-ad-unit='5'] {
background-color: white;
padding: 30px;
}
[data-ad-unit='5'] section {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 30px;
}
[data-ad-unit='5'] section > * {
flex-grow: 1;
flex-basis: calc((700px - 100%) * 999);
margin: 0;
}
[data-ad-unit='5'] figure img {
max-width: 100%;
max-height: 160px;
}
.ad-unit-cta {
font-weight: bold;
color: black;
background-color: #c688ff;
border: 0;
display: inline-flex;
align-items: center;
}
@media screen and (max-width: 800px) {
.landing-content {
max-width: 600px;
}
.monitor-homepage.featured-breach {
min-height: 100vh;
}
.featured-breach h2.landing-headline,
h2.landing-headline,
.landing-subhead {
margin: 0 auto 12px;
}
.landing-subhead {
font-size: 20px;
max-width: 500px;
}
.landing-content form {
margin-top: 8px;
}
[data-ad-unit='4'] article,
[data-ad-unit='6'] article {
text-align: center;
}
}
@media screen and (max-width: 600px) {
.monitor-homepage {
min-height: 86vh;
}
.landing-bottom-bar .take-back-control-banner {
margin: 68px auto 20px;
}
}
@media screen and (max-width: 500px) {
.landing-content {
max-width: 400px;
}
.landing-content form {
max-width: 100%;
}
.landing-subhead {
font-size: 16px;
line-height: 1.5;
}
.landing-subhead,
.landing-headline {
text-align: left;
}
.featured-breach h2.landing-headline {
text-align: center;
font-size: var(--top-headline);
}
}

Просмотреть файл

@ -1,171 +0,0 @@
.product-promo {
position: relative;
overflow: hidden;
border-radius: 16px;
padding: 32px 42px;
color: rgb(255 255 255 / 1);
text-align: left;
background-position: 104%;
background-size: auto 104%;
background-repeat: no-repeat;
border: 1px solid transparent;
}
.promo-copy {
padding-right: 13%;
}
.promo-monitor {
background-color: #393473;
background-image: url('../../img/svg/promos/promo-bg-monitor.svg');
}
.promo-mobile {
background-color: #123474;
background-image: url('../../img/svg/promos/promo-bg-mobile.svg');
}
/* QR Code */
.product-promo.promo-mobile::after {
background: #fff;
border-radius: 4px;
background-image: url('../../img/svg/promos/mobile-qr-gradient.svg');
content: '';
width: 60px;
height: 60px;
position: absolute;
right: 42px;
bottom: 32px;
background-size: 90%;
background-repeat: no-repeat;
background-position: center center;
}
.promo-lockwise {
background-image: url('../../img/svg/promos/promo-bg-lockwise.svg');
}
.promo-fpn {
background-image: url('../../img/svg/promos/promo-bg-fpn.svg');
}
.promo-ecosystem {
background-image: url('../../img/svg/promos/promo-bg-ecosystem.svg');
}
.promo-lockwise,
.promo-fpn,
.promo-ecosystem {
background-color: #1e1338;
}
.promo-monitor > .promo-content::before {
background-image: url('../../img/svg/fxa-tout-yellow-env.svg');
}
.promo-mobile > .promo-content::before {
background-image: url('../../img/svg/logos/fx-logo.svg');
}
.promo-lockwise > .promo-content::before {
background-image: url('../../img/svg/logos/fx-lockwise.svg');
}
.promo-fpn > .promo-content::before {
background-image: url('../../img/svg/logos/fpn-logo.svg');
}
.promo-ecosystem > .promo-content::before {
background-image: url('../../img/svg/logos/fx-master-logo.svg');
}
.promo-content::before {
margin-right: 36px;
min-width: 66px;
min-height: 66px;
background-size: contain;
background-repeat: no-repeat;
background-position: top center;
content: '';
display: inline-block;
}
.promo-headline {
font-size: 24px;
line-height: 1.17;
margin: 0 auto 8px 0;
}
.promo-body {
font-size: 16px;
line-height: 1.5;
margin: 0 auto 16px;
}
.promo-cta {
border-color: rgb(255 255 255 / 1);
border-radius: 8px;
min-height: 40px;
color: rgb(255 255 255 / 1);
font-size: 16px;
margin: auto auto auto 0;
padding: 12px 32px;
pointer-events: all;
}
.promo-cta:hover {
background-color: rgb(255 255 255 / 0.1);
}
.promo-wrapper {
margin-top: 44px;
margin-bottom: 20px;
padding: 0;
}
@media screen and (max-width: 800px) {
.product-promo.drop-shadow.flx.al-cntr.jst-cntr.promo-mobile::after {
display: none;
}
.promo-wrapper {
padding: 0 24px;
}
.product-promo {
background-image: none !important;
padding: 42px;
}
.promo-content {
flex-direction: column;
text-align: center;
align-items: center;
}
.promo-body {
max-width: 400px;
}
.promo-copy {
padding-right: 0;
}
.promo-content::before {
margin: auto auto 16px;
}
.promo-headline {
font-size: 32px;
}
}
@media screen and (max-width: 500px) {
.product-promo {
padding: 42px 24px;
}
.promo-headline {
font-size: 28px;
}
}

Просмотреть файл

@ -1,166 +0,0 @@
.mw-860.found-breaches {
padding-top: 0;
padding-bottom: 0;
flex-direction: row;
display: inline-flex;
}
.headline-col {
text-align: center;
padding-bottom: 0;
}
.scanned-email-address {
margin-top: 0;
margin-bottom: 20px;
font-size: 24px;
font-family: Metropolis, sans-serif;
font-weight: 600;
}
.source-info-wrap {
margin-top: 16px;
padding-bottom: 0;
}
.scan-res-subhead,
.scanned-email-address,
.scan-results-headline {
text-align: center;
}
.scan-results-headline {
max-width: 600px;
margin-left: auto;
margin-right: auto;
margin-bottom: 0;
}
.found-breaches,
.show-additional-breaches {
flex-wrap: wrap;
justify-content: space-between;
width: 100%;
}
.show-additional-breaches-wrapper {
width: 100%;
}
.row.scan-results,
.user-found-breaches {
margin: 0 auto;
}
.scan-res-subhead {
margin: var(--padding) auto auto auto;
}
.scanned-email-address .bold,
.scan-results-headline span.bold,
.scan-res-subhead span.bold {
color: var(--violet3);
}
.temp-marketing-img {
max-width: 320px;
}
.temp-marketing-hl {
color: #20123a;
font-size: 24px;
line-height: 1.13;
margin-bottom: 4px;
}
.temp-marketing-p {
color: var(--grey8);
line-height: 1.5;
margin: 0 auto 16px;
font-size: 18px;
}
.temp-marketing-callout {
margin: 20px auto 40px;
}
.temp-marketing-callout span {
color: var(--purple7);
}
.temp-marketing-btn-blue {
cursor: pointer;
border-radius: 8px;
font-size: 16px;
max-width: 320px;
font-weight: 600;
background-color: var(--blue3);
border: none;
width: 100%;
color: rgb(255 255 255 / 1);
padding-right: var(--padding);
padding-left: var(--padding);
min-height: 48px;
transition: all 0.15s ease-in-out;
}
.temp-marketing-btn-blue.button-top {
margin-top: 16px;
}
.temp-marketing-btn-blue:hover {
background-color: var(--blue4);
transition: all 0.15s ease-in-out;
}
@media screen and (max-width: 800px) {
.scanned-email-address,
.scan-results-headline,
.scan-res-subhead {
text-align: left;
margin-left: 0;
padding-left: 0;
}
.headline-col {
text-align: left;
max-width: 500px;
}
.scanned-email-address {
font-size: 16px;
}
.found-breaches.scan-res-breaches {
padding: 0 var(--padding);
}
}
.facebook-notes {
text-align: left;
margin-bottom: 100px;
}
.facebook-notes .section-headline {
margin-top: 3rem;
}
.facebook-notes.temp-marketing-callout span a {
color: var(--blue3);
}
.facebook-notes .feature-tip-content-wrapper:last-of-type {
margin-bottom: 20px;
}
.facebook-notes a.recommendation {
margin-left: 80px;
}
@media screen and (max-width: 800px) {
.facebook-notes a.recommendation {
margin-left: 0;
text-align: center;
display: inline-block;
}
}

Просмотреть файл

@ -1,625 +0,0 @@
.security-tips.clear-header {
min-height: auto;
}
.articles .col-9 {
width: 100%;
max-width: 100%;
justify-content: center;
}
.security-tip-list-item {
align-items: flex-start;
margin-bottom: 1.75rem;
flex: 1 1 45%;
}
.articles {
color: var(--grey8);
}
.security-tips {
color: var(--grey1);
padding-bottom: 0;
}
.security-tip-list {
margin-top: var(--padding);
flex-flow: row wrap;
}
.st-intro-wrap {
padding: 0 var(--padding);
border-left: 0.5rem solid var(--grey2);
}
.st-intro {
font-size: 1.2rem;
font-style: italic;
line-height: 220%;
}
.security-tip-link {
display: inline;
font-size: 1.1rem;
font-weight: 600;
color: var(--grey1);
align-items: center;
font-family: Metropolis, sans-serif;
}
.security-tip-link-subhead {
margin-top: 0;
margin-bottom: 0;
font-size: 1rem;
max-width: 400px;
opacity: 0.9;
}
.article-icon {
height: 2rem;
width: 2rem;
border-radius: 50%;
overflow: hidden;
position: relative;
margin-right: 1rem;
}
.ico {
width: 3rem;
height: 3rem;
display: flex;
justify-content: center;
align-items: center;
border-bottom-width: 4px;
border-top: none;
border-left: none;
border-right: none;
border-bottom-style: solid;
margin-bottom: 1rem;
padding-bottom: 1rem;
}
.security-tip-subhead {
max-width: 230px;
line-height: 1.3;
margin-bottom: 6rem;
display: block;
font-family: Metropolis, sans-serif;
}
.ico svg.icon-inline path {
fill: var(--grey8);
}
body {
--how-hackers-work: var(--orange6);
--stronger-passwords: var(--blue2);
--steps-to-protect: var(--purple1);
--five-myths: var(--violet1);
--next-steps: #e31587;
--after-breach: #3fe1b0;
}
.how-hackers-work {
border-bottom-color: var(--how-hackers-work);
color: var(--how-hackers-work);
}
.strong-passwords {
border-bottom-color: var(--stronger-passwords);
color: var(--stronger-passwords);
}
.after-breach {
border-bottom-color: var(--after-breach);
color: var(--after-breach);
}
.steps-to-protect {
border-bottom-color: var(--steps-to-protect);
color: var(--steps-to-protect);
}
.five-myths {
border-bottom-color: var(--five-myths);
color: var(--five-myths);
}
.next-steps {
border-bottom-color: var(--next-steps);
color: var(--next-steps);
}
.security-tip-link svg path {
fill: rgb(255 255 255 / 1);
}
.how-hackers-work-list {
margin-top: calc(var(--padding) * 2);
}
.myth {
color: var(--violet3);
display: block;
margin-right: 1rem;
}
.article-icon .icon-inline {
opacity: 1;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
}
.article-wrapper {
padding-top: 12rem;
padding-bottom: 0;
}
.article-wrapper,
.st-intro-wrap {
width: 75%;
margin: auto;
}
.article-headline {
color: var(--ink);
font-size: 3rem;
font-weight: 700;
}
.article-list-item,
.article-paragraph {
font-size: 1.1rem;
line-height: 200%;
opacity: 0.9;
}
a.st-copy-link {
color: var(--blue3);
}
.article-paragraph .bold {
color: var(--ink);
}
a.st-copy-link:hover,
a.st-link:hover {
text-decoration: underline;
}
.st-list-header {
margin-top: 2rem;
color: var(--ink);
font-size: 1.1rem;
}
.article-list-item {
margin-bottom: 0.5rem;
}
.article-list-item::before {
content: '\2022';
display: inline-block;
margin-right: 1rem;
font-size: 1.5rem;
color: var(--ink);
align-self: flex-start;
}
.article-subhead {
font-size: 1.5rem;
display: inline-block;
font-weight: 500;
}
h3.article-subhead,
.toggle-subhead {
margin: 2.5rem auto 0;
font-weight: 700;
color: var(--ink);
}
.steps-to-protect::before {
background-color: var(--blue3);
}
.icon-inline-wrapper {
display: flex;
align-items: center;
margin-right: 1.5rem;
}
.icon-inline {
min-width: 1.5rem;
max-width: 1.5rem;
max-height: 1.5rem;
opacity: 0.9;
}
.st-headline {
margin: 0;
line-height: 200%;
display: inline;
}
.st-list,
.st-subhead {
margin: 0;
padding: var(--padding) calc(30px + 2rem) 0;
}
.st-list-item {
margin: 0.75rem 0;
position: relative;
}
.st-list-item::before {
content: '\2022';
display: inline-block;
margin-right: 0.75rem;
}
a.st-link {
color: var(--blue3);
display: block;
margin-top: 1rem;
}
.st-icon {
margin-right: 2rem;
}
.st-headline .bold {
display: block;
text-transform: uppercase;
font-size: 1.3rem;
}
.st-call-out {
margin: calc(var(--padding) * 2) auto;
border-radius: 0.7rem;
background: var(--monitor-gradient);
padding: 2px;
box-shadow: 0 0 10px 1px rgb(205 205 212 / 0.78);
}
.inset {
background-color: var(--bg-light);
border-radius: 0.575rem;
padding: calc(var(--padding) * 2);
}
/* password dos and don't list */
.pw-tip-item {
width: 100%;
}
.pw-tip-item.flx:nth-child(odd) {
background-color: rgb(32 18 58 / 0.031);
}
.pw-do-hl,
.pw-do {
border-right: 1px solid var(--grey2);
}
.pw-tip-headlines {
position: relative;
}
.pw-tip-headlines::after {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
display: block;
content: '';
background: var(--monitor-gradient);
}
.pw-tip-headlines,
.do-dont-hl {
width: 100%;
}
.do-dont-hl {
padding: 2rem 1rem;
flex: 1 1 50%;
}
.do-dont-hl h4 {
align-items: center;
font-size: 1.5rem;
position: relative;
color: var(--blue5);
}
.icon-dont::before {
background-image: url('../../img/svg/x-close-red.svg');
}
.icon-do::before {
background-image: url('../../img/svg/green-check.svg');
}
.icon-do::before,
.icon-dont::before {
left: -2.5rem;
content: '';
width: 2rem;
height: 2rem;
background-size: contain;
background-position: center center;
display: inline-block;
position: absolute;
}
.do-dont-wrap {
padding: var(--padding);
flex: 1 1 50%;
}
.do-dont {
width: 80%;
line-height: 150%;
font-size: 1.1rem;
font-weight: 500;
color: var(--ink);
}
.password-tip-list {
flex-wrap: wrap;
}
.pw-tip-table {
border-radius: 0.7rem;
box-shadow: 0 0 10px 1px rgb(205 205 212 / 0.78);
}
.password-tip-list-headline {
padding: calc(var(--padding) * 2) var(--padding);
margin-bottom: 0;
font-size: 2.25rem;
}
.do-list-item,
.dont-list-item {
padding: var(--padding);
display: flex;
align-items: center;
min-height: 70px;
}
.article-toggle {
padding: 0;
position: relative;
width: 5rem;
min-width: 100%;
pointer-events: none;
transition: background-color 0.15s ease-in-out;
}
.article-toggle:hover {
background-color: var(--grey2);
transition: background-color 0.15s ease-in-out;
}
.article-toggle svg.toggle-down {
display: none;
}
.drop-cap {
font-size: 40px;
color: var(--orange6);
font-weight: 700;
display: block;
float: left;
padding-top: 5px;
margin-bottom: -10px;
padding-right: 3px;
}
@media screen and (max-width: 950px) {
.security-tip-list-item .article-icon,
.security-tip-link-subhead {
display: none;
}
.security-tip-list-item {
margin-bottom: 1.25rem;
flex: 1 1 100%;
width: 100%;
}
}
@media screen and (max-width: 800px) {
.pw-tip-headlines::after {
display: none;
}
.do-dont-wrap {
flex: 1 1 100% !important;
border: none;
}
.pw-tip-table {
background-color: rgb(0 0 0 / 0);
box-shadow: none;
}
.pw-tip-item {
flex-direction: column;
margin-bottom: 3rem;
border-radius: 0.7rem;
overflow: hidden;
border: none !important;
}
.security-tips-headline {
font-size: var(--headline);
}
.security-tip-link {
font-size: 1rem;
}
.article-wrapper,
.st-intro-wrap {
width: 100%;
max-width: 100%;
}
.do-dont-hl-mobile {
position: relative;
font-weight: bold;
font-size: 1.25rem;
margin-bottom: 0.25rem;
color: var(--blue5);
}
.do-dont-hl-mobile::before {
width: 1.5rem;
height: 1.5rem;
left: -2.15rem;
}
.do-dont-hl {
display: none;
}
.do-dont {
text-align: left;
display: flex;
flex-direction: column;
}
.pw-do {
background-color: rgb(255 255 255 / 1);
}
.pw-dont {
position: relative;
background-color: rgb(32 18 58 / 0.05);
}
.pw-dont::before {
width: 1.25rem;
height: 1.25rem;
background-color: white;
display: block;
content: '';
position: absolute;
top: -0.75rem;
transform: rotate(45deg);
}
}
@media screen and (max-width: 600px) {
.st-intro,
.article-list-item,
.article-paragraph {
font-size: 1rem;
}
.st-intro-wrap {
padding: 0;
border-left: none;
}
.article-headline {
font-size: var(--headline);
}
.drop-cap {
font-size: 28px;
padding-right: 1px;
margin-bottom: -6px;
}
.article-list-item::before {
margin-right: 0.75rem;
}
.article-list-item {
line-height: 180%;
}
.st-toggle {
margin-bottom: var(--padding);
}
.toggle.article-toggle {
pointer-events: all;
margin-bottom: 0;
}
.st-toggle-wrapper {
border-bottom: 1px solid var(--grey3);
border-radius: 0;
padding: var(--padding) 0;
margin: 1rem 0;
}
.st-icon {
display: none;
}
.st-next-steps .st-toggle-wrapper {
border: 1px solid var(--grey8);
border-radius: 0.25rem;
padding: var(--padding);
}
.article-toggle svg.toggle-down {
right: var(--padding);
display: inline-block;
}
.toggle-subhead {
font-weight: 500;
font-size: 1rem;
padding-right: calc(var(--padding) * 3);
margin: auto;
}
.myth {
display: block;
}
.icon-inline {
min-width: 1.15rem;
max-width: 1.15rem;
max-height: 1.15rem;
}
.icon-inline-wrapper {
margin-right: 1rem;
}
.security-tip-list-item {
padding-right: 13%;
}
.logo-title-wrapper .arrow-head-right {
margin-left: 0;
}
.do-dont-wrap {
padding: calc(var(--padding) * 2) var(--padding);
}
.st-subhead {
padding: 1rem 0;
}
.st-call-out {
margin: calc(var(--padding) * 3) auto;
}
}

Просмотреть файл

@ -1,148 +0,0 @@
.sign-up-banner {
display: flex;
flex-direction: row;
width: 100%;
padding: 40px;
justify-content: space-between;
color: rgb(255 255 255 / 1);
}
.breach-detail-sign-up {
border-radius: 16px;
box-shadow: 0 2px 12px -2px #1c1c1d80;
}
.sign-up-feature-list {
max-width: 500px;
margin: 0;
}
.fxa-signup-banner {
padding-left: var(--padding);
padding-right: var(--padding);
justify-content: center;
}
.feature-button {
margin-top: calc(var(--padding) * 2);
}
.feature-button .feature-tip-content {
text-align: center;
}
.sign-up-feature::before {
content: '\2022';
display: inline-block;
height: 1rem;
color: rgb(255 255 255 / 1);
flex: 1;
margin-right: 0.5rem;
width: 1rem;
min-width: 1rem;
max-width: 1rem;
}
.sign-up-cta::before {
width: 76px;
height: 50px;
content: '';
display: block;
background-image: url('../../img/svg/fxa-tout-yellow-env.svg');
background-repeat: no-repeat;
background-position: bottom center;
background-size: contain;
margin-bottom: 16px;
}
.sign-up-cta p,
.sign-up-cta .text-link {
color: rgb(255 255 255 / 1);
}
.have-an-account path {
fill: var(--blue3);
}
.browser-not-required {
display: inline-block;
text-align: center;
font-size: 12px;
margin: 12px auto;
color: rgb(91 91 102 / 1);
line-height: 1.4;
max-width: 288px;
}
p.have-an-account button.text-link,
p.have-an-account {
font-size: 16px;
min-height: 0;
}
p.have-an-account {
display: inline-block;
margin: auto;
}
.have-an-account button.text-link {
margin: 0 0.5rem;
}
.sign-up-headline {
font-size: 24px;
line-height: 1.17;
color: rgb(255 255 255 / 1);
max-width: 320px;
margin: 0 0 16px;
}
.sign-up-feature {
line-height: 1.5;
font-size: 16px;
font-weight: 500;
margin: 0 0 8px;
display: flex;
}
.sign-up-banner .feature-tip-group {
margin-bottom: calc(var(--padding) * 2);
}
.sign-up-cta .browser-not-required {
color: rgb(255 255 255 / 0.8);
}
.sign-up-cta {
max-width: 300px;
text-align: center;
align-items: center;
flex: 1;
justify-content: center;
}
@media screen and (max-width: 800px) {
.col-12.feat-headline-wrapper {
padding-bottom: 0;
}
.sign-up-banner {
flex-direction: column;
padding: calc(var(--padding) + var(--padding)) var(--padding);
}
h2.sign-up-headline {
text-align: center;
margin: 0 auto 24px;
}
.sign-up-cta,
.sign-up-feature-list {
margin-left: auto;
margin-right: auto;
}
.sign-up-cta {
margin-top: 24px;
}
}

Просмотреть файл

@ -1,16 +0,0 @@
.subpage {
margin: auto;
}
.subpage.email-confirmed {
background-image: url('../../img/svg/scan-res-bg.svg');
background-position: center top;
background-size: cover;
background-repeat: no-repeat;
height: 100%;
}
.subpage .content-box {
flex-direction: column;
justify-content: center;
}

Просмотреть файл

@ -1,257 +0,0 @@
.vpn-banner {
--step-list-w: 560px;
position: fixed;
width: 100%;
z-index: 1;
text-align: center;
color: white;
}
/* body header, main, and footer transform based on vpn-banner height */
.vpn-banner ~ header,
.vpn-banner ~ main,
.vpn-banner ~ footer {
transform: translateY(var(--vpn-banner-height, 0));
}
/* By waiting for the async data-protected attribute, we avoid initial animation on page load */
.vpn-banner[data-protected] ~ header,
.vpn-banner[data-protected] ~ main,
.vpn-banner[data-protected] ~ footer {
transition: transform 0.3s;
}
.vpn-banner label,
.vpn-banner li {
font-size: 13px;
}
.vpn-banner em,
.vpn-banner output {
font-weight: bold;
font-style: normal;
}
.vpn-banner em {
color: #e22850;
}
.vpn-banner[data-protected='true'] em {
color: #1cc4a0;
}
.vpn-banner .vpn-banner-top::after,
.vpn-banner:not([data-protected]) .vpn-banner-top::after {
/* black screen while loading protection data */
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: black;
transition: opacity 0.3s ease-out, visibility 0s 0.3s;
}
.vpn-banner[data-protected] .vpn-banner-top::after {
opacity: 0;
visibility: hidden;
}
.vpn-banner[data-protected='true'] .protected-txt:first-child,
.vpn-banner[data-protected='false'] .protected-txt:last-child {
display: block;
}
.vpn-banner[data-protected='false'] .protected-txt:first-child,
.vpn-banner[data-protected='true'] .protected-txt:last-child,
.vpn-banner[data-protected='true'] .vpn-banner-cta {
display: none;
}
/*** TOP PANEL ***/
.vpn-banner-top {
display: flex;
flex-wrap: wrap;
justify-content: center;
padding: 8px 0;
background-color: black;
cursor: pointer;
box-shadow: inset 0 -1px 0 #444;
}
.vpn-banner-top label {
padding: 0 4px;
pointer-events: none;
}
.vpn-banner-top .short-location::after {
content: '•';
padding-left: 8px;
}
.vpn-banner-chevron {
min-width: 76px;
text-align: left;
}
.vpn-banner-chevron::before {
content: attr(data-expand);
display: inline-block;
}
.vpn-banner-chevron::after {
content: url('../../img/svg/chevron.svg');
display: inline-block;
vertical-align: bottom;
width: 16px;
height: 16px;
margin-left: 3px;
transition: transform 0.15s;
transform-origin: 50% 53%;
}
[data-expanded] .vpn-banner-chevron::before {
content: attr(data-close);
}
[data-expanded] .vpn-banner-chevron::after {
transform: rotate(180deg);
}
/*** BOTTOM PANEL ***/
.vpn-banner-bottom {
position: relative;
display: none;
max-width: var(--max-width);
min-height: 250px;
color: black;
margin: auto;
padding: 32px 16px 16px;
counter-reset: step;
animation: vpn-banner-expand 0.3s ease-in both;
}
[data-expanded] .vpn-banner-bottom {
display: grid;
}
.vpn-banner-bottom::before {
content: '';
position: absolute;
width: 100vw;
height: 100%;
top: 0;
left: calc(50% - 50vw);
background: white url('../../img/svg/clouds.svg') no-repeat 90% 75%;
z-index: -1;
}
.vpn-banner-title {
font-family: Metropolis, sans-serif;
text-align: left;
}
.vpn-banner-title .subheading {
display: block;
font-weight: normal;
font-size: 18px;
margin-top: 8px;
}
.vpn-banner-lists {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
/* TODO: <ul> and <li> inherit custom flex styles from main. Should separate this and refine for reusability */
.vpn-banner-lists .status-list {
margin: 16px 0;
}
.vpn-banner-lists .status-list em,
.vpn-banner-lists .status-list output {
padding: 0 4px;
}
.vpn-banner-lists .step-list {
flex: 0 1 var(--step-list-w);
display: flex;
flex-wrap: wrap;
margin: 16px 0;
padding: 0;
list-style: none;
}
.vpn-banner-lists .step-list li {
flex-basis: calc(var(--step-list-w) / 3);
margin: 0 auto 16px;
}
.vpn-banner-lists .step-list li::before {
display: inline-block;
content: counter(step) '.';
counter-increment: step;
height: 40px;
line-height: 40px;
width: 36px;
margin-left: -36px;
border: 2px solid black;
box-shadow: inset 4px 4px 0 white;
background-color: #ededf0;
font-family: Metropolis, sans-serif;
font-size: 18px;
font-weight: bold;
vertical-align: top;
}
.vpn-banner-lists .step-list figure {
display: inline-block;
width: 110px;
margin: 0;
}
.vpn-banner-cta {
justify-self: start;
margin: 16px 0;
padding: 12px 24px;
border: 2px solid black;
box-shadow: 6px 6px 0 #a883f8;
font: bold 14px/1 Metropolis, sans-serif;
font-weight: bold;
color: black;
background-color: white;
}
.vpn-banner-close {
width: 20px;
height: 20px;
padding: 0;
border: transparent;
border-top: 2px solid #321c64;
border-left: 2px solid #321c64;
transform: rotate(45deg);
margin: auto;
}
.vpn-banner-close::after {
content: '';
display: block;
border-top: 1px solid #592acb;
border-left: 1px solid #592acb;
width: 100%;
height: 100%;
margin: 4px;
}
@keyframes vpn-banner-expand {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

Двоичные данные
public/favicon.ico

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 6.9 KiB

Двоичные данные
public/fonts/Inter/Inter-Bold.woff2

Двоичный файл не отображается.

Двоичные данные
public/fonts/Inter/Inter-BoldItalic.woff2

Двоичный файл не отображается.

Двоичные данные
public/fonts/Inter/Inter-Italic.woff2

Двоичный файл не отображается.

Двоичные данные
public/fonts/Inter/Inter-Regular.woff2

Двоичный файл не отображается.

Двоичный файл не отображается.

Двоичные данные
public/img/ad-units/ad-unit-3-video-games.png

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 13 KiB

Двоичные данные
public/img/ad-units/ad-unit-4-alone-time.png

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 9.2 KiB

Двоичные данные
public/img/ad-units/ad-unit-5-breakfast.png

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 4.4 KiB

Двоичные данные
public/img/ad-units/ad-unit-5-shopping.png

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 3.8 KiB

Двоичные данные
public/img/ad-units/ad-unit-5-walking.png

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 3.7 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 11 KiB

Просмотреть файл

@ -1 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4.293 8.293a1 1 0 0 1 1.414 0L12 14.586l6.293-6.293a1 1 0 1 1 1.414 1.414l-7 7a1 1 0 0 1-1.414 0l-7-7a1 1 0 0 1 0-1.414z" fill="#ffffff" fill-opacity=".8"></path></svg>

До

Ширина:  |  Высота:  |  Размер: 314 B

Двоичные данные
public/img/email_images/fxm-lg-drk.png

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 7.0 KiB

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше