Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge autofill code into a component #2378

Merged
merged 2 commits into from
Jan 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions shared/js/background/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { onStartup } from './startup'
import FireButton from './components/fire-button'
import TabTracker from './components/tab-tracking'
import MV3ContentScriptInjection from './components/mv3-content-script-injection'
import EmailAutofill from './components/email-autofill'
import OmniboxSearch from './components/omnibox-search'
import initDebugBuild from './devbuild'
import initReloader from './devbuild-reloader'
Expand All @@ -42,6 +43,7 @@ settings.ready().then(() => {
*/
const components = {
tabTracking: new TabTracker({ tabManager }),
autofill: new EmailAutofill({ settings }),
omnibox: new OmniboxSearch()
}

Expand Down
353 changes: 353 additions & 0 deletions shared/js/background/components/email-autofill.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,353 @@
/* global BUILD_TARGET */
import browser from 'webextension-polyfill'
import { sendPixelRequest } from '../pixels'
import { registerMessageHandler } from '../message-handlers'
import { getDomain } from 'tldts'
import tdsStorage from '../storage/tds'
import { getBrowserName, isInstalledWithinDays, sendTabMessage } from '../utils'
import { getFromSessionStorage, setToSessionStorage, removeFromSessionStorage, createAlarm } from '../wrapper'

/**
* Config type definition
* @typedef {Object} FirePixelOptions
* @property {import('@duckduckgo/autofill/src/deviceApiCalls/__generated__/validators-ts').SendJSPixelParams['pixelName']} pixelName
*/

const MENU_ITEM_ID = 'ddg-autofill-context-menu-item'
export const REFETCH_ALIAS_ALARM = 'refetchAlias'
const REFETCH_ALIAS_ATTEMPT = 'refetchAliasAttempt'

const pixelsEnabled = BUILD_TARGET !== 'firefox'

export default class EmailAutofill {
/**
* @param {{
* settings: import('../settings.js');
* }} options
*/
constructor ({ settings }) {
this.settings = settings
this.contextMenuAvailable = !!browser.contextMenus
if (this.contextMenuAvailable) {
// Create the contextual menu hidden by default
browser.contextMenus.create({
id: MENU_ITEM_ID,
title: 'Generate Private Duck Address',
contexts: ['editable'],
documentUrlPatterns: ['https://*/*'],
visible: false
}, () => {
// It's fine if this context menu already exists, suppress that error.
// Note: Since webextension-polyfill does not wrap the contextMenus.create
// API, the old callback + runtime.lastError approach must be used.
const { lastError } = browser.runtime
if (lastError && lastError.message &&
!lastError.message.startsWith('Cannot create item with duplicate id')) {
throw lastError
}
})
browser.contextMenus.onClicked.addListener((info, tab) => {
const userData = this.settings.getSetting('userData')
if (tab?.id && userData.nextAlias) {
browser.tabs.sendMessage(tab.id, {
type: 'contextualAutofill',
alias: userData.nextAlias
})
}
})
}
// fetch alias timer
browser.alarms.onAlarm.addListener((alarmEvent) => {
if (alarmEvent.name === REFETCH_ALIAS_ALARM) {
this.fetchAlias()
}
})
// message handlers
registerMessageHandler('getAddresses', this.getAddresses.bind(this))
registerMessageHandler('sendJSPixel', this.sendJSPixel.bind(this))
registerMessageHandler('getAlias', this.getAlias.bind(this))
registerMessageHandler('refreshAlias', this.refreshAlias.bind(this))
registerMessageHandler('getEmailProtectionCapabilities', getEmailProtectionCapabilities)
sammacbeth marked this conversation as resolved.
Show resolved Hide resolved
registerMessageHandler('getIncontextSignupDismissedAt', this.getIncontextSignupDismissedAt.bind(this))
registerMessageHandler('setIncontextSignupPermanentlyDismissedAt', this.setIncontextSignupPermanentlyDismissedAt.bind(this))
registerMessageHandler('getUserData', this.getUserData.bind(this))
registerMessageHandler('addUserData', this.addUserData.bind(this))
registerMessageHandler('removeUserData', this.removeUserData.bind(this))
registerMessageHandler('logout', this.logout.bind(this))

this.ready = this.init()
}

async init () {
await this.settings.ready()
// fetch alias if needed
const userData = this.settings.getSetting('userData')
if (userData && userData.token) {
if (!userData.nextAlias) await this.fetchAlias()
this.showContextMenuAction()
}
}

async fetchAlias () {
await this.settings.ready()
// if another fetch was previously scheduled, clear that and execute now
browser.alarms.clear(REFETCH_ALIAS_ALARM)

const userData = this.settings.getSetting('userData')

if (!userData?.token) return

return fetch('https://quack.duckduckgo.com/api/email/addresses', {
method: 'post',
headers: { Authorization: `Bearer ${userData.token}` }
})
.then(async response => {
if (response.ok) {
return response.json().then(async ({ address }) => {
if (!/^[a-z0-9]+$/.test(address)) throw new Error('Invalid address')

this.settings.updateSetting('userData', Object.assign(userData, { nextAlias: `${address}` }))
// Reset attempts
await removeFromSessionStorage(REFETCH_ALIAS_ATTEMPT)
return { success: true }
})
} else {
throw new Error('An error occurred while fetching the alias')
}
})
.catch(async e => {
// TODO: Do we want to logout if the error is a 401 unauthorized?
console.log('Error fetching new alias', e)
// Don't try fetching more than 5 times in a row
const attempts = await getFromSessionStorage(REFETCH_ALIAS_ATTEMPT) || 1
if (attempts < 5) {
createAlarm(REFETCH_ALIAS_ALARM, { delayInMinutes: 2 })
await setToSessionStorage(REFETCH_ALIAS_ATTEMPT, attempts + 1)
}
// Return the error so we can handle it
return { error: e }
})
}

async getAlias () {
await this.settings.ready()
const userData = this.settings.getSetting('userData')
return { alias: userData?.nextAlias }
}

/**
* @returns {Promise<import('@duckduckgo/privacy-dashboard/schema/__generated__/schema.types').RefreshAliasResponse>}
*/
async refreshAlias () {
await this.fetchAlias()
return this.getAddresses()
}

getAddresses () {
const userData = this.settings.getSetting('userData')
return {
personalAddress: userData?.userName,
privateAddress: userData?.nextAlias
}
}

showContextMenuAction () {
if (this.contextMenuAvailable) {
browser.contextMenus.update(MENU_ITEM_ID, { visible: true })
}
}

hideContextMenuAction () {
if (this.contextMenuAvailable) {
browser.contextMenus.update(MENU_ITEM_ID, { visible: false })
}
}

/**
*
* @param {FirePixelOptions} options
*/
sendJSPixel (options) {
const { pixelName } = options
switch (pixelName) {
case 'autofill_show':
this.fireAutofillPixel('email_tooltip_show_extension')
break
case 'autofill_private_address':
this.fireAutofillPixel('email_filled_random_extension', true)
break
case 'autofill_personal_address':
this.fireAutofillPixel('email_filled_main_extension', true)
break
case 'incontext_show':
fireIncontextSignupPixel('incontext_show_extension')
break
case 'incontext_primary_cta':
fireIncontextSignupPixel('incontext_primary_cta_extension')
break
case 'incontext_dismiss_persisted':
fireIncontextSignupPixel('incontext_dismiss_persisted_extension')
break
case 'incontext_close_x':
fireIncontextSignupPixel('incontext_close_x_extension')
break
default:
getFromSessionStorage('dev').then(isDev => {
if (isDev) console.error('Unknown pixel name', pixelName)
})
}
}

fireAutofillPixel (pixel, shouldUpdateLastUsed = false) {
const browserName = getBrowserName() ?? 'unknown'
if (!pixelsEnabled) return

const userData = this.settings.getSetting('userData')
if (!userData?.userName) return

const lastAddressUsedAt = this.settings.getSetting('lastAddressUsedAt') ?? ''

sendPixelRequest(getFullPixelName(pixel, browserName), { duck_address_last_used: lastAddressUsedAt, cohort: userData.cohort })
if (shouldUpdateLastUsed) {
this.settings.updateSetting('lastAddressUsedAt', currentDate())
}
}

getIncontextSignupDismissedAt () {
const permanentlyDismissedAt = this.settings.getSetting('incontextSignupPermanentlyDismissedAt')
// TODO: inject this dependency (after TDS refactor lands)
sammacbeth marked this conversation as resolved.
Show resolved Hide resolved
const installedDays = tdsStorage.config.features.incontextSignup?.settings?.installedDays ?? 3
const isInstalledRecently = isInstalledWithinDays(installedDays)
return { success: { permanentlyDismissedAt, isInstalledRecently } }
}

setIncontextSignupPermanentlyDismissedAt ({ value }) {
this.settings.updateSetting('incontextSignupPermanentlyDismissedAt', value)
}

// Get user data to be used by the email web app settings page. This includes
// username, last alias, and a token for generating additional aliases.
async getUserData (_, sender) {
if (!isExpectedSender(sender)) return

await this.settings.ready()
const userData = this.settings.getSetting('userData')
if (userData) {
return userData
} else {
return { error: 'Something seems wrong with the user data' }
}
}

async addUserData (userData, sender) {
const { userName, token } = userData
if (!isExpectedSender(sender)) return

const sendDdgUserReady = async () => {
const tabs = await browser.tabs.query({})
tabs.forEach((tab) =>
sendTabMessage(tab.id, { type: 'ddgUserReady' })
)
}

await this.settings.ready()
const { existingToken } = this.settings.getSetting('userData') || {}

// If the user is already registered, just notify tabs that we're ready
if (existingToken === token) {
sendDdgUserReady()
return { success: true }
}

// Check general data validity
if (isValidUsername(userName) && isValidToken(token)) {
this.settings.updateSetting('userData', userData)
// Once user is set, fetch the alias and notify all tabs
const response = await this.fetchAlias()
if (response && 'error' in response) {
return { error: response.error.message }
}

sendDdgUserReady()
this.showContextMenuAction()
return { success: true }
} else {
return { error: 'Something seems wrong with the user data' }
}
}

async removeUserData (_, sender) {
if (!isExpectedSender(sender)) return
await this.logout()
}

async logout () {
this.settings.updateSetting('userData', {})
this.settings.updateSetting('lastAddressUsedAt', '')
// Broadcast the logout to all tabs
const tabs = await browser.tabs.query({})
tabs.forEach((tab) => {
sendTabMessage(tab.id, { type: 'logout' })
})
this.hideContextMenuAction()
}
}

function currentDate () {
return new Date().toLocaleString('en-CA', {
timeZone: 'America/New_York',
dateStyle: 'short'
})
}

const getFullPixelName = (name, browserName) => {
return `${name}_${browserName.toLowerCase()}`
}

const fireIncontextSignupPixel = (pixel, params) => {
const browserName = getBrowserName() ?? 'unknown'
if (!pixelsEnabled) return

sendPixelRequest(getFullPixelName(pixel, browserName), params)
}

/**
* Given a username, returns a valid email address with the duck domain
* @param {string} address
* @returns {string}
*/
export const formatAddress = (address) => address + '@duck.com'

/**
* Checks formal username validity
* @param {string} userName
* @returns {boolean}
*/
export const isValidUsername = (userName) => /^[a-z0-9_]+$/.test(userName)

/**
* Checks formal token validity
* @param {string} token
* @returns {boolean}
*/
export const isValidToken = (token) => /^[a-z0-9]+$/.test(token)

function isExpectedSender (sender) {
try {
const domain = getDomain(sender.url)
const { pathname } = new URL(sender.url)
return domain === 'duckduckgo.com' && pathname.startsWith('/email')
} catch {
return false
}
}

function getEmailProtectionCapabilities (_, sender) {
if (!isExpectedSender(sender)) return

return {
addUserData: true,
getUserData: true,
removeUserData: true
}
}