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
This commit is contained in:
neon
2026-09-30 19:31:05 +00:00
parent d1b3fae7f7
commit 951f3b702d
7 changed files with 130 additions and 10 deletions
@@ -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<unknown>>())
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()
})
})
@@ -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()
@@ -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()
@@ -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()
}
@@ -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
}
+39
View File
@@ -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')
})
})
+18
View File
@@ -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)}`
}