Refactor emailAddresses.js to Typescript (#4925)

* convert breaches.js to ts

* convert email_notifications to typescript

* Refactor emailAddresses.js to Typescript

* remove redundant type

* more specific types

* remove use of any for email hash function

* remove use of any for email hash function

* remove no-unsafe-return typescript linter exception

* fix error
This commit is contained in:
Kaitlyn Andres 2024-08-07 08:59:04 -04:00 коммит произвёл GitHub
Родитель eaee8ffec9
Коммит 677eda250f
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
20 изменённых файлов: 497 добавлений и 543 удалений

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

@ -15,7 +15,7 @@ import {
getFxATokens,
updateFxATokens,
} from "../../../db/tables/subscribers.js";
import { addSubscriber } from "../../../db/tables/emailAddresses.js";
import { addSubscriber } from "../../../db/tables/emailAddresses";
import { getBreaches } from "../../functions/server/getBreaches";
import { getBreachesForEmail } from "../../../utils/hibp";
import { getSha1, refreshOAuthTokens } from "../../../utils/fxa";
@ -197,7 +197,7 @@ export const authOptions: AuthOptions = {
});
const enabledFlags = await getEnabledFeatureFlags({
email: verifiedSubscriber.primary_email,
email: verifiedSubscriber ? verifiedSubscriber.primary_email : "",
});
await initEmail(process.env.SMTP_URL);

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

@ -3,7 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { SubscriberRow } from "knex/types/tables";
import { resetUnverifiedEmailAddress } from "../../../db/tables/emailAddresses.js";
import { resetUnverifiedEmailAddress } from "../../../db/tables/emailAddresses";
import { sendEmail, getVerificationUrl } from "../../../utils/email.js";
import { getTemplate } from "../../../emails/email2022.js";
import { verifyPartial } from "../../../emails/emailVerify.js";

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

@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from "next/server";
import AppConstants from "../../../../../appConstants";
import { getSubscriberByFxaUid } from "../../../../../db/tables/subscribers";
import { addSubscriberUnverifiedEmailHash } from "../../../../../db/tables/emailAddresses.js";
import { addSubscriberUnverifiedEmailHash } from "../../../../../db/tables/emailAddresses";
import { sendVerificationEmail } from "../../../utils/email";

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

@ -14,7 +14,7 @@ import {
import {
removeOneSecondaryEmail,
getEmailById,
} from "../../../../../db/tables/emailAddresses.js";
} from "../../../../../db/tables/emailAddresses";
import { getL10n } from "../../../../functions/l10n/serverComponents";
interface EmailDeleteRequest {

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

@ -5,7 +5,7 @@
import { NextRequest, NextResponse } from "next/server";
import { logger } from "../../../../functions/server/logging";
import { verifyEmailHash } from "../../../../../db/tables/emailAddresses.js";
import { verifyEmailHash } from "../../../../../db/tables/emailAddresses";
export async function GET(req: NextRequest) {
try {
const query = req.nextUrl.searchParams;

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

@ -8,7 +8,7 @@ import {
getAllBreachesFromDb,
fetchHibpBreaches,
} from "../../../utils/hibp";
import { upsertBreaches } from "../../../db/tables/breaches.js";
import { upsertBreaches } from "../../../db/tables/breaches";
let breaches: Array<HibpLikeDbBreach>;

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

@ -4,7 +4,7 @@
import { Session } from "next-auth";
import { getSubscriberByFxaUid } from "../../../db/tables/subscribers.js";
import { getUserEmails } from "../../../db/tables/emailAddresses.js";
import { getUserEmails } from "../../../db/tables/emailAddresses";
/**
* NOTE: new function to replace getUserBreaches

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

@ -13,7 +13,7 @@ import { getSubscriberByFxaUid } from "../../../../src/db/tables/subscribers.js"
import {
BundledVerifiedEmails,
getAllEmailsAndBreaches,
} from "../../../../src/utils/breaches.js";
} from "../../../../src/utils/breaches";
import { SubscriberBreach } from "../../../utils/subscriberBreaches";
import { HibpLikeDbBreach } from "../../../utils/hibp";

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

@ -25,4 +25,4 @@ export {
}
export * from './tables/subscribers.js'
export * from './tables/emailAddresses.js'
export * from './tables/emailAddresses'

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

@ -2,56 +2,45 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { formatDataClassesArray } from "../../utils/hibp";
import { BreachRow } from "knex/types/tables";
import {
formatDataClassesArray,
HibpGetBreachesResponse,
} from "../../utils/hibp";
import createDbConnection from "../connect.js";
const knex = createDbConnection();
/**
* Get all records from "breaches" table
*
* @returns Array of all records from "breaches" table
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function getAllBreaches() {
return knex('breaches')
.returning("*")
async function getAllBreaches(): Promise<BreachRow[]> {
return knex("breaches").returning("*");
}
/* c8 ignore stop */
/**
* Get all count from "breaches" table
*
* @returns Count of all records from "breaches" table
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function getAllBreachesCount() {
const breachesCount = await knex('breaches')
async function getAllBreachesCount(): Promise<number> {
const breachesCount = await knex("breaches")
.count({ count: "id" })
.first()
.then(result => result?.count || "")
.then((result) => result?.count || "");
// Make sure we are returning a number.
return parseInt(breachesCount.toString(), 10)
return parseInt(breachesCount.toString(), 10);
}
/* c8 ignore stop */
/**
* Upsert breaches into "breaches" table
* Skip inserting when 'name' field (unique) has a conflict
*
* @param {import("../../utils/hibp").HibpGetBreachesResponse} hibpBreaches breaches array from HIBP API
* @returns
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function upsertBreaches(hibpBreaches) {
console.debug('upsertBreaches', hibpBreaches[0])
async function upsertBreaches(
hibpBreaches: HibpGetBreachesResponse,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Promise<any> {
console.debug("upsertBreaches", hibpBreaches[0]);
return knex.transaction(async trx => {
const queries = hibpBreaches.map(breach =>
knex('breaches')
return knex.transaction(async (trx) => {
const queries = hibpBreaches.map((breach) =>
knex("breaches")
.insert({
name: breach.Name,
title: breach.Title,
@ -61,7 +50,7 @@ async function upsertBreaches(hibpBreaches) {
modified_date: breach.ModifiedDate,
pwn_count: breach.PwnCount,
description: breach.Description,
logo_path: /** @type {RegExpExecArray} */(/[^/]*$/.exec(breach.LogoPath))[0],
logo_path: breach.LogoPath,
data_classes: formatDataClassesArray(breach.DataClasses),
is_verified: breach.IsVerified,
is_fabricated: breach.IsFabricated,
@ -71,35 +60,26 @@ async function upsertBreaches(hibpBreaches) {
is_malware: breach.IsMalware,
favicon_url: null,
})
.onConflict('name')
.onConflict("name")
.merge()
.transacting(trx)
)
.transacting(trx),
);
try {
const value = await Promise.all(queries)
return trx.commit(value)
const value = await Promise.all(queries);
return trx.commit(value);
} catch (error) {
return trx.rollback(error)
return trx.rollback(error);
}
})
});
}
/* c8 ignore stop */
/**
* Update logo path of a breach by name
*
* @param {string} name
* @param {string | null} faviconUrl
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function updateBreachFaviconUrl(name, faviconUrl) {
await knex('breaches')
.where("name", name)
.update({
favicon_url: faviconUrl
})
async function updateBreachFaviconUrl(name: string, faviconUrl: string | null) {
await knex("breaches").where("name", name).update({
favicon_url: faviconUrl,
});
}
/* c8 ignore stop */
@ -108,5 +88,5 @@ export {
getAllBreachesCount,
upsertBreaches,
updateBreachFaviconUrl,
knex
}
knex,
};

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

@ -1,390 +0,0 @@
/* 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/. */
import { v4 as uuidv4 } from 'uuid'
import createDbConnection from "../connect.js";
import { subscribeHash } from '../../utils/hibp'
import { getSha1 } from '../../utils/fxa'
import { getSubscriberByEmail, updateFxAData } from './subscribers.js'
import {
ForbiddenError,
UnauthorizedError
} from '../../utils/error'
const knex = createDbConnection();
/**
* @param {string} token
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function getEmailByToken (token) {
const res = await knex('email_addresses')
.where('verification_token', '=', token)
return res[0]
}
/* c8 ignore stop */
/**
* @param {number} emailAddressId
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function getEmailById (emailAddressId) {
const res = await knex('email_addresses')
.where('id', '=', emailAddressId)
return res[0]
}
/* c8 ignore stop */
/**
* @param {string} email
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function 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?
console.warn('getEmailAddressRecordByEmail', { msg: 'found the same email multiple times' })
}
return emailAddresses[0]
}
/* c8 ignore stop */
/**
* @param {{ id: number; }} user
* @param {string} email
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function addSubscriberUnverifiedEmailHash (user, email) {
const lowerCaseEmail = email.toLowerCase()
const res = await knex.transaction(trx => {
return trx('email_addresses')
.forUpdate()
.select({
subscriber_id: user.id
})
.insert({
subscriber_id: user.id,
email: lowerCaseEmail,
sha1: getSha1(lowerCaseEmail),
verification_token: uuidv4(),
verified: false
}).returning('*')
})
return res[0]
}
/* c8 ignore stop */
/**
* @param {number | string} emailAddressId
* @param {import('knex/types/tables').SubscriberRow} subscriber
* @param {import("@fluent/react").ReactLocalization} l10n
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function resetUnverifiedEmailAddress (emailAddressId, subscriber, l10n) {
const newVerificationToken = uuidv4()
// Time in ms to require between verification reset.
const verificationWait = 5 * 60 * 1000 // 5 minutes
const verificationRecentlyUpdated = await knex('email_addresses')
.select('id')
.whereRaw('"updated_at" > NOW() - INTERVAL \'1 MILLISECOND\' * ?', verificationWait)
.andWhere('id', emailAddressId)
.first()
if (verificationRecentlyUpdated?.id === (typeof emailAddressId === "number" ? emailAddressId : parseInt(emailAddressId, 10))) {
throw new ForbiddenError(l10n.getString('error-email-validation-pending'))
}
const res = await knex('email_addresses')
.update({
verification_token: newVerificationToken,
// @ts-ignore knex.fn.now() results in it being set to a date,
// even if it's not typed as a JS date object:
updated_at: knex.fn.now()
})
.where('id', emailAddressId)
.andWhere("subscriber_id", subscriber.id)
.returning('*')
return res[0]
}
/* c8 ignore stop */
/**
* @param {string} token
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function verifyEmailHash (token) {
const unverifiedEmail = await getEmailByToken(token)
if (!unverifiedEmail) {
throw new UnauthorizedError('Error message for this verification email timed out or something went wrong.')
}
const verifiedEmail = await _verifyNewEmail(unverifiedEmail)
return verifiedEmail[0]
}
/* c8 ignore stop */
// TODO: refactor into an upsert? https://jaketrent.com/post/upsert-knexjs/
// Used internally, ideally should not be called by consumers.
/**
* @param {string} sha1
* @param {any} aFoundCallback
* @param {any} aNotFoundCallback
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function _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()
}
}
/* c8 ignore stop */
// Used internally.
/**
* @param {string} sha1
* @param {string} email
* @param {string} signupLanguage
* @param verified
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function _addEmailHash (sha1, email, signupLanguage, verified = false) {
try {
return await _getSha1EntryAndDo(sha1, async (/** @type {any} */ 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,
// @ts-ignore knex.fn.now() results in it being set to a date,
// even if it's not typed as a JS date object:
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) {
// @ts-ignore Log whatever, we don't care
console.error(e)
throw new Error('Could not add email address to database.')
}
}
/* c8 ignore stop */
/**
* 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 | null} fxaAccessToken from Firefox Account Oauth
* @param {string | null} fxaRefreshToken from Firefox Account Oauth
* @param {number} sessionExpiresAt from Firefox Account Oauth
* @param {string | null} fxaProfileData from Firefox Account
* @returns {Promise<import('knex/types/tables').SubscriberRow>} subscriber knex object added to DB
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function addSubscriber (email, signupLanguage, fxaAccessToken = null, fxaRefreshToken = null, sessionExpiresAt = 0, fxaProfileData = null) {
const lowerCaseEmail = email.toLowerCase()
const emailHash = await _addEmailHash(getSha1(lowerCaseEmail), lowerCaseEmail, signupLanguage, true)
const verified = await _verifySubscriber(emailHash)
const verifiedSubscriber = Array.isArray(verified) ? verified[0] : null
if (fxaRefreshToken || fxaProfileData) {
return updateFxAData(verifiedSubscriber, fxaAccessToken, fxaRefreshToken, sessionExpiresAt, fxaProfileData)
}
return verifiedSubscriber
}
/* c8 ignore stop */
/**
* 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 {any} emailHash knex object in DB
* @returns {Promise<any>} verified subscriber knex object in DB
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function _verifySubscriber (emailHash) {
await subscribeHash(emailHash.primary_sha1)
const verifiedSubscriber = await knex('subscribers')
.where('primary_email', '=', emailHash.primary_email)
.update({
primary_verified: true,
// @ts-ignore knex.fn.now() results in it being set to a date,
// even if it's not typed as a JS date object:
updated_at: knex.fn.now()
})
.returning('*')
return verifiedSubscriber
}
/* c8 ignore stop */
// Verifies new emails added by existing users
/**
* @param {{ sha1: string; id: number; }} emailHash
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function _verifyNewEmail (emailHash) {
await subscribeHash(emailHash.sha1)
const verifiedEmail = await knex('email_addresses')
.where('id', '=', emailHash.id)
.update({
verified: true,
// @ts-ignore knex.fn.now() results in it being set to a date,
// even if it's not typed as a JS date object:
updated_at: knex.fn.now(),
})
.returning('*')
return verifiedEmail
}
/* c8 ignore stop */
/**
* @param {number} userId
* @returns {Promise<import('knex/types/tables').EmailAddressRow[]>}
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function getUserEmails (userId) {
const userEmails = await knex('email_addresses')
.where('subscriber_id', '=', userId)
.returning('*')
return userEmails
}
/* c8 ignore stop */
// 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
/**
* @param {string} email
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function removeEmail (email) {
const subscriber = await getSubscriberByEmail(email)
if (!subscriber) {
const emailAddress = await getEmailAddressRecordByEmail(email)
if (!emailAddress) {
console.warn('removed-subscriber-not-found')
return
}
await knex('email_addresses')
.where({
email,
verified: true
})
.del()
return
}
// This can fail if a subscriber has more email_addresses and marks
// a primary email as spam, but we should let it fail so we can see it
// in the logs
await knex('subscribers')
.where({
primary_verification_token: subscriber.primary_verification_token,
primary_sha1: subscriber.primary_sha1
})
.del()
}
/* c8 ignore stop */
/**
* @param {number} emailId
* @param {number} subscriberId
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function removeOneSecondaryEmail (emailId, subscriberId) {
await knex('email_addresses')
.where("id", emailId)
.andWhere("subscriber_id", subscriberId)
.del()
}
/* c8 ignore stop */
/**
* @param {string[]} hashes
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function 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)
}
/* c8 ignore stop */
/**
* @param {string} uid
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function deleteEmailAddressesByUid (uid) {
await knex('email_addresses').where("subscriber_id", uid).del()
}
/* c8 ignore stop */
export {
getEmailByToken,
getEmailById,
getEmailAddressRecordByEmail,
addSubscriberUnverifiedEmailHash,
resetUnverifiedEmailAddress,
verifyEmailHash,
addSubscriber,
getUserEmails,
removeEmail,
removeOneSecondaryEmail,
getEmailAddressesByHashes,
deleteEmailAddressesByUid,
knex as knexEmailAddresses
}

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

@ -0,0 +1,401 @@
/* 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/. */
import { v4 as uuidv4 } from "uuid";
import createDbConnection from "../connect.js";
import { subscribeHash } from "../../utils/hibp";
import { getSha1 } from "../../utils/fxa";
import { getSubscriberByEmail, updateFxAData } from "./subscribers.js";
import { ForbiddenError, UnauthorizedError } from "../../utils/error";
import { EmailAddressRow, SubscriberRow } from "knex/types/tables";
import { ReactLocalization } from "@fluent/react";
const knex = createDbConnection();
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function getEmailByToken(token: string) {
const res = await knex("email_addresses").where(
"verification_token",
"=",
token,
);
return res[0];
}
/* c8 ignore stop */
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function getEmailById(emailAddressId: number) {
const res = await knex("email_addresses").where("id", "=", emailAddressId);
return res[0];
}
/* c8 ignore stop */
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function getEmailAddressRecordByEmail(email: string) {
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?
console.warn("getEmailAddressRecordByEmail", {
msg: "found the same email multiple times",
});
}
return emailAddresses[0];
}
/* c8 ignore stop */
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function addSubscriberUnverifiedEmailHash(
user: SubscriberRow,
email: string,
) {
const lowerCaseEmail = email.toLowerCase();
const res = await knex.transaction((trx) => {
return trx("email_addresses")
.forUpdate()
.select({
subscriber_id: user.id,
})
.insert({
subscriber_id: user.id,
email: lowerCaseEmail,
sha1: getSha1(lowerCaseEmail),
verification_token: uuidv4(),
verified: false,
})
.returning("*");
});
return res[0];
}
/* c8 ignore stop */
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function resetUnverifiedEmailAddress(
emailAddressId: EmailAddressRow["id"],
subscriber: SubscriberRow,
l10n: ReactLocalization,
) {
const newVerificationToken = uuidv4();
// Time in ms to require between verification reset.
const verificationWait = 5 * 60 * 1000; // 5 minutes
const verificationRecentlyUpdated = await knex("email_addresses")
.select("id")
.whereRaw(
"\"updated_at\" > NOW() - INTERVAL '1 MILLISECOND' * ?",
verificationWait,
)
.andWhere("id", emailAddressId)
.first();
if (
verificationRecentlyUpdated?.id ===
(typeof emailAddressId === "number"
? emailAddressId
: parseInt(emailAddressId, 10))
) {
throw new ForbiddenError(l10n.getString("error-email-validation-pending"));
}
const res = await knex("email_addresses")
.update({
verification_token: newVerificationToken,
// @ts-ignore knex.fn.now() results in it being set to a date,
// even if it's not typed as a JS date object:
updated_at: knex.fn.now(),
})
.where("id", emailAddressId)
.andWhere("subscriber_id", subscriber.id)
.returning("*");
return res[0];
}
/* c8 ignore stop */
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function verifyEmailHash(token: string) {
const unverifiedEmail = await getEmailByToken(token);
if (!unverifiedEmail) {
throw new UnauthorizedError(
"Error message for this verification email timed out or something went wrong.",
);
}
const verifiedEmail = await _verifyNewEmail(unverifiedEmail);
return verifiedEmail[0];
}
/* c8 ignore stop */
// TODO: refactor into an upsert? https://jaketrent.com/post/upsert-knexjs/
// Used internally, ideally should not be called by consumers.
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function _getSha1EntryAndDo(
sha1: string,
aFoundCallback: (existingSubscriber: SubscriberRow) => Promise<SubscriberRow>,
aNotFoundCallback: () => Promise<SubscriberRow>,
) {
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();
}
}
/* c8 ignore stop */
// Used internally.
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function _addEmailHash(
sha1: string,
email: string,
signupLanguage: string,
verified: boolean = false,
): Promise<SubscriberRow | undefined> {
try {
return await _getSha1EntryAndDo(
sha1,
async (aEntry: SubscriberRow) => {
// 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,
// @ts-ignore knex.fn.now() results in it being set to a date,
// even if it's not typed as a JS date object:
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) {
// @ts-ignore Log whatever, we don't care
console.error(e);
throw new Error("Could not add email address to database.");
}
}
/* c8 ignore stop */
/**
* Add a subscriber:
* 1. Add a record to subscribers
* 2. Immediately call _verifySubscriber
* 3. For FxA subscriber, add refresh token and profile data
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
// subscriber knex object added to DB
/* c8 ignore start */
async function addSubscriber(
email: string, // to add
signupLanguage: string, // from Accept-Language
fxaAccessToken: string | null = null, // from Firefox Account Oauth
fxaRefreshToken: string | null = null, // from Firefox Account Oauth
sessionExpiresAt: number = 0, // from Firefox Account Oauth
fxaProfileData: string | null = null,
): // from Firefox Account
Promise<SubscriberRow | null> {
// subscriber knex object added to DB
const lowerCaseEmail = email.toLowerCase();
const emailHash = await _addEmailHash(
getSha1(lowerCaseEmail),
lowerCaseEmail,
signupLanguage,
true,
);
if (!emailHash) {
throw new Error("Email hash undefined");
}
const verified = await _verifySubscriber(emailHash);
const verifiedSubscriber = Array.isArray(verified) ? verified[0] : null;
if (fxaRefreshToken || fxaProfileData) {
return updateFxAData(
verifiedSubscriber,
fxaAccessToken,
fxaRefreshToken,
sessionExpiresAt,
fxaProfileData,
) as Promise<SubscriberRow | null>;
}
return verifiedSubscriber;
}
/* c8 ignore stop */
/**
* 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
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function _verifySubscriber(emailHash: SubscriberRow) {
await subscribeHash(emailHash.primary_sha1);
const verifiedSubscriber = await knex("subscribers")
.where("primary_email", "=", emailHash.primary_email)
.update({
primary_verified: true,
// @ts-ignore knex.fn.now() results in it being set to a date,
// even if it's not typed as a JS date object:
updated_at: knex.fn.now(),
})
.returning("*");
return verifiedSubscriber;
}
/* c8 ignore stop */
// Verifies new emails added by existing users
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
type NewEmailType = {
sha1: SubscriberRow["primary_sha1"];
id: SubscriberRow["id"];
};
async function _verifyNewEmail(emailHash: NewEmailType) {
await subscribeHash(emailHash.sha1);
const verifiedEmail = await knex("email_addresses")
.where("id", "=", emailHash.id)
.update({
verified: true,
// @ts-ignore knex.fn.now() results in it being set to a date,
// even if it's not typed as a JS date object:
updated_at: knex.fn.now(),
})
.returning("*");
return verifiedEmail;
}
/* c8 ignore stop */
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function getUserEmails(userId: number): Promise<EmailAddressRow[]> {
const userEmails = await knex("email_addresses")
.where("subscriber_id", "=", userId)
.returning("*");
return userEmails;
}
/* c8 ignore stop */
// 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
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function removeEmail(email: string) {
const subscriber = await getSubscriberByEmail(email);
if (!subscriber) {
const emailAddress = await getEmailAddressRecordByEmail(email);
if (!emailAddress) {
console.warn("removed-subscriber-not-found");
return;
}
await knex("email_addresses")
.where({
email,
verified: true,
})
.del();
return;
}
// This can fail if a subscriber has more email_addresses and marks
// a primary email as spam, but we should let it fail so we can see it
// in the logs
await knex("subscribers")
.where({
primary_verification_token: subscriber.primary_verification_token,
primary_sha1: subscriber.primary_sha1,
})
.del();
}
/* c8 ignore stop */
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function removeOneSecondaryEmail(emailId: number, subscriberId: number) {
await knex("email_addresses")
.where("id", emailId)
.andWhere("subscriber_id", subscriberId)
.del();
}
/* c8 ignore stop */
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function getEmailAddressesByHashes(hashes: string[]) {
return await knex("email_addresses")
.join("subscribers", "email_addresses.subscriber_id", "=", "subscribers.id")
.whereIn("email_addresses.sha1", hashes)
.andWhere("email_addresses.verified", "=", true);
}
/* c8 ignore stop */
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function deleteEmailAddressesByUid(uid: string) {
await knex("email_addresses").where("subscriber_id", uid).del();
}
/* c8 ignore stop */
export {
getEmailByToken,
getEmailById,
getEmailAddressRecordByEmail,
addSubscriberUnverifiedEmailHash,
resetUnverifiedEmailAddress,
verifyEmailHash,
addSubscriber,
getUserEmails,
removeEmail,
removeOneSecondaryEmail,
getEmailAddressesByHashes,
deleteEmailAddressesByUid,
knex as knexEmailAddresses,
};

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

@ -2,83 +2,52 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { BreachRow } from "knex/types/tables";
import createDbConnection from "../connect.js";
const knex = createDbConnection();
/**
* @param {number} subscriberId
*/
async function getAllEmailNotificationsForSubscriber(subscriberId){
/**
* @param {number} subscriberId
*/
async function getAllEmailNotificationsForSubscriber(subscriberId: number) {
console.info("getAllEmailNotificationsForSubscriber: ", subscriberId);
return await knex.transaction(trx => {
return trx('email_notifications')
return await knex.transaction((trx) => {
return trx("email_notifications")
.forUpdate()
.select()
.where("subscriber_id", subscriberId)
.orderBy("id");
})
});
}
/**
* @param {number} subscriberId
* @param {number} breachId
* @param {string} email
*/
async function getEmailNotification(
subscriberId,
breachId,
email
){
console.info(
`getEmailNotification for subscriber: ${subscriberId}, breach: ${breachId}`,
);
const res = await knex.transaction(trx => {
return trx('email_notifications')
.forUpdate()
.select()
.where("subscriber_id", subscriberId)
.andWhere("breach_id", breachId)
.andWhere("email", email);
})
if (res.length > 1) {
console.error(
"More than one entry for subscriber/breach email notification: ",
res,
);
}
return res?.[0] || null;
}
/**
* @param {number} breachId
*/
async function getNotifiedSubscribersForBreach(
breachId
){
breachId: BreachRow["id"],
): Promise<number[]> {
console.info(
`getEmailNotificationSubscribersForBreach for breach: ${breachId}`,
);
const res = await knex.transaction(trx => {
return trx('email_notifications')
const res = await knex.transaction((trx) => {
return trx("email_notifications")
.forUpdate()
.select("subscriber_id")
.where("notified", true)
.andWhere("breach_id", breachId);
})
});
return res.map((row) => row.subscriber_id);
}
/**
* @param {{ subscriberId: number; breachId: number; notified: boolean; email: string; notificationType: string; }} newNotification
*/
async function addEmailNotification(
newNotification
){
type NewNotification = {
breachId: number;
subscriberId: number;
notified: boolean;
email: string;
notificationType: string;
};
async function addEmailNotification(newNotification: NewNotification) {
console.info(`addEmailNotification: ${JSON.stringify(newNotification)}`);
const emailNotificationDb = {
subscriber_id: newNotification.subscriberId,
@ -88,29 +57,24 @@ async function addEmailNotification(
email: newNotification.email,
notification_type: newNotification.notificationType,
};
const res = await knex.transaction(trx => {
return trx('email_notifications')
.forUpdate()
.insert(emailNotificationDb)
.returning("*");
});
const res = await knex.transaction((trx) => {
return trx("email_notifications")
.forUpdate()
.insert(emailNotificationDb)
.returning("*");
});
return res[0];
}
/**
* @param {number} subscriberId
* @param {number} breachId
* @param {string} email
*/
async function markEmailAsNotified(
subscriberId,
breachId,
email
subscriberId: number,
breachId: BreachRow["id"],
email: string,
) {
console.info(`markEmailAsNotified for breach: ${breachId}`);
await knex.transaction(trx => {
return trx('email_notifications')
await knex.transaction((trx) => {
return trx("email_notifications")
.forUpdate()
.where("subscriber_id", subscriberId)
.andWhere("breach_id", breachId)
@ -121,13 +85,12 @@ async function markEmailAsNotified(
// even if it's not typed as a JS date object:
updated_at: knex.fn.now(),
});
});
});
}
export {
getAllEmailNotificationsForSubscriber,
getEmailNotification,
getNotifiedSubscribersForBreach,
addEmailNotification,
markEmailAsNotified
}
markEmailAsNotified,
};

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

@ -14,7 +14,7 @@ jest.mock("@sentry/nextjs", () => {
};
});
jest.mock("../../utils/email.js", () => {
jest.mock("../../utils/email", () => {
return {
initEmail: jest.fn(),
EmailTemplateType: jest.fn(),
@ -43,13 +43,13 @@ jest.mock("../../db/tables/subscribers.js", () => {
};
});
jest.mock("../../db/tables/emailAddresses.js", () => {
jest.mock("../../db/tables/emailAddresses", () => {
return {
getEmailAddressesByHashes: jest.fn(() => [""]),
};
});
jest.mock("../../db/tables/email_notifications.js", () => {
jest.mock("../../db/tables/email_notifications", () => {
return {
getNotifiedSubscribersForBreach: jest.fn(() => [""]),
addEmailNotification: jest.fn(),
@ -368,13 +368,13 @@ test("skipping email when subscriber id exists in email_notifications table", as
};
});
jest.mock("../../db/tables/emailAddresses.js", () => {
jest.mock("../../db/tables/emailAddresses", () => {
return {
getEmailAddressesByHashes: jest.fn(() => []),
};
});
jest.mock("../../db/tables/email_notifications.js", () => {
jest.mock("../../db/tables/email_notifications", () => {
return {
getNotifiedSubscribersForBreach: jest.fn(() => [1]),
addEmailNotification: jest.fn(),
@ -423,13 +423,13 @@ test("throws an error when addEmailNotification fails", async () => {
};
});
jest.mock("../../db/tables/emailAddresses.js", () => {
jest.mock("../../db/tables/emailAddresses", () => {
return {
getEmailAddressesByHashes: jest.fn(() => [""]),
};
});
jest.mock("../../db/tables/email_notifications.js", () => {
jest.mock("../../db/tables/email_notifications", () => {
return {
getNotifiedSubscribersForBreach: jest.fn(() => [2]),
addEmailNotification: jest.fn().mockImplementationOnce(() => {
@ -482,13 +482,13 @@ test("throws an error when markEmailAsNotified fails", async () => {
};
});
jest.mock("../../db/tables/emailAddresses.js", () => {
jest.mock("../../db/tables/emailAddresses", () => {
return {
getEmailAddressesByHashes: jest.fn(() => [""]),
};
});
jest.mock("../../db/tables/email_notifications.js", () => {
jest.mock("../../db/tables/email_notifications", () => {
return {
getNotifiedSubscribersForBreach: jest.fn(() => [2]),
addEmailNotification: jest.fn(),

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

@ -19,12 +19,12 @@ import {
import {
getEmailAddressesByHashes,
knexEmailAddresses,
} from "../../db/tables/emailAddresses.js";
} from "../../db/tables/emailAddresses";
import {
getNotifiedSubscribersForBreach,
addEmailNotification,
markEmailAsNotified,
} from "../../db/tables/email_notifications.js";
} from "../../db/tables/email_notifications";
import { getTemplate } from "../../emails/email2022.js";
import { breachAlertEmailPartial } from "../../emails/emailBreachAlert.js";
import {

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

@ -17,7 +17,7 @@ import {
getAllBreaches,
upsertBreaches,
updateBreachFaviconUrl,
} from "../../db/tables/breaches.js";
} from "../../db/tables/breaches";
import { uploadToS3 } from "../s3.js";
const SENTRY_SLUG = "cron-sync-breaches";

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

@ -2,7 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { getUserEmails } from "../db/tables/emailAddresses.js";
import { getUserEmails } from "../db/tables/emailAddresses";
import {
getBreachesForEmail,
getFilteredBreaches,

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

@ -3,7 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import AppConstants from "../appConstants.js";
import { getAllBreaches, knex } from "../db/tables/breaches.js";
import { getAllBreaches, knex } from "../db/tables/breaches";
import { isUsingMockHIBPEndpoint } from "../app/functions/universal/mock.ts";
import { BreachRow, EmailAddressRow, SubscriberRow } from "knex/types/tables";
import { ISO8601DateString } from "./parse.js";

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

@ -7,7 +7,7 @@ import { getBreachesForEmail, HibpLikeDbBreach } from "./hibp";
import { getSubBreaches } from "./subscriberBreaches";
import { getUserEmails } from "../db/tables/emailAddresses";
jest.mock("../db/tables/emailAddresses.js", () => ({
jest.mock("../db/tables/emailAddresses", () => ({
getUserEmails: jest.fn(),
}));

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

@ -7,7 +7,7 @@ import {
SubscriberBreachResolution,
SubscriberRow,
} from "knex/types/tables";
import { getUserEmails } from "../db/tables/emailAddresses.js";
import { getUserEmails } from "../db/tables/emailAddresses";
import { HibpLikeDbBreach, getBreachesForEmail } from "./hibp";
import { getSha1 } from "./fxa";
import { parseIso8601Datetime } from "./parse";