mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)}`
|
||||
}
|
||||
Reference in New Issue
Block a user