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:
neon
2026-06-30 19:51:06 +00:00
parent e6c24bc608
commit 117bc82de6
2 changed files with 153 additions and 12 deletions
@@ -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,
}),
})