diff --git a/.env-dist b/.env-dist index 6859bf620..5336b829e 100755 --- a/.env-dist +++ b/.env-dist @@ -59,6 +59,9 @@ HIBP_THROTTLE_MAX_TRIES=5 # Authorization token for HIBP to present to /hibp/notify endpoint HIBP_NOTIFY_TOKEN=unsafe-default-token-for-dev +# OneRep API for exposure scanning +ONEREP_API_KEY= + # Firefox Remote Settings FX_REMOTE_SETTINGS_WRITER_SERVER=https://settings-writer.prod.mozaws.net/v1 FX_REMOTE_SETTINGS_WRITER_USER= diff --git a/db/migrations/20230413104243_add_onerep_profile_id.js b/db/migrations/20230413104243_add_onerep_profile_id.js new file mode 100644 index 000000000..5d765f015 --- /dev/null +++ b/db/migrations/20230413104243_add_onerep_profile_id.js @@ -0,0 +1,23 @@ +/* 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/. */ + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.table('subscribers', (table) => { + table.integer('onerep_profile_id').nullable() + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.table('subscribers', (table) => { + table.dropColumn('onerep_profile_id') + }) +} diff --git a/src/client/js/partials/exposuresSetup.js b/src/client/js/partials/exposuresSetup.js new file mode 100644 index 000000000..b532e7763 --- /dev/null +++ b/src/client/js/partials/exposuresSetup.js @@ -0,0 +1,60 @@ +/* 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/. */ + +/** @type {HTMLDivElement} */ +// @ts-ignore: We guard against a null value by not calling init(): +const exposuresSetupPartial = document.querySelector("[data-partial='exposuresSetup']") + +if (exposuresSetupPartial) { + init() +} + +async function init () { + const dataEl = /** @type {HTMLTemplateElement} */ (exposuresSetupPartial.querySelector('#data')) + const csrfToken = /** @type {string} */ (dataEl.dataset.csrfToken) + + const mockButton = /** @type {HTMLButtonElement} */ (exposuresSetupPartial.querySelector('#storeMockData')) + mockButton.addEventListener('click', async () => { + await storeMockData(csrfToken) + document.location.reload() + }) +} + +/** + * @param {string} csrfToken + */ +async function storeMockData (csrfToken) { + /** @type {import('../../../external/onerep').ProfileData} */ + const requestBody = { + city: 'Tulsa', + first_name: 'John', + last_name: 'Doe', + state: 'OK' + } + + try { + const res = await fetch('/api/v1/user/exposures/', { + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': csrfToken, + Accept: 'application/json' + }, + mode: 'same-origin', + method: 'POST', + body: JSON.stringify(requestBody) + }) + + if (!res.ok) { + throw new Error('Immediately caught to show an error message.') + } + + /** @type {import("../../../controllers/request-breach-scan").RequestBreachScanResponse} */ + const responseBody = await res.json() + + if (!responseBody.success) { + throw new Error('Immediately caught to show an error message.') + } + } catch (e) { + } +} diff --git a/src/controllers/exposures.js b/src/controllers/exposures.js index a1d32b901..01095681a 100644 --- a/src/controllers/exposures.js +++ b/src/controllers/exposures.js @@ -6,12 +6,18 @@ import { mainLayout } from '../views/mainLayout.js' import { generateToken } from '../utils/csrf.js' import { exposuresSetup } from '../views/partials/exposuresSetup.js' import { exposuresList } from '../views/partials/exposuresList.js' +import { listScanResults } from '../external/onerep.js' /** * @type {import('express').RequestHandler} */ -async function exposuresPage (req, res) { - const showDashboard = await hasSetUpExposureScanning(req.user) +async function exposuresPage (req, res, next) { + if (!req.user) { + next() + return + } + const user = req.user + const showDashboard = hasSetUpExposureScanning(user) if (!showDashboard) { /** @@ -21,12 +27,13 @@ async function exposuresPage (req, res) { partial: exposuresSetup, csrfToken: generateToken(res, req), nonce: res.locals.nonce, - fxaProfile: req.user?.fxa_profile_json + fxaProfile: user.fxa_profile_json } res.send(mainLayout(data)) return } + const scanResults = await listScanResults(user.onerep_profile_id) /** * @type {MainViewPartialData} */ @@ -34,20 +41,19 @@ async function exposuresPage (req, res) { partial: exposuresList, csrfToken: generateToken(res, req), nonce: res.locals.nonce, - fxaProfile: req.user?.fxa_profile_json + fxaProfile: user.fxa_profile_json, + scanResults } res.send(mainLayout(data)) } /** - * @param {import('express').Request['user']} user - * @returns {Promise} Whether the user has set up exposure scanning already + * @param {NonNullable} user + * @returns {user is import('express').Request['user'] & { onerep_profile_id: number }} Whether the user has set up exposure scanning already */ -async function hasSetUpExposureScanning (user) { - // TODO: Once the back-end supports storing a user's exposure scan data, - // check whether it has been entered: - return false +function hasSetUpExposureScanning (user) { + return typeof user.onerep_profile_id === 'number' } export { exposuresPage } diff --git a/src/controllers/storeExposureScanData.js b/src/controllers/storeExposureScanData.js new file mode 100644 index 000000000..dc6d5bef1 --- /dev/null +++ b/src/controllers/storeExposureScanData.js @@ -0,0 +1,46 @@ +/* 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 { createProfile, createScan, parseExposureScanData } from '../external/onerep.js' +import { setOnerepProfileId } from '../db/tables/subscribers.js' + +/** + * @typedef {{ success: false }} StoreExposureScanDataErrorResponse + * @typedef {{ success: true }} StoreExposureScanDataSuccessResponse + * @typedef {StoreExposureScanDataErrorResponse | StoreExposureScanDataSuccessResponse} StoreExposureScanDataResponse + */ + +/** @type {import('express').RequestHandler} */ +async function storeExposureScanData (req, res, next) { + if (req.method !== 'POST' || !req.user) { + return next() + } + if (typeof req.user.onerep_profile_id === 'number') { + // Exposure scan data is already set for this user + res.status(409).send({ success: false }) + return + } + const exposureScanInput = parseExposureScanData(req.body) + if (exposureScanInput === null) { + res.status(400).send({ success: false }) + return + } + + try { + const onerepProfileId = await createProfile(exposureScanInput) + await createScan(onerepProfileId) + await setOnerepProfileId(req.user, onerepProfileId) + + /** @type {StoreExposureScanDataSuccessResponse} */ + const successResponse = { + success: true + } + res.json(successResponse) + return + } catch (ex) { + res.status(500).send({ success: false }) + } +} + +export { storeExposureScanData } diff --git a/src/custom-types.d.ts b/src/custom-types.d.ts index 0c457373f..08870e032 100644 --- a/src/custom-types.d.ts +++ b/src/custom-types.d.ts @@ -40,7 +40,7 @@ type FxaProfile = { } declare namespace Express { export interface Request { - user?: { + user?: (import('./db/tables/subscribers_types').SubscriberRow) & { // TODO: Finish the type definition of the user object fxa_profile_json?: FxaProfile; }; diff --git a/src/db/tables/subscribers.js b/src/db/tables/subscribers.js index 264a17917..04d1a6f2d 100644 --- a/src/db/tables/subscribers.js +++ b/src/db/tables/subscribers.js @@ -124,6 +124,18 @@ async function removeFxAData (subscriber) { return updatedSubscriber } +/** + * @param {import('./subscribers_types').SubscriberRow} subscriber + * @param {number} onerepProfileId + */ +async function setOnerepProfileId (subscriber, onerepProfileId) { + await knex('subscribers') + .where('id', subscriber.id) + .update({ + onerep_profile_id: onerepProfileId + }) +} + async function setBreachesLastShownNow (subscriber) { // TODO: turn 2 db queries into a single query (also see #942) const nowDateTime = new Date() @@ -314,6 +326,7 @@ export { updateFxAData, removeFxAData, updateFxAProfileData, + setOnerepProfileId, setBreachesLastShownNow, setAllEmailsToPrimary, setBreachesResolved, diff --git a/src/db/tables/subscribers_types.d.ts b/src/db/tables/subscribers_types.d.ts new file mode 100644 index 000000000..de1f91b0c --- /dev/null +++ b/src/db/tables/subscribers_types.d.ts @@ -0,0 +1,10 @@ +// This file contains types for subscribers.js until it's fully typed + +export type SubscriberRow = { + id: number; + sha1: string; + email: string; + verification_token: string; + verified: boolean; + onerep_profile_id?: number; +} diff --git a/src/external/onerep.js b/src/external/onerep.js index 0de32420a..677a912ad 100644 --- a/src/external/onerep.js +++ b/src/external/onerep.js @@ -4,6 +4,8 @@ import AppConstants from '../appConstants.js' import mozlog from '../utils/log.js' +import { parseE164PhoneNumber, parseIso8601Datetime } from '../utils/parse.js' +import { usStates } from '../utils/states.js' const log = mozlog('external.onerep') /** @@ -14,11 +16,13 @@ async function onerepFetch (path, options = {}) { const url = 'https://api.onerep.com' + path const headers = new Headers(options.headers) headers.set('Authorization', `Basic ${Buffer.from(`${AppConstants.ONEREP_API_KEY}:`).toString('base64')}`) + headers.set('Accept', 'application/json') + headers.set('Content-Type', 'application/json') return fetch(url, { ...options, headers }) } /** - * @typedef {object} OneRepProfile + * @typedef {object} ProfileData * @property {string} first_name * @property {string} last_name * @property {string} city @@ -28,7 +32,7 @@ async function onerepFetch (path, options = {}) { */ /** - * @param {OneRepProfile} profileData + * @param {ProfileData} profileData * @returns {Promise} Profile ID */ export async function createProfile (profileData) { @@ -78,6 +82,38 @@ export async function createProfile (profileData) { return savedProfile.id } +/** + * @param {any} body + * @returns {ProfileData | null} + */ +export function parseExposureScanData (body) { + const state = usStates.find(state => typeof body === 'object' && state === body.state) + if ( + typeof body !== 'object' || + typeof body.first_name !== 'string' || + body.first_name.length === 0 || + typeof body.last_name !== 'string' || + body.last_name.length === 0 || + typeof body.city !== 'string' || + body.city.length === 0 || + typeof body.state !== 'string' || + typeof state !== 'string' || + (typeof body.birth_date !== 'string' && typeof body.birth_date !== 'undefined') || + (typeof body.phone_number !== 'string' && typeof body.phone_number !== 'undefined') + ) { + return null + } + + return { + first_name: body.first_name, + last_name: body.last_name, + city: body.city, + state, + birth_date: parseIso8601Datetime(body.birth_date)?.toISOString(), + phone_number: parseE164PhoneNumber(body.phone_number) ?? undefined + } +} + /** * @param {number} profileId * @returns {Promise} diff --git a/src/routes/api/v1/user.js b/src/routes/api/v1/user.js index 41ea216f8..a54b059a2 100644 --- a/src/routes/api/v1/user.js +++ b/src/routes/api/v1/user.js @@ -8,6 +8,7 @@ import { asyncMiddleware } from '../../../middleware/util.js' import { requireSessionUser } from '../../../middleware/auth.js' import { methodNotAllowed } from '../../../middleware/error.js' import { putBreachResolution, getBreaches } from '../../../controllers/breaches.js' +import { storeExposureScanData } from '../../../controllers/storeExposureScanData.js' import { addEmail, resendEmail, @@ -20,6 +21,7 @@ const router = Router() // breaches router.put('/breaches', requireSessionUser, asyncMiddleware(putBreachResolution)) router.get('/breaches', requireSessionUser, asyncMiddleware(getBreaches)) +router.post('/exposures', requireSessionUser, asyncMiddleware(storeExposureScanData)) router.post('/email', requireSessionUser, asyncMiddleware(addEmail)) router.post('/resend-email', requireSessionUser, asyncMiddleware(resendEmail)) router.post('/remove-email', requireSessionUser, asyncMiddleware(removeEmail)) diff --git a/src/views/partials/exposuresList.js b/src/views/partials/exposuresList.js index 6335e7ec8..6f648bf68 100644 --- a/src/views/partials/exposuresList.js +++ b/src/views/partials/exposuresList.js @@ -5,6 +5,7 @@ /** * @typedef {object} PartialParameters * @property {string} csrfToken + * @property {import("../../external/onerep").ListScanResultsResponse} scanResults */ /** @@ -12,4 +13,5 @@ */ export const exposuresList = data => ` This page will show the user's exposures dashboard, when they have already set up exposure scanning. +
${JSON.stringify(data.scanResults, null, 2)}
` diff --git a/src/views/partials/exposuresSetup.js b/src/views/partials/exposuresSetup.js index 58d908862..59cd56b44 100644 --- a/src/views/partials/exposuresSetup.js +++ b/src/views/partials/exposuresSetup.js @@ -11,5 +11,7 @@ * @type {ViewPartial} */ export const exposuresSetup = data => ` + This page will allow the user to enter their information to do a scan for public data exposures. + ` diff --git a/tsconfig.json b/tsconfig.json index 77f76a90c..c47f75a64 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "src/controllers/exposures.js", "src/external/onerep.js", "src/utils/emailAddress.js", + "src/utils/log.js", "src/utils/states.js", "src/utils/parse.js", // Replace the above with the following when our entire codebase has type annotations: