forked from Shiloh/remnantchat
* Add URL secret reading logic * Add private url share dialog * Salt password hash with roomId * Don't allow incorrect password to be entered Co-authored-by: Jeremy Kahn <jeremyckahn@gmail.com>
This commit is contained in:
parent
d5aa4d7f0b
commit
fcec242194
@ -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<Array<Message | InlineMedia>>(
|
||||
@ -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<any>[] = [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,
|
||||
|
190
src/components/Shell/RoomShareDialog.tsx
Normal file
190
src/components/Shell/RoomShareDialog.tsx
Normal file
@ -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<void>
|
||||
}
|
||||
|
||||
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<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (!passThrottled) copyWithPass()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.isOpen}
|
||||
onClose={handleClose}
|
||||
aria-labelledby="alert-dialog-title"
|
||||
aria-describedby="alert-dialog-description"
|
||||
>
|
||||
<form onSubmit={handleFormSubmit}>
|
||||
{isAdvanced && (
|
||||
<DialogTitle id="alert-dialog-title">
|
||||
Copy URL with password
|
||||
<Button onClick={() => setIsAdvanced(false)}>Simple</Button>
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
onClick={handleClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: 8,
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
)}
|
||||
{isAdvanced && (
|
||||
<DialogContent>
|
||||
<DialogContentText sx={{ mb: 2 }}>
|
||||
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.
|
||||
</DialogContentText>
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
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.
|
||||
</Alert>
|
||||
<Alert severity="warning">
|
||||
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.
|
||||
</Alert>
|
||||
<FormControlLabel
|
||||
label="I understand the risks"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={isUnderstood}
|
||||
onChange={e => setIsUnderstood(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="none"
|
||||
id="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
value={password}
|
||||
disabled={!isUnderstood}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
)}
|
||||
<DialogActions>
|
||||
{isAdvanced ? (
|
||||
<Tooltip title="Copy room URL with password. No password entry required to access room.">
|
||||
<span>
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={copyWithPass}
|
||||
color="error"
|
||||
disabled={
|
||||
password.length === 0 || !isUnderstood || passThrottled
|
||||
}
|
||||
>
|
||||
Copy URL with password
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button onClick={() => setIsAdvanced(true)} color="error">
|
||||
Advanced
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip title="Copy room URL. Password required to access room.">
|
||||
<Button onClick={copyWithoutPass} color="success" autoFocus>
|
||||
Copy URL
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
@ -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<AlertColor>('info')
|
||||
const [title, setTitle] = useState('')
|
||||
const [alertText, setAlertText] = useState('')
|
||||
const [numberOfPeers, setNumberOfPeers] = useState(1)
|
||||
const [roomId, setRoomId] = useState<string | undefined>(undefined)
|
||||
const [password, setPassword] = useState<string | undefined>(undefined)
|
||||
const [isPeerListOpen, setIsPeerListOpen] = useState(false)
|
||||
const [peerList, setPeerList] = useState<Peer[]>([]) // 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 (
|
||||
<ShellContext.Provider value={shellContextValue}>
|
||||
<ThemeProvider theme={theme}>
|
||||
@ -234,6 +259,14 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
|
||||
isOpen={isQRCodeDialogOpen}
|
||||
handleClose={handleQRCodeDialogClose}
|
||||
/>
|
||||
<RoomShareDialog
|
||||
isOpen={isRoomShareDialogOpen}
|
||||
handleClose={handleRoomShareDialogClose}
|
||||
roomId={roomId ?? ''}
|
||||
password={password ?? ''}
|
||||
showAlert={showAlert}
|
||||
copyToClipboard={copyToClipboard}
|
||||
/>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
</ShellContext.Provider>
|
||||
|
@ -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<SetStateAction<MediaStream | null>>
|
||||
@ -18,6 +19,7 @@ interface RoomContextProps {
|
||||
}
|
||||
|
||||
export const RoomContext = createContext<RoomContextProps>({
|
||||
isPrivate: false,
|
||||
isMessageSending: false,
|
||||
selfVideoStream: null,
|
||||
setSelfVideoStream: () => {},
|
||||
|
@ -10,6 +10,10 @@ interface ShellContextProps {
|
||||
setNumberOfPeers: Dispatch<SetStateAction<number>>
|
||||
setTitle: Dispatch<SetStateAction<string>>
|
||||
showAlert: (message: string, options?: AlertOptions) => void
|
||||
roomId?: string
|
||||
setRoomId: Dispatch<SetStateAction<string | undefined>>
|
||||
password?: string
|
||||
setPassword: Dispatch<SetStateAction<string | undefined>>
|
||||
isPeerListOpen: boolean
|
||||
setIsPeerListOpen: Dispatch<SetStateAction<boolean>>
|
||||
peerList: Peer[]
|
||||
@ -29,6 +33,10 @@ export const ShellContext = createContext<ShellContextProps>({
|
||||
setNumberOfPeers: () => {},
|
||||
setTitle: () => {},
|
||||
showAlert: () => {},
|
||||
roomId: undefined,
|
||||
setRoomId: () => {},
|
||||
password: undefined,
|
||||
setPassword: () => {},
|
||||
isPeerListOpen: false,
|
||||
setIsPeerListOpen: () => {},
|
||||
peerList: [],
|
||||
|
@ -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 ? (
|
||||
<PasswordPrompt
|
||||
isOpen={isPasswordEntered}
|
||||
isOpen={awaitingSecret}
|
||||
onPasswordEntered={handlePasswordEntered}
|
||||
/>
|
||||
) : (
|
||||
<Room userId={userId} roomId={roomId} password={password} />
|
||||
<Room userId={userId} roomId={roomId} password={secret} />
|
||||
)
|
||||
}
|
||||
|
@ -14,3 +14,10 @@ export const isRecord = (variable: any): variable is Record<string, any> => {
|
||||
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))
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user