diff --git a/src/components/Room/useRoom.ts b/src/components/Room/useRoom.ts index ae119de..d7d45b0 100644 --- a/src/components/Room/useRoom.ts +++ b/src/components/Room/useRoom.ts @@ -40,7 +40,7 @@ export function useRoom( { password, ...roomConfig }: BaseRoomConfig & TorrentRoomConfig, { roomId, userId, getUuid = uuid }: UseRoomConfig ) { - const isPublicRoom = !password + const isPrivate = password !== undefined const [peerRoom] = useState( () => new PeerRoom({ password: password ?? roomId, ...roomConfig }, roomId) @@ -54,8 +54,11 @@ export function useRoom( setPeerList, tabHasFocus, showAlert, + setRoomId, + setPassword, setIsPeerListOpen, } = useContext(ShellContext) + const settingsContext = useContext(SettingsContext) const [isMessageSending, setIsMessageSending] = useState(false) const [messageLog, _setMessageLog] = useState>( @@ -105,6 +108,7 @@ export function useRoom( const roomContextValue = useMemo( () => ({ + isPrivate, isMessageSending, selfVideoStream, setSelfVideoStream, @@ -118,6 +122,7 @@ export function useRoom( setPeerOfferedFileMetadata, }), [ + isPrivate, isMessageSending, selfVideoStream, setSelfVideoStream, @@ -139,6 +144,22 @@ export function useRoom( } }, [peerRoom, setIsPeerListOpen]) + useEffect(() => { + setPassword(password) + + return () => { + setPassword(undefined) + } + }, [password, setPassword]) + + useEffect(() => { + setRoomId(roomId) + + return () => { + setRoomId(undefined) + } + }, [roomId, setRoomId]) + useEffect(() => { setDoShowPeers(true) @@ -239,7 +260,7 @@ export function useRoom( try { const promises: Promise[] = [sendPeerId(userId, peerId)] - if (isPublicRoom) { + if (!isPrivate) { promises.push( sendMessageTranscript(messageLog.filter(isMessageReceived), peerId) ) @@ -322,6 +343,7 @@ export function useRoom( }) return { + isPrivate, handleInlineMediaUpload, isMessageSending, messageLog, diff --git a/src/components/Shell/RoomShareDialog.tsx b/src/components/Shell/RoomShareDialog.tsx new file mode 100644 index 0000000..238ddb9 --- /dev/null +++ b/src/components/Shell/RoomShareDialog.tsx @@ -0,0 +1,190 @@ +import { + Alert, + AlertColor, + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + FormControlLabel, + TextField, + Tooltip, +} from '@mui/material' +import CloseIcon from '@mui/icons-material/Close' + +import { AlertOptions } from 'models/shell' +import { useEffect, useState, SyntheticEvent } from 'react' +import { encodePassword, sleep } from 'utils' + +export interface RoomShareDialogProps { + isOpen: boolean + handleClose: () => void + roomId: string + password: string + showAlert: (message: string, options?: AlertOptions) => void + copyToClipboard: ( + content: string, + alert: string, + severity: AlertColor + ) => Promise +} + +export function RoomShareDialog(props: RoomShareDialogProps) { + const [isAdvanced, setIsAdvanced] = useState(false) + const [isUnderstood, setIsUnderstood] = useState(false) + const [password, setPassword] = useState('') + const [passThrottled, setPassThrottled] = useState(false) + const handleClose = () => { + props.handleClose() + setPassword('') + } + + useEffect(() => { + if (!isAdvanced) setIsUnderstood(false) + }, [isAdvanced]) + + useEffect(() => { + if (!isUnderstood) setPassword('') + }, [isUnderstood]) + + const url = window.location.href.split('#')[0] + + const copyWithPass = async () => { + const encoded = await encodePassword(props.roomId, password) + if (encoded === props.password) { + const params = new URLSearchParams() + params.set('secret', props.password) + await props.copyToClipboard( + `${url}#${params}`, + 'Private room URL with password copied to clipboard', + 'warning' + ) + handleClose() + } else { + setPassThrottled(true) + props.showAlert('Incorrect password entered. Please wait 2s to retry.', { + severity: 'error', + }) + await sleep(2000) + setPassThrottled(false) + } + } + + const copyWithoutPass = async () => { + await props.copyToClipboard( + url, + isAdvanced + ? 'Private room URL without password copied to clipboard' + : 'Current URL copied to clipboard', + 'success' + ) + handleClose() + } + + const handleFormSubmit = (event: SyntheticEvent) => { + event.preventDefault() + if (!passThrottled) copyWithPass() + } + + return ( + +
+ {isAdvanced && ( + + Copy URL with password + + + + + + )} + {isAdvanced && ( + + + Copy URL to this private room containing an indecipherable hash of + the password. When using this URL, users will not need to enter + the password themselves. + + + Be careful where and how this URL is shared. Anybody who obtains + it can enter the room. The sharing medium must be trusted, as well + as all potential recipients of the URL, just as if you were + sharing the password itself. + + + By design, the password hash does not leave the web browser when + this URL is used to access the room. However, web browsers can + still independently record the full URL in the address history, + and may even store the history in the cloud if configured to do + so. + + setIsUnderstood(e.target.checked)} + /> + } + /> + setPassword(e.target.value)} + /> + + )} + + {isAdvanced ? ( + + + + + + ) : ( + + )} + + + + +
+
+ ) +} diff --git a/src/components/Shell/Shell.tsx b/src/components/Shell/Shell.tsx index bf49346..fd2b4e6 100644 --- a/src/components/Shell/Shell.tsx +++ b/src/components/Shell/Shell.tsx @@ -25,6 +25,7 @@ import { NotificationArea } from './NotificationArea' import { RouteContent } from './RouteContent' import { PeerList } from './PeerList' import { QRCodeDialog } from './QRCodeDialog' +import { RoomShareDialog } from './RoomShareDialog' export interface ShellProps extends PropsWithChildren { userPeerId: string @@ -36,11 +37,14 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { const [isAlertShowing, setIsAlertShowing] = useState(false) const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [isQRCodeDialogOpen, setIsQRCodeDialogOpen] = useState(false) + const [isRoomShareDialogOpen, setIsRoomShareDialogOpen] = useState(false) const [doShowPeers, setDoShowPeers] = useState(false) const [alertSeverity, setAlertSeverity] = useState('info') const [title, setTitle] = useState('') const [alertText, setAlertText] = useState('') const [numberOfPeers, setNumberOfPeers] = useState(1) + const [roomId, setRoomId] = useState(undefined) + const [password, setPassword] = useState(undefined) const [isPeerListOpen, setIsPeerListOpen] = useState(false) const [peerList, setPeerList] = useState([]) // except you const [tabHasFocus, setTabHasFocus] = useState(true) @@ -67,6 +71,10 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { showAlert, isPeerListOpen, setIsQRCodeDialogOpen, + roomId, + setRoomId, + password, + setPassword, setIsPeerListOpen, peerList, setPeerList, @@ -80,6 +88,10 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { [ isPeerListOpen, setIsQRCodeDialogOpen, + roomId, + setRoomId, + password, + setPassword, numberOfPeers, peerList, tabHasFocus, @@ -146,12 +158,21 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { setIsPeerListOpen(!isPeerListOpen) } - const handleLinkButtonClick = async () => { - await navigator.clipboard.writeText(window.location.href) + const copyToClipboard = async ( + content: string, + alert: string, + severity: AlertColor = 'success' + ) => { + await navigator.clipboard.writeText(content) + shellContextValue.showAlert(alert, { severity }) + } - shellContextValue.showAlert('Current URL copied to clipboard', { - severity: 'success', - }) + const handleLinkButtonClick = async () => { + if (roomId !== undefined && password !== undefined) { + setIsRoomShareDialogOpen(true) + } else { + copyToClipboard(window.location.href, 'Current URL copied to clipboard') + } } const handleDrawerClose = () => { @@ -178,6 +199,10 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { setIsQRCodeDialogOpen(false) } + const handleRoomShareDialogClose = () => { + setIsRoomShareDialogOpen(false) + } + return ( @@ -234,6 +259,14 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { isOpen={isQRCodeDialogOpen} handleClose={handleQRCodeDialogClose} /> + diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index 37c4e18..921f07a 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -2,6 +2,7 @@ import { createContext, Dispatch, SetStateAction } from 'react' import { FileOfferMetadata } from 'models/chat' interface RoomContextProps { + isPrivate: boolean isMessageSending: boolean selfVideoStream: MediaStream | null setSelfVideoStream: Dispatch> @@ -18,6 +19,7 @@ interface RoomContextProps { } export const RoomContext = createContext({ + isPrivate: false, isMessageSending: false, selfVideoStream: null, setSelfVideoStream: () => {}, diff --git a/src/contexts/ShellContext.ts b/src/contexts/ShellContext.ts index da6fd11..c77d7f9 100644 --- a/src/contexts/ShellContext.ts +++ b/src/contexts/ShellContext.ts @@ -10,6 +10,10 @@ interface ShellContextProps { setNumberOfPeers: Dispatch> setTitle: Dispatch> showAlert: (message: string, options?: AlertOptions) => void + roomId?: string + setRoomId: Dispatch> + password?: string + setPassword: Dispatch> isPeerListOpen: boolean setIsPeerListOpen: Dispatch> peerList: Peer[] @@ -29,6 +33,10 @@ export const ShellContext = createContext({ setNumberOfPeers: () => {}, setTitle: () => {}, showAlert: () => {}, + roomId: undefined, + setRoomId: () => {}, + password: undefined, + setPassword: () => {}, isPeerListOpen: false, setIsPeerListOpen: () => {}, peerList: [], diff --git a/src/pages/PrivateRoom/PrivateRoom.tsx b/src/pages/PrivateRoom/PrivateRoom.tsx index a47a22d..1fc3cc9 100644 --- a/src/pages/PrivateRoom/PrivateRoom.tsx +++ b/src/pages/PrivateRoom/PrivateRoom.tsx @@ -5,6 +5,7 @@ import { useParams } from 'react-router-dom' import { ShellContext } from 'contexts/ShellContext' import { NotificationService } from 'services/Notification' import { PasswordPrompt } from 'components/PasswordPrompt/PasswordPrompt' +import { encodePassword } from 'utils' interface PublicRoomProps { userId: string @@ -13,7 +14,12 @@ interface PublicRoomProps { export function PrivateRoom({ userId }: PublicRoomProps) { const { roomId = '' } = useParams() const { setTitle } = useContext(ShellContext) - const [password, setPassword] = useState('') + + const urlParams = new URLSearchParams(window.location.hash.substring(1)) + // Clear secret from address bar + if (window.location.hash.length > 0) + window.history.replaceState(window.history.state, '', '#') + const [secret, setSecret] = useState(urlParams.get('secret') ?? '') useEffect(() => { NotificationService.requestPermission() @@ -23,18 +29,21 @@ export function PrivateRoom({ userId }: PublicRoomProps) { setTitle(`Room: ${roomId}`) }, [roomId, setTitle]) - const handlePasswordEntered = (password: string) => { - setPassword(password) + const handlePasswordEntered = async (password: string) => { + if (password.length !== 0) setSecret(await encodePassword(roomId, password)) } - const isPasswordEntered = password.length === 0 + if (urlParams.has('pwd') && !urlParams.has('secret')) + handlePasswordEntered(urlParams.get('pwd') ?? '') - return isPasswordEntered ? ( + const awaitingSecret = secret.length === 0 + + return awaitingSecret ? ( ) : ( - + ) } diff --git a/src/utils.ts b/src/utils.ts index 9c92d2f..d97832b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -14,3 +14,10 @@ export const isRecord = (variable: any): variable is Record => { export const isError = (e: any): e is Error => { return e instanceof Error } + +export const encodePassword = async (roomId: string, password: string) => { + const data = new TextEncoder().encode(`${roomId}_${password}`) + const digest = await window.crypto.subtle.digest('SHA-256', data) + const bytes = new Uint8Array(digest) + return window.btoa(String.fromCharCode(...bytes)) +}