From 618992ec1408ae7669a55d2f7433a0e741657bc8 Mon Sep 17 00:00:00 2001 From: maashkinoozhe Date: Sat, 31 Oct 2026 11:49:52 +0100 Subject: [PATCH] Reader: stale reading sessions never move "Continue" back A reader tab left open on one device (or restored by the browser) used to save its old chapter as the newest position as soon as it was shown again. Progress saves now carry the tab id and the newest progress time the tab knows (`seen`); the server refuses a save when another tab or device saved a newer position since. The reader then asks: jump to the newest position, or keep reading here (which makes this chapter the head again). It checks on open, when the tab becomes visible, and on every save. Reader links carry `at` and the reader keeps it in the address bar, so reloads and restored tabs still know what they had seen. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 6 ++- public/app.css | 8 ++++ public/collection.js | 2 +- public/common.js | 17 ++++++- public/library.js | 2 +- public/manga.js | 12 ++--- public/reader.js | 69 ++++++++++++++++++++++++--- server.js | 39 ++++++++++++--- test/stale-session.test.js | 97 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 228 insertions(+), 24 deletions(-) create mode 100644 test/stale-session.test.js diff --git a/docs/architecture.md b/docs/architecture.md index 3676450..8204600 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -198,8 +198,9 @@ All requests and responses are JSON. POSTs require `Content-Type: application/js | `POST /api/password` `{old, new}` | user | Change password | | `GET /api/library` | user | Manga list with covers, counts, the user's read count, unread notifications, last read, and optional personal Reading Story aggregates | | `GET /api/manga?m=` | user | Manga details, books, chapters with ranked versions, progress, optional chapter-time summaries, and `continue` target | -| `GET /api/chapter?m=&c=[&v=]` | user | Pages (`u, w, h, t, r`), direction, versions, prev/next, progress, optional Active Reading Clock summary, plus all books and chapters of the manga (with the user's read state) for the chapter picker. Also marks that chapter's notifications read | -| `POST /api/progress` `{m, c, v, p, n, done, shift}` | user | Save the reading position | +| `GET /api/chapter?m=&c=[&v=]` | user | Pages (`u, w, h, t, r`), direction, versions, prev/next, progress, `latest` (as in `/api/progress/latest`), optional Active Reading Clock summary, plus all books and chapters of the manga (with the user's read state) for the chapter picker. Also marks that chapter's notifications read | +| `POST /api/progress` `{m, c, v, p, n, done, shift[, tab, seen]}` | user | Save the reading position. With `seen`, a save is refused (`{ok:false, stale:true, latest}`) when another tab saved a newer position of this manga since; otherwise `{ok:true, t}` | +| `GET /api/progress/latest?m=` | user | The manga's newest saved position (`latest`: `t, w, c, label, v, p, n, done, continue`) or `null` | | `POST /api/reading/pulse` `{m, c, v, session, seq, …deltas}` | user | When opted in, idempotently merge a clamped active-time pulse, verified page coverage and input totals under a single-reader lease | | `GET /api/reading/export` | user | Export the user's private derived reading-time summary without raw input or page-exposure diagnostics | | `POST /api/reading/delete` `{m}` or `{all:true,password}` | user | Delete one manga's reading history, or all history after password confirmation, without changing normal reading progress | @@ -352,6 +353,7 @@ mode = adapt && intended ∉ compatible ? compatible[0] : intended - Saved 700 ms after the position settles, and on `pagehide` (a `fetch` with `keepalive`). - Stores the page index in the full page list, so skipping extras doesn't corrupt it. - On open, the saved page is used for the same version, and mapped proportionally for another version. A finished chapter reopens at the start. +- **Stale reading sessions.** A tab left open on one device (or restored by the browser) must not move "Continue" back after the user read on elsewhere. Each browser tab has an id (`sessionStorage`) that its saves store as `w`; reader links carry `at`, the newest progress time the linking page knew (`continue.at`), and the reader keeps `at` current in its address bar so a reload or restored tab still knows it. A link without `at` (bookmark, notification) counts as a deliberate open. When the manga's newest position is later than `at` and came from another tab, the reader saves nothing and asks: jump to the newest position, or keep reading here, which makes this chapter the head again. It checks on open, when the tab becomes visible again, and with every save (the server refuses the stale save). The comparison is by save time, not chapter order; the question names no device. **Active Reading Clock (opt-in):** - Runs only while the reader is visible and focused, a sufficiently exposed page is decoded, a server lease is held, and accepted reader input occurred in the last 50 seconds. diff --git a/public/app.css b/public/app.css index f67eac7..7111d70 100644 --- a/public/app.css +++ b/public/app.css @@ -429,6 +429,14 @@ body.reader { position: fixed; top: 0; left: 0; right: 0; bottom: 0; overflow: h .reading-gate-message[data-tone="retry"] { border-color: #9b7233; background: #382b1a; color: #ffdaa0; } .reading-gate-message[data-tone="error"] { border-color: #944c55; background: #3c2025; color: #ffc0c6; } @media (max-width: 660px) { .reading-gate { margin: 28px 12px 100px; padding: 18px; } } +/* Stale reading session: a newer position was saved elsewhere since this page was opened. */ +.stale-session { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; + padding: 16px; background: rgba(0, 0, 0, .6); } +.stale-box { box-sizing: border-box; width: min(440px, 100%); padding: 20px; border-radius: 12px; background: var(--card); + border: 1px solid var(--line); box-shadow: 0 12px 40px rgba(0, 0, 0, .6); } +.stale-box h2 { margin: 0 0 8px; font-size: 18px; } +.stale-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0 10px; } +.stale-actions .btn { flex: 1 1 auto; justify-content: center; text-align: center; } .pg { position: absolute; overflow: hidden; background: #19191c; } .pg img { display: block; width: 100%; height: 100%; -webkit-user-drag: none; } #canvas[data-page-tone="inverted"] .pg img { filter: invert(1); } diff --git a/public/collection.js b/public/collection.js index 0754529..881cd02 100644 --- a/public/collection.js +++ b/public/collection.js @@ -8,7 +8,7 @@ h('div', { class: 'info' }, h('h1', {}, c.title), h('p', { class: 'muted' }, `${c.members.length} manga · ${c.read}/${c.chapters} chapters read · ${c.unread} new`), c.description ? h('p', {}, c.description) : null, c.reading.activeMs ? h('p', {}, `${readingDuration(c.reading.activeMs)} together`) : null, - h('div', { class: 'actions' }, h('a', { class: 'btn primary', href: readUrl(c.continue.m, c.continue.c, c.continue.v) }, c.continue.text), + h('div', { class: 'actions' }, h('a', { class: 'btn primary', href: readUrl(c.continue.m, c.continue.c, c.continue.v, c.continue.at) }, c.continue.text), me.admin ? h('a', { class: 'btn', href: 'collections.html?' + qsa({ id: c.id }) }, 'Edit collection') : null)))); main.append(h('h2', {}, 'Reading order'), h('div', { class: 'collection-members' }, c.members.map(m => h('a', { class: 'collection-member', href: mangaUrl(m.id) }, m.cover ? h('img', { src: m.cover, alt: '', loading: 'lazy' }) : null, diff --git a/public/common.js b/public/common.js index 328a348..51e4aee 100644 --- a/public/common.js +++ b/public/common.js @@ -39,6 +39,20 @@ const store = { get(k) { try { return localStorage.getItem('mc.' + k); } catch (e) { return null; } }, set(k, v) { try { localStorage.setItem('mc.' + k, v); } catch (e) { /* private mode */ } }, }; +// One id per browser tab (it survives navigation and reloads in that tab): progress saves carry it, so +// a reader can tell its own saves from those of another tab or device. +const tabId = (() => { + let id = null; + return () => { + if (id) return id; + try { id = sessionStorage.getItem('mc.tab'); } catch (e) { /* storage blocked */ } + if (!/^[a-zA-Z0-9_-]{8,40}$/.test(id || '')) { + id = Array.from(crypto.getRandomValues(new Uint8Array(12)), b => b.toString(16).padStart(2, '0')).join(''); + try { sessionStorage.setItem('mc.tab', id); } catch (e) { /* this page keeps it in memory */ } + } + return id; + }; +})(); function ago(t) { const s = (Date.now() - t) / 1000; @@ -60,7 +74,8 @@ function readingDuration(ms, compact = false) { } const qsa = obj => { const p = new URLSearchParams(); for (const k in obj) if (obj[k] != null && obj[k] !== '') p.set(k, obj[k]); return p.toString(); }; -const readUrl = (m, c, v) => 'read.html?' + qsa({ m, c, v }); +// `at`: the manga's latest progress time the linking page knew, so the reader can tell it opened from a stale page. +const readUrl = (m, c, v, at) => 'read.html?' + qsa({ m, c, v, at: at || null }); const mangaUrl = m => 'manga.html?' + qsa({ m }); const libraryUrlForTags = tags => { const p = new URLSearchParams(); diff --git a/public/library.js b/public/library.js index e1c0985..dd5f3a0 100644 --- a/public/library.js +++ b/public/library.js @@ -72,7 +72,7 @@ data.reading.rereads ? h('span', { class: 'story-pill' }, `${data.reading.rereads} reread${data.reading.rereads === 1 ? '' : 's'}`) : null), h('div', { class: 'reading-summary-links' }, most ? h('a', { href: mangaUrl(most.id) }, `Most time with ${most.title}`) : null, - latest && data.reading.lastChapterId ? h('a', { href: readUrl(latest.id, data.reading.lastChapterId) }, `Continue ${latest.title} →`) : null))); + latest && data.reading.lastChapterId ? h('a', { href: readUrl(latest.id, data.reading.lastChapterId, null, latest.lastRead) }, `Continue ${latest.title} →`) : null))); } main.append( h('div', { class: 'library-controls' }, diff --git a/public/manga.js b/public/manga.js index 62cc9f9..8661be2 100644 --- a/public/manga.js +++ b/public/manga.js @@ -48,7 +48,7 @@ h('div', { class: 'chips' }, tags.map(tag => h('a', { class: 'chip chip-link', href: libraryUrlForTags([tag]), title: `Show manga tagged ${tag}` }, tag))), meta.description ? h('p', { class: 'desc' }, meta.description) : null, h('div', { class: 'actions' }, - M.continue ? h('a', { class: 'btn primary', href: readUrl(M.id, M.continue.c, M.continue.v) }, M.continue.text) : null, + M.continue ? h('a', { class: 'btn primary', href: readUrl(M.id, M.continue.c, M.continue.v, M.continue.at) }, M.continue.text) : null, follow)))); let storyRhythm = null; @@ -73,7 +73,7 @@ storyStat(M.reading.typicalChapterMs ? readingDuration(M.reading.typicalChapterMs) : '—', 'typical chapter'), storyStat(M.reading.rereads, `reread${M.reading.rereads === 1 ? '' : 's'}`)), lastChapter ? h('p', { class: 'story-last muted' }, 'Last timed ', - h('a', { href: readUrl(M.id, lastChapter.id) }, `${lastChapter.label}${lastChapter.title ? ' · ' + lastChapter.title : ''}`), + h('a', { href: readUrl(M.id, lastChapter.id, null, M.continue?.at) }, `${lastChapter.label}${lastChapter.title ? ' · ' + lastChapter.title : ''}`), ` · ${ago(M.reading.updatedAt)}`) : null, h('div', { class: 'story-rhythm-head' }, h('b', {}, 'Reading rhythm'), h('span', { class: 'muted small' }, 'Completed chapters · active time')), storyRhythm, @@ -110,7 +110,7 @@ const passes = chapter.reading.completedPasses; const title = `${label} · ${readingDuration(value)}${passes > 1 ? ` · ${passes} timed reads · ${readingDuration(chapter.reading.totalActiveMs)} total` : ''}`; storyRhythm.append(h('a', { - class: 'rhythm-bar', role: 'listitem', href: readUrl(M.id, chapter.id), title, + class: 'rhythm-bar', role: 'listitem', href: readUrl(M.id, chapter.id, null, M.continue?.at), title, 'aria-label': title, style: `height:${height}px`, })); } @@ -221,7 +221,7 @@ if (done) { await api('api/unread', { m: M.id, c: ch.id }); ch.progress = null; ch.readAt = null; } else { const version = ch.versions[0]; - await api('api/progress', { m: M.id, c: ch.id, v: version.id, p: version.pages - 1, n: version.pages, done: true }); + await api('api/progress', { m: M.id, c: ch.id, v: version.id, p: version.pages - 1, n: version.pages, done: true, tab: tabId() }); ch.progress = { v: version.id, p: version.pages - 1, n: version.pages, done: true }; ch.readAt = { at: Date.now(), approx: false }; } @@ -234,7 +234,7 @@ const timing = chapterTiming(ch), released = chapterRelease(ch.dates); wrap.textContent = ''; wrap.append(h('div', { class: 'ch' + (done ? ' read' : '') }, - h('a', { class: 'ch-main' + (timing ? ' has-time' : '') + (M.chapterDates?.release ? ' has-date' : ''), href: readUrl(M.id, ch.id) }, + h('a', { class: 'ch-main' + (timing ? ' has-time' : '') + (M.chapterDates?.release ? ' has-date' : ''), href: readUrl(M.id, ch.id, null, M.continue?.at) }, h('span', { class: 'ch-label' }, ch.label), h('span', { class: 'ch-pages' }, `${pages} p.`), M.chapterDates?.release ? h('span', { class: 'ch-date' + (released ? ' tier-' + released.tier : ''), title: released ? released.title : 'No date known', 'aria-label': released ? released.title : 'No date known' }, @@ -259,7 +259,7 @@ h('button', { class: version.my > 0 ? 'on' : '', title: 'Better', onclick: () => vote(1) }, '▲'), h('b', {}, version.score), h('button', { class: version.my < 0 ? 'on' : '', title: 'Worse', onclick: () => vote(-1) }, '▼')), - h('a', { class: 'btn small', href: readUrl(M.id, ch.id, version.id) }, 'Read'))); + h('a', { class: 'btn small', href: readUrl(M.id, ch.id, version.id, M.continue?.at) }, 'Read'))); }); } draw(); diff --git a/public/reader.js b/public/reader.js index 4971301..590d18d 100644 --- a/public/reader.js +++ b/public/reader.js @@ -155,6 +155,55 @@ // A link to a page (e.g. "Open in reader" from a moment) wins over the saved position. if (P.get('p') != null) { const k = list.findIndex(x => x.i >= +P.get('p')); if (k >= 0) cur = k; } + // Stale reading sessions: a tab left open (or restored by the browser) on another device must not move + // the "Continue" point back. `seen` is the newest progress time this tab knows: the link's `at`, kept + // current in the address bar, so a reload or restored tab still knows it. Without `at` the open was a + // deliberate one (bookmark, notification) and starts from the current head. + const tab = tabId(); + let seen = Number(P.get('at')) || (D.latest ? D.latest.t : 0), stale = null, staleCard = null; + const chapterUrl = (c, v) => readUrl(D.manga.id, c, v, seen); + const newer = latest => !!latest && latest.t > seen && latest.w !== tab; + function markSeen(t) { + seen = Math.max(seen, t || 0); + const q = new URLSearchParams(location.search); + q.set('at', seen); + history.replaceState(history.state, '', '?' + q); + } + // Another tab or device saved a newer position. The same place is no conflict; anything else asks. + function checkLatest(latest) { + if (!newer(latest)) return; + if (latest.c === D.chapter.id && list[cur] && latest.p === list[cur].i) { markSeen(latest.t); return; } + showStale(latest); + } + function showStale(latest) { + stale = latest; + clearTimeout(saveTimer); + if (staleCard) staleCard.remove(); + const where = latest.done ? `${latest.label} (finished)` : `${latest.label} · page ${latest.p + 1}`; + const go = h('a', { class: 'btn primary', href: readUrl(D.manga.id, latest.continue.c, latest.continue.v, latest.t) }, latest.continue.text); + const stay = h('button', { class: 'btn', type: 'button' }, 'Keep reading here'); + stay.onclick = () => { + staleCard.remove(); staleCard = null; stale = null; + markSeen(latest.t); + saveProgress(); + stage.focus({ preventScroll: true }); + }; + staleCard = h('div', { class: 'stale-session', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'stale-title' }, + h('div', { class: 'stale-box' }, + h('h2', { id: 'stale-title' }, 'You have read further since'), + h('p', {}, 'This page is an older reading session. Your newest reading position is ', h('b', {}, where), ` (${ago(latest.t)}).`), + h('div', { class: 'stale-actions' }, go, stay), + h('p', { class: 'muted small' }, `Keeping ${D.chapter.label} makes it your “Continue” point again.`))); + document.body.append(staleCard); + go.focus(); + } + async function recheck() { + if (stale || document.hidden) return; + try { checkLatest((await api('api/progress/latest?' + qsa({ m: D.manga.id }))).latest); } catch { /* offline: the next save checks again */ } + } + document.addEventListener('visibilitychange', recheck); + window.addEventListener('pageshow', e => { if (e.persisted) recheck(); }); + // Book mode: pages pair up inside each run of single pages. Runs restart after every double page / cover, // so one missing pair never shifts the rest of the chapter. "Shift" makes a run start with a lone page. function computeSpreads() { @@ -240,7 +289,7 @@ const n = D.next; return h('div', { class: 'end' }, h('div', { class: 'endbox' }, h('div', { class: 'muted' }, 'End of ' + D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : '')), - n ? h('a', { class: 'btn primary', href: readUrl(D.manga.id, n.id), onclick: () => saveProgress(true) }, `Next: ${n.label}${n.title ? ' · ' + n.title : ''} ›`) + n ? h('a', { class: 'btn primary', href: chapterUrl(n.id), onclick: e => { e.currentTarget.href = chapterUrl(n.id); saveProgress(true); } }, `Next: ${n.label}${n.title ? ' · ' + n.title : ''} ›`) : h('div', {}, 'You are up to date.'), h('a', { class: 'btn', href: mangaUrl(D.manga.id) }, 'Back to ' + D.manga.title))); } @@ -589,7 +638,7 @@ function goChapter(c) { if (!c) return toast('You are up to date'); saveProgress(true); - location.href = readUrl(D.manga.id, c.id); + location.href = chapterUrl(c.id); } // Flow: jump to the top of the next page, but never further than ~one screen. function flowStep(dir) { @@ -701,8 +750,13 @@ } function saveProgress(keepalive) { clearTimeout(saveTimer); - const body = JSON.stringify({ m: D.manga.id, c: D.chapter.id, v: D.version, p: list[cur].i, n: all.length, done, shift: [...shift] }); - fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body }).catch(() => {}); + if (stale) return; // nothing is saved until the reader chose where to go on + const body = JSON.stringify({ m: D.manga.id, c: D.chapter.id, v: D.version, p: list[cur].i, n: all.length, done, shift: [...shift], tab, seen }); + fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body }) + .then(r => r.json()).then(r => { + if (r.stale) checkLatest(r.latest); + else if (r.ok && r.t) markSeen(r.t); + }).catch(() => {}); } addEventListener('pagehide', () => saveProgress(true)); @@ -717,17 +771,17 @@ groups[c.book].append(h('option', { value: c.id }, `${c.label}${c.title ? ' · ' + c.title : ''}${c.extra ? ' (extra)' : ''}${c.done ? ' ✓' : ''}`)); } chsel.value = D.chapter.id; - chsel.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, chsel.value); }; + chsel.onchange = () => { saveProgress(true); location.href = chapterUrl(chsel.value); }; $('#back').href = mangaUrl(D.manga.id); for (const [id, c] of [['#prevch', D.prev], ['#nextch', D.next]]) { const a = $(id); - if (c) { a.href = readUrl(D.manga.id, c.id); a.title = c.label + (c.title ? ' · ' + c.title : ''); a.onclick = () => saveProgress(true); } + if (c) { a.href = chapterUrl(c.id); a.title = c.label + (c.title ? ' · ' + c.title : ''); a.onclick = () => { a.href = chapterUrl(c.id); saveProgress(true); }; } else a.classList.add('disabled'); } for (const v of D.versions) ver.append(h('option', { value: v.id }, (v.source || v.id) + (v.score ? ` (${v.score > 0 ? '+' : ''}${v.score})` : ''))); ver.value = D.version; ver.hidden = D.versions.length < 2; - ver.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, D.chapter.id, ver.value); }; + ver.onchange = () => { saveProgress(true); location.href = chapterUrl(D.chapter.id, ver.value); }; for (const b of modeBtns) b.onclick = () => { if (b.dataset.mode !== mode) setMode(b.dataset.mode, true); }; toneButton.onclick = cycleTone; $('#moment').onclick = saveMoment; @@ -748,6 +802,7 @@ for (const bar of [$('#top'), $('#bot')]) bar.addEventListener('click', e => { if (e.target.closest('button')) stage.focus({ preventScroll: true }); }); setMode(mode, false); + if (newer(D.latest)) checkLatest(D.latest); else markSeen(seen); if (S.readingClock) readingClock = createReadingClock({ button: $('#readclock'), panel: $('#clockpanel'), diff --git a/server.js b/server.js index 9addcf9..c01ab2b 100644 --- a/server.js +++ b/server.js @@ -438,14 +438,28 @@ function readAtOf(progress, record) { if (progress.doneAt) return { at: progress.doneAt, approx: false }; return earliestCompletion(record); } -function continueOf(m, prog, s) { - const ok = c => !(s.skipExtras && c.extra); +// The most recently saved chapter position of a manga: the head that "Continue" follows. +function lastOf(m, prog) { let last = null; for (const ch of m.chapters) { const p = prog[ch.id]; if (p && (!last || p.t > last.p.t)) last = { ch, p }; } - if (!last) { const first = m.chapters.find(ok) || m.chapters[0]; return { c: first.id, text: 'Start reading · ' + first.label }; } - if (!last.p.done) return { c: last.ch.id, v: last.p.v, text: `Continue · ${last.ch.label} · page ${last.p.p + 1}` }; + return last; +} +// `at` is the head's time; reader links carry it so a reader opened from a stale page notices a newer head. +function continueOf(m, prog, s) { + const ok = c => !(s.skipExtras && c.extra); + const last = lastOf(m, prog); + if (!last) { const first = m.chapters.find(ok) || m.chapters[0]; return { c: first.id, at: 0, text: 'Start reading · ' + first.label }; } + const at = last.p.t; + if (!last.p.done) return { c: last.ch.id, v: last.p.v, at, text: `Continue · ${last.ch.label} · page ${last.p.p + 1}` }; const next = m.chapters.slice(m.chapters.indexOf(last.ch) + 1).find(ok); - return next ? { c: next.id, text: 'Read next · ' + next.label } : { c: last.ch.id, v: last.p.v, text: 'Read again · ' + last.ch.label }; + return next ? { c: next.id, at, text: 'Read next · ' + next.label } : { c: last.ch.id, v: last.p.v, at, text: 'Read again · ' + last.ch.label }; +} +// What a reader tab compares itself with (stale reading sessions, docs/architecture.md). +function latestOf(m, prog, s) { + const last = lastOf(m, prog); + if (!last) return null; + const { t, w, v, p, n, done } = last.p; + return { t, w: w || '', c: last.ch.id, label: last.ch.label, v, p, n, done, continue: continueOf(m, prog, s) }; } // From the saved page on, then the pages before it; afterwards the next chapter's preferred version. function warmChapter(u, m, ch, v, progress, s) { @@ -777,6 +791,7 @@ const routes = { pages: v.pages.map(p => ({ u: url(v.rel + '/' + p.f), w: p.w, h: p.h, t: p.t, r: p.r })), prev: neighbour(m, ch, -1, s), next: neighbour(m, ch, 1, s), progress: prog[ch.id] || null, + latest: latestOf(m, prog, s), reading: s.readingClock ? publicSummary(readingOf(u, m, ch), v.id, eligiblePages(v, s)) : null, // the user's moments on this version, so the reader can mark pages that already have one moments: momentsOf(u).filter(x => x.m === m.id && x.c === ch.id && x.v === v.id) @@ -787,18 +802,30 @@ const routes = { }; }, + 'GET /api/progress/latest': ({ u, q }) => { + const m = mangaOf(q.get('m')); + return { latest: latestOf(m, progressOf(u, m), settingsOf(u)) }; + }, 'POST /api/progress': ({ u, b }) => { const m = mangaOf(b.m), ch = chapterOf(m, b.c); const all = (db.progress[u.name] = db.progress[u.name] || {}), mp = (all[m.id] = all[m.id] || {}), old = mp[ch.id] || {}; + const tab = /^[a-zA-Z0-9_-]{8,40}$/.test(String(b.tab || '')) ? String(b.tab) : ''; + // A reader tab sends the head time it knows (`seen`). When another tab or device saved a newer + // position since, this tab is stale: it must not become the head until the reader chooses to stay. + if (Number.isFinite(b.seen)) { + const last = lastOf(m, mp); + if (last && last.p.t > b.seen && (!tab || last.p.w !== tab)) return { ok: false, stale: true, latest: latestOf(m, mp, settingsOf(u)) }; + } const now = Date.now(), done = !!(old.done || b.done); // doneAt: when the chapter was first completed; kept until it is marked unread. const doneAt = old.done ? old.doneAt : done ? now : undefined; mp[ch.id] = { v: String(b.v || ''), p: Math.max(0, b.p | 0), n: Math.max(0, b.n | 0), t: now, done, ...(doneAt ? { doneAt } : {}), shift: Array.isArray(b.shift) ? b.shift.filter(Number.isInteger).slice(0, 1000) : old.shift || [], + ...(tab ? { w: tab } : {}), }; save(); - return { ok: true }; + return { ok: true, t: now }; }, // Separate endpoint: an older server on rollback cannot misread cumulative // visit totals as legacy deltas. Old open tabs retain their original protocol. diff --git a/test/stale-session.test.js b/test/stale-session.test.js new file mode 100644 index 0000000..2693c33 --- /dev/null +++ b/test/stale-session.test.js @@ -0,0 +1,97 @@ +'use strict'; +// Stale reading sessions: a reader tab left open on another device must not move "Continue" back. +const test = require('node:test'), assert = require('node:assert/strict'); +const fs = require('fs'), os = require('os'), path = require('path'), net = require('net'); +const { spawn } = require('child_process'); +const { Canvas } = require('../tools/png'); + +async function freePort() { + const listener = net.createServer(); + await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve)); + const port = listener.address().port; + await new Promise(resolve => listener.close(resolve)); + return port; +} + +test('a save from a tab that missed a newer position is refused until the reader stays', { timeout: 30000 }, async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-stale-')); + let child; + try { + const library = path.join(root, 'library'), data = path.join(root, 'data'); + for (const ch of ['c001 One', 'c002 Two', 'c003 Three']) { + const dir = path.join(library, 'Moon Garden', 'v01', ch); + fs.mkdirSync(dir, { recursive: true }); + for (const n of [1, 2, 3]) fs.writeFileSync(path.join(dir, `00${n}.png`), new Canvas(60, 90, [40 * n, 60, 120]).png()); + } + fs.mkdirSync(data); + const port = await freePort(), output = []; + child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true, + env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0', + SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '', GREATEST_COVER_WARM: '0' } }); + child.stdout.on('data', d => output.push(String(d))); + child.stderr.on('data', d => output.push(String(d))); + const base = `http://127.0.0.1:${port}`; + let ready = false; + for (let n = 0; n < 100 && !ready; n++) { + ready = await fetch(base + '/health').then(r => r.ok).catch(() => false); + if (!ready) await new Promise(resolve => setTimeout(resolve, 50)); + } + assert.ok(ready, output.join('')); + const post = (cookie, url, body) => fetch(base + url, { method: 'POST', + headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify(body) }).then(r => r.json()); + const get = (cookie, url) => fetch(base + url, { headers: { cookie } }).then(r => r.json()); + const signup = await fetch(base + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'alice', password: 'alice-password' }) }); + const alice = signup.headers.get('set-cookie').split(';')[0]; + await post(alice, '/api/admin/rescan', { full: true }); + + const manga = await get(alice, '/api/manga?m=Moon%20Garden'); + const [one, two, three] = manga.chapters.map(c => ({ c: c.id, v: c.versions[0].id })); + const save = (ch, extra) => post(alice, '/api/progress', { m: 'Moon Garden', ...ch, p: 1, n: 3, ...extra }); + const latest = () => get(alice, '/api/progress/latest?m=Moon%20Garden').then(r => r.latest); + + // Phone reads chapter 1, the tablet then reads on in chapter 3. + const first = await save(one, { tab: 'phone-tab-1', seen: 0 }); + assert.equal(first.ok, true); + const phoneSeen = first.t; + await new Promise(resolve => setTimeout(resolve, 5)); + const tablet = await save(three, { tab: 'tablet-tab-1', seen: phoneSeen }); + assert.equal(tablet.ok, true); + assert.equal((await latest()).c, three.c); + + // The phone's old tab wakes up and saves chapter 1 again: refused, the head stays on chapter 3. + const refused = await save(one, { tab: 'phone-tab-1', seen: phoneSeen, p: 2 }); + assert.equal(refused.ok, false); + assert.equal(refused.stale, true); + assert.equal(refused.latest.c, three.c); + assert.equal(refused.latest.w, 'tablet-tab-1'); + assert.equal(refused.latest.continue.c, three.c); + assert.match(refused.latest.continue.text, /Continue/); + const after = await get(alice, '/api/manga?m=Moon%20Garden'); + assert.equal(after.continue.c, three.c); + assert.equal(after.continue.at, tablet.t); + assert.equal(after.chapters[0].progress.p, 1, 'the stale save changed nothing'); + + // The chapter view tells a reader which head it is compared with. + const view = await get(alice, `/api/chapter?m=Moon%20Garden&c=${encodeURIComponent(one.c)}`); + assert.equal(view.latest.c, three.c); + assert.equal(view.latest.t, tablet.t); + + // The tablet's own saves never conflict with themselves, even with an older `seen` still in flight. + assert.equal((await save(three, { tab: 'tablet-tab-1', seen: phoneSeen, p: 2 })).ok, true); + + // "Keep reading here": the phone now knows the newest time and its save becomes the head. + const head = await latest(); + const stayed = await save(one, { tab: 'phone-tab-1', seen: head.t, p: 2 }); + assert.equal(stayed.ok, true); + assert.equal((await latest()).c, one.c); + assert.equal((await get(alice, '/api/manga?m=Moon%20Garden')).continue.c, one.c); + + // Saves without `seen` (mark as read on the manga page, older clients) behave as before. + assert.equal((await save(two, { done: true })).ok, true); + assert.equal((await latest()).c, two.c); + } finally { + if (child && child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); } + fs.rmSync(root, { recursive: true, force: true }); + } +});