mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(reader): prevent tracking progress out of bounds or non-finite
Progress values computed during reader events could occasionally result in NaN, Infinity, or out-of-bounds numbers, which caused API validation failures or corrupted state. Progress values are now clamped to valid ranges and sanitized to omit non-finite numbers before tracking. Closes #479
This commit is contained in:
@@ -16,6 +16,11 @@ function makeErrorResponse() {
|
||||
return { ok: false } as Response
|
||||
}
|
||||
|
||||
function postedProgressBody(callIndex = 0): Record<string, unknown> {
|
||||
const [, opts] = mockApi.mock.calls[callIndex]!
|
||||
return JSON.parse((opts as RequestInit).body as string) as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('formatTimeRemaining', () => {
|
||||
it('returns empty string for negative values', () => {
|
||||
expect(formatTimeRemaining(-1)).toBe('')
|
||||
@@ -180,6 +185,58 @@ describe('useReaderProgress', () => {
|
||||
expect(percentage.value).toBe(50)
|
||||
})
|
||||
|
||||
it('clamps out-of-range relocate progress values', () => {
|
||||
const elapsedMinutes = ref(0)
|
||||
const { onRelocate, fraction, percentage, koboContentSourceProgressPercent } = useReaderProgress(1, 42, elapsedMinutes)
|
||||
|
||||
onRelocate({
|
||||
cfi: 'epubcfi(/6/4)',
|
||||
fraction: 1.5,
|
||||
contentSourceProgressPercent: 125,
|
||||
} as never)
|
||||
|
||||
expect(fraction.value).toBe(1)
|
||||
expect(percentage.value).toBe(100)
|
||||
expect(koboContentSourceProgressPercent.value).toBe(100)
|
||||
})
|
||||
|
||||
it('preserves last valid progress when relocate fraction is non-finite', () => {
|
||||
const elapsedMinutes = ref(0)
|
||||
const { onRelocate, cfi, fraction, percentage, koboContentSourceProgressPercent } = useReaderProgress(1, 42, elapsedMinutes)
|
||||
|
||||
onRelocate({
|
||||
cfi: 'epubcfi(/6/4)',
|
||||
fraction: 0.42,
|
||||
contentSourceProgressPercent: 40,
|
||||
} as never)
|
||||
onRelocate({
|
||||
cfi: 'epubcfi(/6/10)',
|
||||
fraction: Number.NaN,
|
||||
contentSourceProgressPercent: Number.NaN,
|
||||
} as never)
|
||||
|
||||
expect(cfi.value).toBe('epubcfi(/6/10)')
|
||||
expect(fraction.value).toBe(0.42)
|
||||
expect(percentage.value).toBe(42)
|
||||
expect(koboContentSourceProgressPercent.value).toBeNull()
|
||||
})
|
||||
|
||||
it('does not schedule save when relocate has no usable location', () => {
|
||||
mockApi.mockResolvedValue(makeOkResponse({}))
|
||||
const elapsedMinutes = ref(0)
|
||||
const { onRelocate } = useReaderProgress(1, 42, elapsedMinutes)
|
||||
|
||||
onRelocate({
|
||||
cfi: null,
|
||||
fraction: Number.NaN,
|
||||
koboLocationValue: null,
|
||||
koreaderProgress: null,
|
||||
} as never)
|
||||
|
||||
vi.advanceTimersByTime(2500)
|
||||
expect(mockApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets koboLocationSource and koboLocationValue from detail', () => {
|
||||
const elapsedMinutes = ref(0)
|
||||
const { onRelocate, koboLocationSource, koboLocationValue } = useReaderProgress(1, 42, elapsedMinutes)
|
||||
@@ -423,8 +480,7 @@ describe('useReaderProgress', () => {
|
||||
vi.clearAllTimers()
|
||||
await save()
|
||||
|
||||
const [, opts] = mockApi.mock.calls[0]!
|
||||
const body = JSON.parse((opts as RequestInit).body as string)
|
||||
const body = postedProgressBody()
|
||||
expect(body.cfi).toBe('epubcfi(/6/2)')
|
||||
expect(body.percentage).toBe(25)
|
||||
expect(body.koboLocationSource).toBe('KoboSpan')
|
||||
@@ -433,6 +489,50 @@ describe('useReaderProgress', () => {
|
||||
expect(body.koboContentSourceProgressPercent).toBe(0.25)
|
||||
expect(body.koreaderProgress).toBe('/body/p[1]')
|
||||
})
|
||||
|
||||
it('serializes finite progress after invalid relocate values', async () => {
|
||||
mockApi.mockResolvedValue(makeOkResponse({}))
|
||||
const elapsedMinutes = ref(0)
|
||||
const { onRelocate, save } = useReaderProgress(1, 42, elapsedMinutes)
|
||||
|
||||
onRelocate({
|
||||
cfi: 'epubcfi(/6/4)',
|
||||
fraction: 0.42,
|
||||
contentSourceProgressPercent: 40,
|
||||
} as never)
|
||||
onRelocate({
|
||||
cfi: 'epubcfi(/6/10)',
|
||||
fraction: Number.POSITIVE_INFINITY,
|
||||
contentSourceProgressPercent: Number.NEGATIVE_INFINITY,
|
||||
} as never)
|
||||
|
||||
vi.clearAllTimers()
|
||||
await save()
|
||||
|
||||
const body = postedProgressBody()
|
||||
expect(body.cfi).toBe('epubcfi(/6/10)')
|
||||
expect(body.percentage).toBe(42)
|
||||
expect(Number.isFinite(body.percentage)).toBe(true)
|
||||
expect(body.koboContentSourceProgressPercent).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the last valid percentage when exposed state is non-finite', async () => {
|
||||
mockApi.mockResolvedValue(makeOkResponse({}))
|
||||
const elapsedMinutes = ref(0)
|
||||
const { percentage, save } = useReaderProgress(1, 42, elapsedMinutes)
|
||||
|
||||
percentage.value = 55
|
||||
await save()
|
||||
mockApi.mockClear()
|
||||
|
||||
percentage.value = Number.NaN
|
||||
await save()
|
||||
|
||||
const body = postedProgressBody()
|
||||
expect(body.percentage).toBe(55)
|
||||
expect(Number.isFinite(body.percentage)).toBe(true)
|
||||
expect(percentage.value).toBe(55)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cycleFooterMode()', () => {
|
||||
|
||||
@@ -8,6 +8,33 @@ export interface ReaderProgressOptions {
|
||||
trackingEnabled?: MaybeRef<boolean>
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
function normalizeFraction(value: unknown): number | null {
|
||||
const parsed = finiteNumber(value)
|
||||
return parsed === null ? null : clamp(parsed, 0, 1)
|
||||
}
|
||||
|
||||
function normalizePercentage(value: unknown): number | null {
|
||||
const parsed = finiteNumber(value)
|
||||
return parsed === null ? null : clamp(parsed, 0, 100)
|
||||
}
|
||||
|
||||
function normalizeNullablePercentage(value: unknown): number | null {
|
||||
return normalizePercentage(value)
|
||||
}
|
||||
|
||||
function normalizePageNumber(value: unknown): number | null {
|
||||
const parsed = finiteNumber(value)
|
||||
return parsed === null || parsed < 0 ? null : parsed
|
||||
}
|
||||
|
||||
export function formatTimeRemaining(minutes: number): string {
|
||||
if (!Number.isFinite(minutes) || minutes < 0) return ''
|
||||
if (minutes < 1) return '< 1 min'
|
||||
@@ -49,35 +76,48 @@ export function useReaderProgress(
|
||||
const trackingEnabled = options.trackingEnabled ?? true
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let lastValidPercentage = 0
|
||||
|
||||
onUnmounted(() => {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
})
|
||||
|
||||
function updatePercentage(value: unknown, fallback = lastValidPercentage): number {
|
||||
const normalized = normalizePercentage(value) ?? fallback
|
||||
percentage.value = normalized
|
||||
lastValidPercentage = normalized
|
||||
return normalized
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!unref(trackingEnabled)) return
|
||||
const res = await api(`/api/v1/books/files/${fileId}/progress`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
cfi.value = data.cfi ?? null
|
||||
pageNumber.value = data.pageNumber ?? null
|
||||
percentage.value = data.percentage ?? 0
|
||||
pageNumber.value = normalizePageNumber(data.pageNumber)
|
||||
updatePercentage(data.percentage, 0)
|
||||
koboLocationSource.value = data.koboLocationSource ?? null
|
||||
koboLocationType.value = data.koboLocationType ?? null
|
||||
koboLocationValue.value = data.koboLocationValue ?? null
|
||||
koboContentSourceProgressPercent.value = data.koboContentSourceProgressPercent ?? null
|
||||
koboContentSourceProgressPercent.value = normalizeNullablePercentage(data.koboContentSourceProgressPercent)
|
||||
koreaderProgress.value = data.koreaderProgress ?? null
|
||||
}
|
||||
|
||||
function onRelocate(detail: RelocateDetail) {
|
||||
cfi.value = detail?.cfi ?? null
|
||||
fraction.value = detail?.fraction ?? 0
|
||||
percentage.value = fraction.value * 100
|
||||
const relocatedFraction = normalizeFraction(detail?.fraction)
|
||||
if (relocatedFraction !== null) {
|
||||
fraction.value = relocatedFraction
|
||||
updatePercentage(relocatedFraction * 100)
|
||||
}
|
||||
koboLocationSource.value = detail?.source ?? null
|
||||
koboLocationValue.value = detail?.koboLocationValue ?? null
|
||||
koboLocationType.value = koboLocationValue.value ? (detail?.koboLocationType ?? 'KoboSpan') : null
|
||||
koboContentSourceProgressPercent.value = detail?.contentSourceProgressPercent ?? null
|
||||
koboContentSourceProgressPercent.value = normalizeNullablePercentage(detail?.contentSourceProgressPercent)
|
||||
koreaderProgress.value = detail?.koreaderProgress ?? null
|
||||
const hasSaveableLocation =
|
||||
relocatedFraction !== null || cfi.value !== null || koboLocationValue.value !== null || koreaderProgress.value !== null
|
||||
chapterTitle.value = detail?.tocItem?.label ?? ''
|
||||
sectionIndex.value = detail?.section?.current ?? detail?.index ?? 0
|
||||
totalSections.value = detail?.section?.total ?? detail?.total ?? 0
|
||||
@@ -90,23 +130,24 @@ export function useReaderProgress(
|
||||
timeTotal.value = detail?.time?.total ?? 0
|
||||
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
if (!unref(trackingEnabled)) return
|
||||
if (!unref(trackingEnabled) || !hasSaveableLocation) return
|
||||
saveTimer = setTimeout(() => save(), 2000)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!unref(trackingEnabled)) return
|
||||
const safePercentage = updatePercentage(percentage.value)
|
||||
await api(`/api/v1/books/files/${fileId}/progress`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
cfi: cfi.value,
|
||||
pageNumber: pageNumber.value,
|
||||
percentage: percentage.value,
|
||||
pageNumber: normalizePageNumber(pageNumber.value),
|
||||
percentage: safePercentage,
|
||||
koboLocationSource: koboLocationSource.value,
|
||||
koboLocationType: koboLocationType.value,
|
||||
koboLocationValue: koboLocationValue.value,
|
||||
koboContentSourceProgressPercent: koboContentSourceProgressPercent.value,
|
||||
koboContentSourceProgressPercent: normalizeNullablePercentage(koboContentSourceProgressPercent.value),
|
||||
koreaderProgress: koreaderProgress.value,
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user