feat: cast flow

This commit is contained in:
httpjamesm 2023-11-22 21:18:47 -05:00
parent 92a29ec096
commit a600445e1b
No known key found for this signature in database
9 changed files with 292 additions and 23 deletions

View file

@ -1,4 +1,8 @@
import { useEffect, useState } from 'react'; 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 = [ const colourPool = [
'#87CEFA', // Light Blue '#87CEFA', // Light Blue
@ -45,10 +49,105 @@ export default function PairingMode() {
const [digits, setDigits] = useState<string[]>([]); const [digits, setDigits] = useState<string[]>([]);
const [publicKeyB64, setPublicKeyB64] = useState('');
useEffect(() => { 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); const data = generateSecureData(4);
setDigits(convertDataToHex(data).split('')); 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 ( return (
<> <>

View file

@ -15,8 +15,9 @@ export default function Slideshow() {
const init = async () => { const init = async () => {
const collections = await syncCollections(); const collections = await syncCollections();
// get requested collection id from fragment (this is temporary and will be changed during cast) // get requested collection id from localStorage
const requestedCollectionID = window.location.hash.slice(1); const requestedCollectionID =
window.localStorage.getItem('targetCollectionId');
const files = await syncFiles('normal', collections, () => {}); const files = await syncFiles('normal', collections, () => {});

View file

@ -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;
}
};

View file

@ -620,5 +620,8 @@
"FASTER_UPLOAD":"Faster uploads", "FASTER_UPLOAD":"Faster uploads",
"FASTER_UPLOAD_DESCRIPTION":"Route uploads through nearby servers", "FASTER_UPLOAD_DESCRIPTION":"Route uploads through nearby servers",
"STATUS": "Status", "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"
} }

View file

@ -1,5 +1,5 @@
import { CollectionInfo } from './CollectionInfo'; import { CollectionInfo } from './CollectionInfo';
import React from 'react'; import React, { useState } from 'react';
import { Collection, CollectionSummary } from 'types/collection'; import { Collection, CollectionSummary } from 'types/collection';
import CollectionOptions from 'components/Collections/CollectionOptions'; import CollectionOptions from 'components/Collections/CollectionOptions';
import { SetCollectionNamerAttributes } from 'components/Collections/CollectionNamer'; 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 PeopleIcon from '@mui/icons-material/People';
import LinkIcon from '@mui/icons-material/Link'; import LinkIcon from '@mui/icons-material/Link';
import { SetCollectionDownloadProgressAttributes } from 'types/gallery'; import { SetCollectionDownloadProgressAttributes } from 'types/gallery';
import AlbumCastDialog from './CollectionOptions/AlbumCastDialog';
interface Iprops { interface Iprops {
activeCollection: Collection; activeCollection: Collection;
@ -52,7 +53,11 @@ export default function CollectionInfoWithOptions({
return <></>; return <></>;
} }
}; };
const [showAlbumCastDialog, setShowAlbumCastDialog] = useState(true);
return ( return (
<>
<CollectionInfoBarWrapper> <CollectionInfoBarWrapper>
<SpaceBetweenFlex> <SpaceBetweenFlex>
<CollectionInfo <CollectionInfo
@ -63,10 +68,17 @@ export default function CollectionInfoWithOptions({
{shouldShowOptions(type) && ( {shouldShowOptions(type) && (
<CollectionOptions <CollectionOptions
{...props} {...props}
setShowAlbumCastDialog={setShowAlbumCastDialog}
collectionSummaryType={type} collectionSummaryType={type}
/> />
)} )}
</SpaceBetweenFlex> </SpaceBetweenFlex>
</CollectionInfoBarWrapper> </CollectionInfoBarWrapper>
<AlbumCastDialog
currentCollectionId={props.activeCollection.id}
show={showAlbumCastDialog}
onHide={() => setShowAlbumCastDialog(false)}
/>
</>
); );
} }

View file

@ -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 (
<DialogBoxV2
sx={{ zIndex: 1600 }}
open={props.show}
onClose={props.onHide}
attributes={{
title: t('CAST_ALBUM_TO_TV'),
}}>
<Typography>{t('ENTER_CAST_PIN_CODE')}</Typography>
<SingleInputForm
callback={onSubmit}
fieldType="text"
placeholder={'123456'}
buttonText={t('PAIR_DEVICE_TO_TV')}
submitButtonProps={{ sx: { mt: 1, mb: 2 } }}
/>
</DialogBoxV2>
);
}

View file

@ -1,5 +1,4 @@
import { OverflowMenuOption } from 'components/OverflowMenu/option'; import { OverflowMenuOption } from 'components/OverflowMenu/option';
import React from 'react';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import PeopleIcon from '@mui/icons-material/People'; 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 { UnPinIcon } from 'components/icons/UnPinIcon';
import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined'; import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined';
import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined'; import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined';
import TvIcon from '@mui/icons-material/Tv';
interface Iprops { interface Iprops {
isArchived: boolean; isArchived: boolean;
@ -123,6 +123,16 @@ export function AlbumCollectionOption({
startIcon={<PeopleIcon />}> startIcon={<PeopleIcon />}>
{t('SHARE_COLLECTION')} {t('SHARE_COLLECTION')}
</OverflowMenuOption> </OverflowMenuOption>
<OverflowMenuOption
startIcon={<TvIcon />}
onClick={() => {
handleCollectionAction(
CollectionActions.SHOW_ALBUM_CAST_DIALOG,
false
);
}}>
{t('CAST_ALBUM_TO_TV')}
</OverflowMenuOption>
</> </>
); );
} }

View file

@ -1,5 +1,11 @@
import { AlbumCollectionOption } from './AlbumCollectionOption'; 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 CollectionAPI from 'services/collectionService';
import * as TrashService from 'services/trashService'; import * as TrashService from 'services/trashService';
import { import {
@ -45,6 +51,7 @@ interface CollectionOptionsProps {
collectionSummaryType: CollectionSummaryType; collectionSummaryType: CollectionSummaryType;
showCollectionShareModal: () => void; showCollectionShareModal: () => void;
setActiveCollectionID: (collectionID: number) => void; setActiveCollectionID: (collectionID: number) => void;
setShowAlbumCastDialog: Dispatch<SetStateAction<boolean>>;
} }
export enum CollectionActions { export enum CollectionActions {
@ -67,6 +74,7 @@ export enum CollectionActions {
UNPIN, UNPIN,
HIDE, HIDE,
UNHIDE, UNHIDE,
SHOW_ALBUM_CAST_DIALOG,
} }
const CollectionOptions = (props: CollectionOptionsProps) => { const CollectionOptions = (props: CollectionOptionsProps) => {
@ -78,6 +86,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
showCollectionShareModal, showCollectionShareModal,
setCollectionDownloadProgressAttributesCreator, setCollectionDownloadProgressAttributesCreator,
isActiveCollectionDownloadInProgress, isActiveCollectionDownloadInProgress,
setShowAlbumCastDialog,
} = props; } = props;
const { startLoading, finishLoading, setDialogMessage } = const { startLoading, finishLoading, setDialogMessage } =
@ -98,7 +107,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
action: CollectionActions, action: CollectionActions,
loader = true loader = true
) => { ) => {
let callback; let callback: Function;
switch (action) { switch (action) {
case CollectionActions.SHOW_RENAME_DIALOG: case CollectionActions.SHOW_RENAME_DIALOG:
callback = showRenameCollectionModal; callback = showRenameCollectionModal;
@ -157,6 +166,9 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
case CollectionActions.UNHIDE: case CollectionActions.UNHIDE:
callback = unHideAlbum; callback = unHideAlbum;
break; break;
case CollectionActions.SHOW_ALBUM_CAST_DIALOG:
callback = showCastAlbumDialog;
break;
default: default:
logError( logError(
@ -167,7 +179,7 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
action; action;
} }
} }
return async (...args) => { return async (...args: any) => {
try { try {
loader && startLoading(); loader && startLoading();
await callback(...args); await callback(...args);
@ -185,6 +197,10 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
}; };
}; };
const showCastAlbumDialog = async () => {
setShowAlbumCastDialog(true);
};
const renameCollection = async (newName: string) => { const renameCollection = async (newName: string) => {
if (activeCollection.name !== newName) { if (activeCollection.name !== newName) {
await CollectionAPI.renameCollection(activeCollection, newName); await CollectionAPI.renameCollection(activeCollection, newName);

View file

@ -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;
}
};