From 5acef9aa001560d73244346f6f6c84ffa4a6702b Mon Sep 17 00:00:00 2001 From: Ian Krieger Date: Wed, 13 Sep 2023 16:39:52 -0400 Subject: [PATCH] wip: decrypt conversion data --- package-lock.json | 22 ++- package.json | 2 + .../components/ReportUtils.tsx | 98 +----------- .../analyticsOverview/lib/csv.library.tsx | 53 ------- src/user/reporting/ReportMenu.tsx | 142 ++++++++++++++++++ src/user/reporting/csv.library.tsx | 122 +++++++++++++++ src/user/settings/NewKeyPairModal.tsx | 2 +- 7 files changed, 288 insertions(+), 153 deletions(-) delete mode 100644 src/user/analytics/analyticsOverview/lib/csv.library.tsx create mode 100644 src/user/reporting/ReportMenu.tsx create mode 100644 src/user/reporting/csv.library.tsx diff --git a/package-lock.json b/package-lock.json index 82fe9fc4d..71d2d8dce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "lodash": "4.17.21", "moment": "2.29.4", "npm-audit-resolver": "3.0.0-RC.0", + "papaparse": "5.4.1", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "5.3.4", @@ -52,6 +53,7 @@ "@types/jest": "29.5.4", "@types/jwt-decode": "2.2.1", "@types/lodash": "4.14.197", + "@types/papaparse": "^5.3.8", "@types/react": "18.2.21", "@types/react-dom": "18.2.7", "@types/react-router-dom": "5.3.3", @@ -3508,11 +3510,20 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.15.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz", - "integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==", + "version": "18.17.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.17.15.tgz", + "integrity": "sha512-2yrWpBk32tvV/JAd3HNHWuZn/VDN1P+72hWirHnvsvTGSqbANi+kSeuQR9yAHnbvaBvHDsoTdXV0Fe+iRtHLKA==", "dev": true }, + "node_modules/@types/papaparse": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.8.tgz", + "integrity": "sha512-ArKIEOOWULbhi53wkAiRy1ze4wvrTfhpAj7Yfzva+EkmX2sV8PpFB+xqzJfzXNzK4me95FJH9QZt5NXFVGzOoQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -8486,6 +8497,11 @@ "node": ">=6" } }, + "node_modules/papaparse": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", + "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", diff --git a/package.json b/package.json index 5af766dec..6a9d6d1da 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "lodash": "4.17.21", "moment": "2.29.4", "npm-audit-resolver": "3.0.0-RC.0", + "papaparse": "5.4.1", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "5.3.4", @@ -61,6 +62,7 @@ "@types/jest": "29.5.4", "@types/jwt-decode": "2.2.1", "@types/lodash": "4.14.197", + "@types/papaparse": "^5.3.8", "@types/react": "18.2.21", "@types/react-dom": "18.2.7", "@types/react-router-dom": "5.3.3", diff --git a/src/user/analytics/analyticsOverview/components/ReportUtils.tsx b/src/user/analytics/analyticsOverview/components/ReportUtils.tsx index e0684444c..be0fba72c 100644 --- a/src/user/analytics/analyticsOverview/components/ReportUtils.tsx +++ b/src/user/analytics/analyticsOverview/components/ReportUtils.tsx @@ -1,21 +1,9 @@ -import { - Alert, - Box, - Button, - ListItemIcon, - Menu, - MenuItem, - Snackbar, -} from "@mui/material"; +import { Box } from "@mui/material"; import { DateRangePicker } from "components/Date/DateRangePicker"; import { DashboardButton } from "components/Button/DashboardButton"; import { CampaignFormat } from "graphql/types"; import _ from "lodash"; -import { useDownloadCSV } from "user/analytics/analyticsOverview/lib/csv.library"; -import { useEffect, useState } from "react"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; -import DownloadIcon from "@mui/icons-material/Download"; +import { ReportMenu } from "user/reporting/ReportMenu"; interface DownloaderProps { startDate: Date | undefined; @@ -79,85 +67,3 @@ export default function ReportUtils({ ); } - -interface ReportMenuProps { - hasVerifiedConversions: boolean; - campaignId: string; -} -const ReportMenu = ({ - campaignId, - hasVerifiedConversions, -}: ReportMenuProps) => { - const [isError, setIsError] = useState(false); - const { download, loading, error } = useDownloadCSV({ - onComplete() { - setAnchorEl(null); - }, - }); - const [anchorEl, setAnchorEl] = useState(null); - const menu = Boolean(anchorEl); - - useEffect(() => { - if (error !== undefined) { - setIsError(true); - } - }, [error]); - - return ( - <> - - - setAnchorEl(null)}> - download(campaignId, false)} - disabled={loading} - > - - - - Performance Report - - , - {hasVerifiedConversions && ( - download(campaignId, true)} - disabled={loading} - > - - - - Verified Conversions Report - - )} - - - setIsError(false)} - anchorOrigin={{ vertical: "top", horizontal: "center" }} - > - setIsError(false)} - severity="error" - sx={{ width: "100%" }} - > - {error} - - - - ); -}; diff --git a/src/user/analytics/analyticsOverview/lib/csv.library.tsx b/src/user/analytics/analyticsOverview/lib/csv.library.tsx deleted file mode 100644 index 59e5feb23..000000000 --- a/src/user/analytics/analyticsOverview/lib/csv.library.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useCallback, useState } from "react"; -import { buildAdServerEndpoint } from "util/environment"; - -interface DownloadProps { - onComplete?: () => void; -} - -export function useDownloadCSV(props: DownloadProps = {}) { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(); - - const download = useCallback((campaignId: string, isVac: boolean) => { - setLoading(true); - setError(undefined); - - const baseUrl = `/report/campaign/csv/${campaignId}`; - fetch(buildAdServerEndpoint(isVac ? `${baseUrl}/vac` : baseUrl), { - method: "GET", - mode: "cors", - credentials: "include", - headers: { - "Content-Type": "text/csv", - }, - }) - .then((res) => { - if (res.status !== 200) { - const err = "Unable to download CSV"; - setError(err); - throw new Error(err); - } - - return res.blob(); - }) - .then((blob) => { - const file = new Blob([blob], { - type: "text/csv", - endings: "transparent", - }); - const fileURL = URL.createObjectURL(file); - const link = document.createElement("a"); - link.href = fileURL; - link.setAttribute("download", `${campaignId}.csv`); - document.body.appendChild(link); - link.click(); - if (props.onComplete) props.onComplete(); - }) - .finally(() => { - setLoading(false); - }); - }, []); - - return { download, loading, error }; -} diff --git a/src/user/reporting/ReportMenu.tsx b/src/user/reporting/ReportMenu.tsx new file mode 100644 index 000000000..2ac033bce --- /dev/null +++ b/src/user/reporting/ReportMenu.tsx @@ -0,0 +1,142 @@ +import { useState } from "react"; +import { useDownloadCSV } from "user/reporting/csv.library"; +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + LinearProgress, + ListItemIcon, + Menu, + MenuItem, + Snackbar, + TextField, +} from "@mui/material"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import DownloadIcon from "@mui/icons-material/Download"; + +interface ReportMenuProps { + hasVerifiedConversions: boolean; + campaignId: string; +} +export const ReportMenu = ({ + campaignId, + hasVerifiedConversions, +}: ReportMenuProps) => { + const [privateKey, setPrivateKey] = useState(); + const [dialogue, setDialogue] = useState(false); + const [isError, setIsError] = useState(false); + const { download, loading, error } = useDownloadCSV({ + onComplete() { + setAnchorEl(null); + setDialogue(false); + setPrivateKey(undefined); + }, + onError() { + setIsError(true); + setDialogue(false); + setPrivateKey(undefined); + }, + }); + + const [anchorEl, setAnchorEl] = useState(null); + const menu = Boolean(anchorEl); + return ( + <> + + + setAnchorEl(null)}> + download(campaignId, false)} + disabled={loading} + > + + + + Performance Report + + , + {hasVerifiedConversions && ( + setDialogue(true)} disabled={loading}> + + + + Verified Conversions Report + + )} + + + setIsError(false)} + anchorOrigin={{ vertical: "top", horizontal: "center" }} + > + setIsError(false)} + severity="error" + sx={{ width: "100%" }} + > + {error} + + + + setDialogue(false)}> + Decrypt Conversion Data? + + + To protect user’s privacy, verified Ad conversion data is + encrypted so that the identities of converted users remain anonymous + to Brave. You can decrypt the conversion data in the CSV file by + providing your private key here. If no key is provided, you will + receive the encrypted conversion data. Your private key will never + be sent to or stored on any Brave servers. + + setPrivateKey(e.target.value)} + margin="normal" + value={privateKey} + label="Private key" + fullWidth + variant="standard" + /> + {loading && } + + + + + + + + ); +}; diff --git a/src/user/reporting/csv.library.tsx b/src/user/reporting/csv.library.tsx new file mode 100644 index 000000000..02746abf8 --- /dev/null +++ b/src/user/reporting/csv.library.tsx @@ -0,0 +1,122 @@ +import { useCallback, useState } from "react"; +import { buildAdServerEndpoint } from "util/environment"; +import Papa from "papaparse"; +import tweetnacl from "tweetnacl"; +interface DownloadProps { + onComplete?: () => void; + onError?: () => void; +} + +export function useDownloadCSV(props: DownloadProps = {}) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + + const download = useCallback( + (campaignId: string, isVac: boolean, privateKey?: string) => { + setLoading(true); + setError(undefined); + + const baseUrl = `/report/campaign/csv/${campaignId}`; + fetch(buildAdServerEndpoint(isVac ? `${baseUrl}/vac` : baseUrl), { + method: "GET", + mode: "cors", + credentials: "include", + headers: { + "Content-Type": "text/csv", + }, + }) + .then((res) => { + if (res.status !== 200) { + const err = "Unable to download CSV"; + setError(err); + throw new Error(err); + } + + return res.blob(); + }) + .then((blob) => { + const file = new Blob([blob], { + type: "text/csv", + endings: "transparent", + }); + + if (isVac) { + return transformConversionEnvelope(file, privateKey); + } + + return Promise.resolve(file); + }) + .then((file) => { + const fileURL = URL.createObjectURL(file); + const link = document.createElement("a"); + link.href = fileURL; + link.setAttribute("download", `${campaignId}.csv`); + document.body.appendChild(link); + link.click(); + if (props.onComplete) props.onComplete(); + }) + .catch((e) => { + setError(e.message); + }) + .finally(() => { + setLoading(false); + }); + }, + [], + ); + + return { download, loading, error }; +} + +type Envelope = { ciphertext: string; epk: string; nonce: string }; +async function transformConversionEnvelope( + blob: Blob, + privateKey?: string, +): Promise { + const te = new TextEncoder(); + const td = new TextDecoder(); + const b64 = (i: string) => te.encode(atob(i)); + + return await new Promise((resolve, reject) => { + const fileReader = new FileReader(); + fileReader.onload = () => { + const text = fileReader.result as string | null; + if (text === null) { + reject(new Error("No file Result")); + return; + } + + try { + Papa.parse(text, { + header: true, + transform(value: string, field: string) { + if (field === "Conversion Envelope" && privateKey) { + const { ciphertext, nonce, epk }: Envelope = JSON.parse(value); + const res = tweetnacl.box.open( + b64(ciphertext), + b64(nonce), + b64(epk), + b64(privateKey), + ); + return res + ? td.decode(res) + : "Data not valid for this private key"; + } + + return value; + }, + complete(results, file) { + console.log("Parsing complete:", results, file); + resolve(blob); + // Papa.unparse() + }, + }); + } catch (e) { + console.log(e); + reject(new Error("Unable to decrypt conversion data")); + } + }; + + fileReader.readAsText(blob); + }); +} diff --git a/src/user/settings/NewKeyPairModal.tsx b/src/user/settings/NewKeyPairModal.tsx index 17a51bb7f..283fefb63 100644 --- a/src/user/settings/NewKeyPairModal.tsx +++ b/src/user/settings/NewKeyPairModal.tsx @@ -8,7 +8,7 @@ import { Typography, } from "@mui/material"; import { useUpdateAdvertiserMutation } from "graphql/advertiser.generated"; -import * as tweetnacl from "tweetnacl"; +import tweetnacl from "tweetnacl"; import { useRef, useState } from "react"; import { IAdvertiser } from "auth/context/auth.interface"; import { CardContainer } from "components/Card/CardContainer";