diff --git a/web/src/lib/attachmentAdapter.ts b/web/src/lib/attachmentAdapter.ts index 5e3fd325..57c28097 100644 --- a/web/src/lib/attachmentAdapter.ts +++ b/web/src/lib/attachmentAdapter.ts @@ -2,6 +2,7 @@ import type { AttachmentAdapter, PendingAttachment, CompleteAttachment, Attachme import type { ApiClient } from '@/api/client' import type { AttachmentMetadata } from '@/types/api' import { isImageMimeType } from '@/lib/fileAttachments' +import { randomId } from '@/lib/randomId' const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 const MAX_PREVIEW_BYTES = 5 * 1024 * 1024 @@ -27,7 +28,7 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta accept: '*/*', async *add({ file }): AsyncGenerator { - const id = crypto.randomUUID() + const id = randomId() const contentType = file.type || 'application/octet-stream' yield { diff --git a/web/src/lib/fileAttachments.ts b/web/src/lib/fileAttachments.ts index 4b30b924..08819424 100644 --- a/web/src/lib/fileAttachments.ts +++ b/web/src/lib/fileAttachments.ts @@ -1,4 +1,5 @@ import type { UploadFileResponse } from '@/types/api' +import { randomId } from '@/lib/randomId' export type FileAttachment = { id: string @@ -12,7 +13,7 @@ export type UploadFunction = (file: File) => Promise export function createFileAttachment(file: File): FileAttachment { return { - id: crypto.randomUUID(), + id: randomId(), file, status: 'uploading' } diff --git a/web/src/lib/messages.ts b/web/src/lib/messages.ts index 93e5bcbf..aaee5522 100644 --- a/web/src/lib/messages.ts +++ b/web/src/lib/messages.ts @@ -1,11 +1,9 @@ import type { InfiniteData } from '@tanstack/react-query' import type { DecryptedMessage, MessagesResponse } from '@/types/api' +import { randomId } from '@/lib/randomId' export function makeClientSideId(prefix: string): string { - if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { - return `${prefix}-${crypto.randomUUID()}` - } - return `${prefix}-${Date.now()}-${Math.random()}` + return `${prefix}-${randomId()}` } export function isUserMessage(msg: DecryptedMessage): boolean { diff --git a/web/src/lib/randomId.test.ts b/web/src/lib/randomId.test.ts new file mode 100644 index 00000000..37411a21 --- /dev/null +++ b/web/src/lib/randomId.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { randomId } from './randomId' + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +describe('randomId', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses crypto.randomUUID when available (secure context)', () => { + const randomUUID = vi.fn(() => '00000000-0000-4000-8000-000000000000') + vi.stubGlobal('crypto', { randomUUID, getRandomValues: vi.fn() }) + + const id = randomId() + + expect(randomUUID).toHaveBeenCalledOnce() + expect(id).toBe('00000000-0000-4000-8000-000000000000') + }) + + it('falls back to getRandomValues when randomUUID is missing (non-secure context)', () => { + const getRandomValues = vi.fn((bytes: Uint8Array) => { + for (let i = 0; i < bytes.length; i++) bytes[i] = i + return bytes + }) + vi.stubGlobal('crypto', { getRandomValues }) + + const id = randomId() + + expect(getRandomValues).toHaveBeenCalledOnce() + expect(id).toMatch(UUID_V4) + // Version 4 bit: byte[6] should be 0x4_ after masking (input 0x06 → 0x46) + expect(id.charAt(14)).toBe('4') + // Variant bit: byte[8] high two bits should be 10 (input 0x08 → 0x88) + expect('89ab'.includes(id.charAt(19).toLowerCase())).toBe(true) + }) + + it('produces unique ids across multiple getRandomValues calls', () => { + let counter = 0 + const getRandomValues = vi.fn((bytes: Uint8Array) => { + for (let i = 0; i < bytes.length; i++) bytes[i] = (counter + i) & 0xff + counter += bytes.length + return bytes + }) + vi.stubGlobal('crypto', { getRandomValues }) + + const ids = new Set([randomId(), randomId(), randomId()]) + expect(ids.size).toBe(3) + for (const id of ids) expect(id).toMatch(UUID_V4) + }) + + it('falls back to a Date/Math.random string when crypto is unavailable', () => { + vi.stubGlobal('crypto', undefined) + + const id = randomId() + + expect(typeof id).toBe('string') + expect(id.length).toBeGreaterThan(0) + // Not UUID v4 format; just needs to be non-empty and unique enough + expect(id).not.toMatch(UUID_V4) + }) +}) diff --git a/web/src/lib/randomId.ts b/web/src/lib/randomId.ts new file mode 100644 index 00000000..d71e71b3 --- /dev/null +++ b/web/src/lib/randomId.ts @@ -0,0 +1,36 @@ +/** + * Generates a random ID string that works in both secure and non-secure contexts. + * + * crypto.randomUUID() is only available in secure contexts (HTTPS or localhost). + * When accessed over HTTP on a LAN IP, it throws TypeError, breaking file + * attachment and other ID-generation paths. + * + * Fallback chain: + * 1. crypto.randomUUID() — secure context (HTTPS / localhost) + * 2. crypto.getRandomValues() — available in non-secure contexts on modern browsers + * 3. Date.now() + Math.random() — last resort for very old environments + * + * All paths return a UUID v4-format string or a similarly unique string, + * maintaining compatibility with existing ID consumers (DB, SSE, RPC payloads). + */ +export function randomId(): string { + const c = globalThis.crypto + + if (typeof c?.randomUUID === 'function') { + return c.randomUUID() + } + + if (typeof c?.getRandomValues === 'function') { + const bytes = new Uint8Array(16) + c.getRandomValues(bytes) + // Set version 4 bits + bytes[6] = (bytes[6] & 0x0f) | 0x40 + // Set variant bits + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` + } + + // Fallback for environments without any crypto support + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}` +} diff --git a/web/src/lib/toast-context.tsx b/web/src/lib/toast-context.tsx index 4386d9d2..b2ef1e9e 100644 --- a/web/src/lib/toast-context.tsx +++ b/web/src/lib/toast-context.tsx @@ -1,4 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { randomId } from '@/lib/randomId' export type Toast = { id: string @@ -18,10 +19,7 @@ const ToastContext = createContext(null) const TOAST_DURATION_MS = 6000 function createToastId(): string { - if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { - return crypto.randomUUID() - } - return `toast_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + return randomId() } export function ToastProvider({ children }: { children: ReactNode }) { diff --git a/web/src/routes/sessions/terminal.tsx b/web/src/routes/sessions/terminal.tsx index d8a0f77f..26e04ab7 100644 --- a/web/src/routes/sessions/terminal.tsx +++ b/web/src/routes/sessions/terminal.tsx @@ -8,6 +8,7 @@ import { useSession } from '@/hooks/queries/useSession' import { useTerminalSocket } from '@/hooks/useTerminalSocket' import { useLongPress } from '@/hooks/useLongPress' import { useTranslation } from '@/lib/use-translation' +import { randomId } from '@/lib/randomId' import { TerminalView } from '@/components/Terminal/TerminalView' import { LoadingState } from '@/components/LoadingState' import { Button } from '@/components/ui/button' @@ -186,12 +187,7 @@ export default function TerminalPage() { const goBack = useAppGoBack() const { session } = useSession(api, sessionId) const terminalSupported = isRemoteTerminalSupported(session?.metadata) - const terminalId = useMemo(() => { - if (typeof crypto?.randomUUID === 'function') { - return crypto.randomUUID() - } - return `${Date.now()}-${Math.random().toString(16).slice(2)}` - }, [sessionId]) + const terminalId = useMemo(() => randomId(), [sessionId]) const terminalRef = useRef(null) const inputDisposableRef = useRef<{ dispose: () => void } | null>(null) const connectOnceRef = useRef(false)