From 951f3b702d667b068296ffa464be53a1857b8d36 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:31:05 +0000 Subject: [PATCH] fix(reader): persist audiobook progress on insecure origins Generate RFC 4122 UUIDs with getRandomValues when randomUUID is unavailable so plain HTTP deployments can save playback state and bookmarks. Fixes #1608 --- .../__tests__/useAudioBookmarks.spec.ts | 44 +++++++++++++++++++ .../__tests__/useAudioProgress.spec.ts | 22 ++++++++++ .../composables/useAudioBookmarks.ts | 3 +- .../audiobook/composables/useAudioProgress.ts | 3 +- .../shared/composables/useReadingSession.ts | 11 ++--- client/src/lib/__tests__/uuid.spec.ts | 39 ++++++++++++++++ client/src/lib/uuid.ts | 18 ++++++++ 7 files changed, 130 insertions(+), 10 deletions(-) create mode 100644 client/src/features/reader/audiobook/composables/__tests__/useAudioBookmarks.spec.ts create mode 100644 client/src/lib/__tests__/uuid.spec.ts create mode 100644 client/src/lib/uuid.ts diff --git a/client/src/features/reader/audiobook/composables/__tests__/useAudioBookmarks.spec.ts b/client/src/features/reader/audiobook/composables/__tests__/useAudioBookmarks.spec.ts new file mode 100644 index 000000000..95df1227e --- /dev/null +++ b/client/src/features/reader/audiobook/composables/__tests__/useAudioBookmarks.spec.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAudioBookmarks } from '../useAudioBookmarks' + +const apiMock = vi.hoisted(() => vi.fn<(url: string, init?: RequestInit) => Promise>()) +vi.mock('@/lib/api', () => ({ api: apiMock })) + +describe('useAudioBookmarks', () => { + afterEach(() => { + apiMock.mockReset() + vi.unstubAllGlobals() + }) + + it('creates a bookmark when randomUUID is unavailable on an insecure origin', async () => { + const getRandomValues = vi.fn<(bytes: Uint8Array) => Uint8Array>((bytes) => { + bytes.fill(0) + return bytes + }) + vi.stubGlobal('crypto', { getRandomValues }) + apiMock.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + id: '00000000-0000-4000-8000-000000000000', + bookId: 7, + positionMs: 12_500, + chapterId: null, + title: 'Chapter 1', + note: null, + createdAt: '2026-09-30T12:00:00.000Z', + updatedAt: '2026-09-30T12:00:00.000Z', + }), + }) + const audioBookmarks = useAudioBookmarks(7) + + await expect(audioBookmarks.add(12.5, 'Chapter 1')).resolves.toMatchObject({ positionMs: 12_500 }) + + const [, init] = apiMock.mock.calls[0]! + expect(JSON.parse(init!.body as string)).toMatchObject({ + clientId: '00000000-0000-4000-8000-000000000000', + positionMs: 12_500, + }) + expect(getRandomValues).toHaveBeenCalledOnce() + }) +}) diff --git a/client/src/features/reader/audiobook/composables/__tests__/useAudioProgress.spec.ts b/client/src/features/reader/audiobook/composables/__tests__/useAudioProgress.spec.ts index c8ec8b2e5..4f4032e96 100644 --- a/client/src/features/reader/audiobook/composables/__tests__/useAudioProgress.spec.ts +++ b/client/src/features/reader/audiobook/composables/__tests__/useAudioProgress.spec.ts @@ -51,9 +51,31 @@ describe('useAudioProgress', () => { }) afterEach(() => { + vi.unstubAllGlobals() vi.useRealTimers() }) + it('persists playback state when randomUUID is unavailable on an insecure origin', async () => { + const getRandomValues = vi.fn<(bytes: Uint8Array) => Uint8Array>((bytes) => { + bytes.fill(0) + return bytes + }) + vi.stubGlobal('crypto', { getRandomValues }) + apiMock.mockResolvedValue(response(200, serverState({ revision: 1 }))) + const { progress } = setup() + + expect(() => progress.update(ASSET_ID, 12)).not.toThrow() + await vi.advanceTimersByTimeAsync(5_000) + + expect(puts()).toEqual([ + expect.objectContaining({ + positionMs: 12_000, + operationId: '00000000-0000-4000-8000-000000000000', + }), + ]) + expect(getRandomValues).toHaveBeenCalledOnce() + }) + it('stamps capturedAt when the position is captured and clamps it to the asset duration', async () => { apiMock.mockResolvedValue(response(200, serverState({ revision: 1 }))) const { progress } = setup() diff --git a/client/src/features/reader/audiobook/composables/useAudioBookmarks.ts b/client/src/features/reader/audiobook/composables/useAudioBookmarks.ts index 45d143865..b1a51f6f9 100644 --- a/client/src/features/reader/audiobook/composables/useAudioBookmarks.ts +++ b/client/src/features/reader/audiobook/composables/useAudioBookmarks.ts @@ -1,6 +1,7 @@ import { ref } from 'vue' import type { AudiobookBookmark } from '@bookorbit/types' import { api } from '@/lib/api' +import { createUuid } from '@/lib/uuid' export type AudioBookmark = AudiobookBookmark @@ -18,7 +19,7 @@ export function useAudioBookmarks(bookId: number) { const res = await api(`/api/v1/audiobooks/${bookId}/bookmarks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ clientId: crypto.randomUUID(), positionMs: Math.round(positionSeconds * 1000), title, chapterId }), + body: JSON.stringify({ clientId: createUuid(), positionMs: Math.round(positionSeconds * 1000), title, chapterId }), }) if (!res.ok) return null const created: AudioBookmark = await res.json() diff --git a/client/src/features/reader/audiobook/composables/useAudioProgress.ts b/client/src/features/reader/audiobook/composables/useAudioProgress.ts index 306660f87..9425aefdd 100644 --- a/client/src/features/reader/audiobook/composables/useAudioProgress.ts +++ b/client/src/features/reader/audiobook/composables/useAudioProgress.ts @@ -1,6 +1,7 @@ import { onUnmounted, ref, unref, type MaybeRef } from 'vue' import type { AudiobookManifestAsset, AudiobookPlaybackState } from '@bookorbit/types' import { api } from '@/lib/api' +import { createUuid } from '@/lib/uuid' const SAVE_THROTTLE_MS = 5_000 @@ -64,7 +65,7 @@ export function useAudioProgress(bookId: number, options: AudioProgressOptions) assetId, positionMs: Math.max(0, Math.round(positionSeconds * 1000)), capturedAt: new Date().toISOString(), - operationId: crypto.randomUUID(), + operationId: createUuid(), } scheduleFlush() } diff --git a/client/src/features/reader/shared/composables/useReadingSession.ts b/client/src/features/reader/shared/composables/useReadingSession.ts index db12fffb1..bc930ec73 100644 --- a/client/src/features/reader/shared/composables/useReadingSession.ts +++ b/client/src/features/reader/shared/composables/useReadingSession.ts @@ -1,5 +1,6 @@ import { onUnmounted, ref, unref, type MaybeRef } from 'vue' import { api } from '@/lib/api' +import { createUuid } from '@/lib/uuid' export interface ProgressSnapshot { percentage: number @@ -18,14 +19,8 @@ export interface ReadingSessionOptions { sessionType?: ReadingSessionType } -function generateSessionId(): string { - return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `session-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` // codeql[js/insecure-randomness] - session IDs are non-security deduplication keys -} - export function useReadingSession(bookFileId: number, getProgress: () => ProgressSnapshot, options: ReadingSessionOptions = {}) { - let sessionId = generateSessionId() + let sessionId = createUuid() let startedAt: Date | null = null let activeMs = 0 let activeStart: number | null = null @@ -107,7 +102,7 @@ export function useReadingSession(bookFileId: number, getProgress: () => Progres if (!canTrack()) return // No active session or previous session ended (e.g. after idle timeout) - start fresh. if (!startedAt || ended) { - sessionId = generateSessionId() + sessionId = createUuid() startSession() return } diff --git a/client/src/lib/__tests__/uuid.spec.ts b/client/src/lib/__tests__/uuid.spec.ts new file mode 100644 index 000000000..c120d8fcb --- /dev/null +++ b/client/src/lib/__tests__/uuid.spec.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createUuid } from '../uuid' + +const NATIVE_UUID = '12345678-1234-4567-89ab-123456789abc' + +describe('createUuid', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('uses the native UUID generator when it is available', () => { + const randomUUID = vi.fn<() => string>(() => NATIVE_UUID) + const getRandomValues = vi.fn<() => void>() + vi.stubGlobal('crypto', { randomUUID, getRandomValues }) + + expect(createUuid()).toBe(NATIVE_UUID) + expect(randomUUID).toHaveBeenCalledOnce() + expect(getRandomValues).not.toHaveBeenCalled() + }) + + it('creates an RFC 4122 version 4 UUID with getRandomValues when randomUUID is unavailable', () => { + const getRandomValues = vi.fn<(bytes: Uint8Array) => Uint8Array>((bytes) => { + bytes.set(Array.from({ length: 16 }, (_, index) => index)) + return bytes + }) + vi.stubGlobal('crypto', { getRandomValues }) + + expect(createUuid()).toBe('00010203-0405-4607-8809-0a0b0c0d0e0f') + expect(getRandomValues).toHaveBeenCalledOnce() + }) + + it('keeps the UUID contract when Web Crypto is unavailable', () => { + vi.stubGlobal('crypto', undefined) + vi.spyOn(Math, 'random').mockReturnValue(0) + + expect(createUuid()).toBe('00000000-0000-4000-8000-000000000000') + }) +}) diff --git a/client/src/lib/uuid.ts b/client/src/lib/uuid.ts new file mode 100644 index 000000000..eab7f4b8b --- /dev/null +++ b/client/src/lib/uuid.ts @@ -0,0 +1,18 @@ +export function createUuid(): string { + const cryptoApi = globalThis.crypto + if (typeof cryptoApi?.randomUUID === 'function') return cryptoApi.randomUUID() + + const bytes = new Uint8Array(16) + if (typeof cryptoApi?.getRandomValues === 'function') { + cryptoApi.getRandomValues(bytes) + } else { + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Math.floor(Math.random() * 256) // codeql[js/insecure-randomness] - client IDs are non-security deduplication keys + } + } + + bytes[6] = (bytes[6]! & 0x0f) | 0x40 + bytes[8] = (bytes[8]! & 0x3f) | 0x80 + const hex = Array.from(bytes, (byte) => byte.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)}` +}