From a600445e1b28ae83a70cefe333cf34546e261a80 Mon Sep 17 00:00:00 2001 From: httpjamesm Date: Wed, 22 Nov 2023 21:18:47 -0500 Subject: [PATCH] feat: cast flow --- apps/cast/src/pages/index.tsx | 101 +++++++++++++++++- apps/cast/src/pages/slideshow.tsx | 5 +- apps/cast/src/services/kexService.ts | 28 +++++ .../photos/public/locales/en/translation.json | 5 +- .../Collections/CollectionInfoWithOptions.tsx | 42 +++++--- .../CollectionOptions/AlbumCastDialog.tsx | 72 +++++++++++++ .../AlbumCollectionOption.tsx | 12 ++- .../Collections/CollectionOptions/index.tsx | 22 +++- apps/photos/src/services/kexService.ts | 28 +++++ 9 files changed, 292 insertions(+), 23 deletions(-) create mode 100644 apps/cast/src/services/kexService.ts create mode 100644 apps/photos/src/components/Collections/CollectionOptions/AlbumCastDialog.tsx create mode 100644 apps/photos/src/services/kexService.ts diff --git a/apps/cast/src/pages/index.tsx b/apps/cast/src/pages/index.tsx index 7fe02e9f7..2df82064d 100644 --- a/apps/cast/src/pages/index.tsx +++ b/apps/cast/src/pages/index.tsx @@ -1,4 +1,8 @@ import { useEffect, useState } from 'react'; +import _sodium from 'libsodium-wrappers'; +import { getKexValue, setKexValue } from 'services/kexService'; +import { boxSealOpen, toB64 } from 'utils/crypto/libsodium'; +import { useRouter } from 'next/router'; const colourPool = [ '#87CEFA', // Light Blue @@ -45,10 +49,105 @@ export default function PairingMode() { const [digits, setDigits] = useState([]); + const [publicKeyB64, setPublicKeyB64] = useState(''); + useEffect(() => { + const interval = setInterval(() => { + init(); + }, 45 * 1000); // the kex API deletes keys every 60s, so we'll regenerate stuff prematurely + + return () => { + clearInterval(interval); + }; + }, []); + + const init = async () => { const data = generateSecureData(4); setDigits(convertDataToHex(data).split('')); - }, []); + + const keypair = await generateKeyPair(); + setPublicKeyB64(await toB64(keypair.publicKey)); + setPrivateKeyB64(await toB64(keypair.privateKey)); + }; + + const [privateKeyB64, setPrivateKeyB64] = useState(''); + + const generateKeyPair = async () => { + await _sodium.ready; + + const keypair = _sodium.crypto_box_keypair(); + + return keypair; + }; + + const pollForCastData = async () => { + // see if we were acknowledged on the client. + // the client will send us the encrypted payload using our public key that we advertised. + // then, we can decrypt this and store all the necessary info locally so we can play the collection slideshow. + let devicePayload = ''; + try { + devicePayload = await getKexValue(`${digits.join('')}_payload`); + } catch (e) { + return; + } + + const decryptedPayload = await boxSealOpen( + devicePayload, + publicKeyB64, + privateKeyB64 + ); + + const decryptedPayloadObj = JSON.parse(decryptedPayload); + + return decryptedPayloadObj; + }; + + const storePayloadLocally = (payloadObj: Object) => { + // iterate through all the keys in the payload object and set them in localStorage. + // if the key is "encryptionKey", store it in session storage instead. + for (const key in payloadObj) { + if (key === 'encryptionKey') { + window.sessionStorage.setItem(key, payloadObj[key]); + } else { + window.localStorage.setItem(key, payloadObj[key]); + } + } + }; + + const advertisePublicKey = async (publicKeyB64: string) => { + // hey client, we exist! + try { + await setKexValue(`${digits.join('')}_pubkey`, publicKeyB64); + } catch (e) { + return; + } + }; + + const router = useRouter(); + + useEffect(() => { + if (digits.length < 1 || !publicKeyB64 || !privateKeyB64) return; + + const interval = setInterval(async () => { + const data = await pollForCastData(); + + if (!data) return; + + storePayloadLocally(data); + + router.push('/slideshow'); + }, 1000); + + return () => { + clearInterval(interval); + }; + }, [digits, publicKeyB64, privateKeyB64]); + + useEffect(() => { + if (!publicKeyB64) return; + + advertisePublicKey(publicKeyB64); + }, [publicKeyB64]); return ( <> diff --git a/apps/cast/src/pages/slideshow.tsx b/apps/cast/src/pages/slideshow.tsx index 1917a3120..5d01aab4b 100644 --- a/apps/cast/src/pages/slideshow.tsx +++ b/apps/cast/src/pages/slideshow.tsx @@ -15,8 +15,9 @@ export default function Slideshow() { const init = async () => { const collections = await syncCollections(); - // get requested collection id from fragment (this is temporary and will be changed during cast) - const requestedCollectionID = window.location.hash.slice(1); + // get requested collection id from localStorage + const requestedCollectionID = + window.localStorage.getItem('targetCollectionId'); const files = await syncFiles('normal', collections, () => {}); diff --git a/apps/cast/src/services/kexService.ts b/apps/cast/src/services/kexService.ts new file mode 100644 index 000000000..64c1971d5 --- /dev/null +++ b/apps/cast/src/services/kexService.ts @@ -0,0 +1,28 @@ +import { logError } from 'utils/sentry'; +import HTTPService from './HTTPService'; + +export const getKexValue = async (key: string) => { + let resp; + try { + resp = await HTTPService.get(`/kex/get`, { + identifier: key, + }); + } catch (e) { + logError(e, 'failed to get kex value'); + throw e; + } + + return resp.data.wrappedKey; +}; + +export const setKexValue = async (key: string, value: string) => { + try { + await HTTPService.put('/kex/add', { + customIdentifier: key, + wrappedKey: value, + }); + } catch (e) { + logError(e, 'failed to set kex value'); + throw e; + } +}; diff --git a/apps/photos/public/locales/en/translation.json b/apps/photos/public/locales/en/translation.json index f24b50748..78b2555e4 100644 --- a/apps/photos/public/locales/en/translation.json +++ b/apps/photos/public/locales/en/translation.json @@ -620,5 +620,8 @@ "FASTER_UPLOAD":"Faster uploads", "FASTER_UPLOAD_DESCRIPTION":"Route uploads through nearby servers", "STATUS": "Status", - "INDEXED_ITEMS": "Indexed items" + "INDEXED_ITEMS": "Indexed items", + "CAST_ALBUM_TO_TV": "Play album on TV", + "ENTER_CAST_PIN_CODE": "Enter the code you see on the TV below to pair this device.", + "PAIR_DEVICE_TO_TV": "Pair devices" } diff --git a/apps/photos/src/components/Collections/CollectionInfoWithOptions.tsx b/apps/photos/src/components/Collections/CollectionInfoWithOptions.tsx index 6921ee5bf..dabf9d140 100644 --- a/apps/photos/src/components/Collections/CollectionInfoWithOptions.tsx +++ b/apps/photos/src/components/Collections/CollectionInfoWithOptions.tsx @@ -1,5 +1,5 @@ import { CollectionInfo } from './CollectionInfo'; -import React from 'react'; +import React, { useState } from 'react'; import { Collection, CollectionSummary } from 'types/collection'; import CollectionOptions from 'components/Collections/CollectionOptions'; import { SetCollectionNamerAttributes } from 'components/Collections/CollectionNamer'; @@ -12,6 +12,7 @@ import ArchiveOutlined from '@mui/icons-material/ArchiveOutlined'; import PeopleIcon from '@mui/icons-material/People'; import LinkIcon from '@mui/icons-material/Link'; import { SetCollectionDownloadProgressAttributes } from 'types/gallery'; +import AlbumCastDialog from './CollectionOptions/AlbumCastDialog'; interface Iprops { activeCollection: Collection; @@ -52,21 +53,32 @@ export default function CollectionInfoWithOptions({ return <>; } }; + + const [showAlbumCastDialog, setShowAlbumCastDialog] = useState(true); + return ( - - - } - /> - {shouldShowOptions(type) && ( - + + + } /> - )} - - + {shouldShowOptions(type) && ( + + )} + + + setShowAlbumCastDialog(false)} + /> + ); } diff --git a/apps/photos/src/components/Collections/CollectionOptions/AlbumCastDialog.tsx b/apps/photos/src/components/Collections/CollectionOptions/AlbumCastDialog.tsx new file mode 100644 index 000000000..4dd9d3450 --- /dev/null +++ b/apps/photos/src/components/Collections/CollectionOptions/AlbumCastDialog.tsx @@ -0,0 +1,72 @@ +import { Typography } from '@mui/material'; +import DialogBoxV2 from 'components/DialogBoxV2'; +import SingleInputForm, { + SingleInputFormProps, +} from 'components/SingleInputForm'; +import { t } from 'i18next'; +import { getKexValue, setKexValue } from 'services/kexService'; +import { boxSeal } from 'utils/crypto/libsodium'; +import { SESSION_KEYS, getKey } from 'utils/storage/sessionStorage'; + +interface Props { + show: boolean; + onHide: () => void; + currentCollectionId: number; +} + +export default function AlbumCastDialog(props: Props) { + const onSubmit: SingleInputFormProps['callback'] = async ( + value, + setFieldError + ) => { + try { + await doCast(value); + props.onHide(); + } catch (e) { + setFieldError(t('UNKNOWN_ERROR')); + } + }; + + const doCast = async (pin: string) => { + // does the TV exist? have they advertised their existence? + const tvPublicKeyKexKey = `${pin}_pubkey`; + + const tvPublicKeyB64 = await getKexValue(tvPublicKeyKexKey); + if (!tvPublicKeyB64) { + throw new Error('Failed to get TV public key'); + } + + // ok, they exist. let's give them the good stuff. + const payload = JSON.stringify({ + ...window.localStorage, + sessionKey: getKey(SESSION_KEYS.ENCRYPTION_KEY), + targetCollectionId: props.currentCollectionId, + }); + + const encryptedPayload = await boxSeal(payload, tvPublicKeyB64); + + const encryptedPayloadForTvKexKey = `${pin}_payload`; + + // hey TV, we acknowlege you! + await setKexValue(encryptedPayloadForTvKexKey, encryptedPayload); + }; + + return ( + + {t('ENTER_CAST_PIN_CODE')} + + + ); +} diff --git a/apps/photos/src/components/Collections/CollectionOptions/AlbumCollectionOption.tsx b/apps/photos/src/components/Collections/CollectionOptions/AlbumCollectionOption.tsx index d426c66b8..c06066b0f 100644 --- a/apps/photos/src/components/Collections/CollectionOptions/AlbumCollectionOption.tsx +++ b/apps/photos/src/components/Collections/CollectionOptions/AlbumCollectionOption.tsx @@ -1,5 +1,4 @@ import { OverflowMenuOption } from 'components/OverflowMenu/option'; -import React from 'react'; import EditIcon from '@mui/icons-material/Edit'; import PeopleIcon from '@mui/icons-material/People'; @@ -13,6 +12,7 @@ import PushPinOutlined from '@mui/icons-material/PushPinOutlined'; import { UnPinIcon } from 'components/icons/UnPinIcon'; import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined'; import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined'; +import TvIcon from '@mui/icons-material/Tv'; interface Iprops { isArchived: boolean; @@ -123,6 +123,16 @@ export function AlbumCollectionOption({ startIcon={}> {t('SHARE_COLLECTION')} + } + onClick={() => { + handleCollectionAction( + CollectionActions.SHOW_ALBUM_CAST_DIALOG, + false + ); + }}> + {t('CAST_ALBUM_TO_TV')} + ); } diff --git a/apps/photos/src/components/Collections/CollectionOptions/index.tsx b/apps/photos/src/components/Collections/CollectionOptions/index.tsx index acd9f1644..27ad5bf44 100644 --- a/apps/photos/src/components/Collections/CollectionOptions/index.tsx +++ b/apps/photos/src/components/Collections/CollectionOptions/index.tsx @@ -1,5 +1,11 @@ import { AlbumCollectionOption } from './AlbumCollectionOption'; -import React, { useContext, useRef, useState } from 'react'; +import React, { + Dispatch, + SetStateAction, + useContext, + useRef, + useState, +} from 'react'; import * as CollectionAPI from 'services/collectionService'; import * as TrashService from 'services/trashService'; import { @@ -45,6 +51,7 @@ interface CollectionOptionsProps { collectionSummaryType: CollectionSummaryType; showCollectionShareModal: () => void; setActiveCollectionID: (collectionID: number) => void; + setShowAlbumCastDialog: Dispatch>; } export enum CollectionActions { @@ -67,6 +74,7 @@ export enum CollectionActions { UNPIN, HIDE, UNHIDE, + SHOW_ALBUM_CAST_DIALOG, } const CollectionOptions = (props: CollectionOptionsProps) => { @@ -78,6 +86,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => { showCollectionShareModal, setCollectionDownloadProgressAttributesCreator, isActiveCollectionDownloadInProgress, + setShowAlbumCastDialog, } = props; const { startLoading, finishLoading, setDialogMessage } = @@ -98,7 +107,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => { action: CollectionActions, loader = true ) => { - let callback; + let callback: Function; switch (action) { case CollectionActions.SHOW_RENAME_DIALOG: callback = showRenameCollectionModal; @@ -157,6 +166,9 @@ const CollectionOptions = (props: CollectionOptionsProps) => { case CollectionActions.UNHIDE: callback = unHideAlbum; break; + case CollectionActions.SHOW_ALBUM_CAST_DIALOG: + callback = showCastAlbumDialog; + break; default: logError( @@ -167,7 +179,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => { action; } } - return async (...args) => { + return async (...args: any) => { try { loader && startLoading(); await callback(...args); @@ -185,6 +197,10 @@ const CollectionOptions = (props: CollectionOptionsProps) => { }; }; + const showCastAlbumDialog = async () => { + setShowAlbumCastDialog(true); + }; + const renameCollection = async (newName: string) => { if (activeCollection.name !== newName) { await CollectionAPI.renameCollection(activeCollection, newName); diff --git a/apps/photos/src/services/kexService.ts b/apps/photos/src/services/kexService.ts new file mode 100644 index 000000000..64c1971d5 --- /dev/null +++ b/apps/photos/src/services/kexService.ts @@ -0,0 +1,28 @@ +import { logError } from 'utils/sentry'; +import HTTPService from './HTTPService'; + +export const getKexValue = async (key: string) => { + let resp; + try { + resp = await HTTPService.get(`/kex/get`, { + identifier: key, + }); + } catch (e) { + logError(e, 'failed to get kex value'); + throw e; + } + + return resp.data.wrappedKey; +}; + +export const setKexValue = async (key: string, value: string) => { + try { + await HTTPService.put('/kex/add', { + customIdentifier: key, + wrappedKey: value, + }); + } catch (e) { + logError(e, 'failed to set kex value'); + throw e; + } +};