diff --git a/.dockerignore b/.dockerignore index 5de4c11..56be159 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,6 +11,7 @@ !reading-analytics.js !reading-access.js !moments.js +!reader-sessions.js !public/ !public/** !greatest/ diff --git a/Dockerfile b/Dockerfile index 656cfc4..83870c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ RUN apk add --no-cache tzdata openssl WORKDIR /app ARG VCS_REF=development LABEL org.opencontainers.image.title="MangaCircus" org.opencontainers.image.revision=$VCS_REF -COPY package.json server.js library.js reading-direction.js chapter-identity.js chapter-dates.js reading-analytics.js reading-access.js catalog.js backup.js moments.js ./ +COPY package.json server.js library.js reading-direction.js chapter-identity.js chapter-dates.js reading-analytics.js reading-access.js catalog.js backup.js moments.js reader-sessions.js ./ COPY public ./public COPY greatest ./greatest COPY discovery ./discovery diff --git a/README.md b/README.md index a1d9700..c6936c1 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,10 @@ Settings has **User** and **Admin** menu groups. User sections cover Reading, Re Notifications and Account. Admin sections cover Scraper, Server & library, Users & access, Collections & metadata and Service health. Only admins see administration ([settings navigation](docs/settings-navigation.md)). +Reader settings (mode, zoom, page tone, skipping) are kept per sign-in: each device or browser keeps its own once you +change one there, and a new sign-in starts from the one you used last. **Settings → Reading → Reader settings per +session** shows every session's state and turns this off for one shared set ([details](docs/architecture.md#67-reader-settings-per-session)). + ### Settings (environment variables) These configure the reader process. In Docker most are set for you by `docker-compose.yml`; the variables diff --git a/docs/architecture.md b/docs/architecture.md index 8204600..5329da1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -130,11 +130,14 @@ This keeps ids human-readable and derivable without a database. The price: renam "readingStory": true, // show private retrospective summaries when history exists "continueRow": true, // library: "Continue where you left off" row at the top "zoom": { "flow": 1, "page": 1.5, "book": 1, "strip": 0.8 }, // page zoom per mode - "webhook": "" - } + "webhook": "", + "readerPerSession": true // reader settings per login session (section 6.7) + }, + "readerFrom": "3f9c…" // public id of the session the latest reader settings came from } }, - "sessions": { "": { "u": "admin", "t": 1790796794000 } }, + "sessions": { "": { "u": "admin", "t": 1790796794000, "seen": 1790796794000, + "agent": "", "reader": { …locked reader settings, only after a change… } } }, "progress": { "admin": { "Moon Garden": { "1/2": { "v": "c0002 Roots [MangaDex]", // version read "p": 4, "n": 14, // page index in that version (all pages), page count @@ -177,7 +180,7 @@ This keeps ids human-readable and derivable without a database. The price: renam | Path | Handling | |------|----------| -| `/api/*` | `routes[" "]`. Handlers get `{ u, b, q, req, res }` and return an object, which is sent as JSON. Throwing `HttpError(code, message, extra)` sends `{ error, ...extra }` with that status (for example `nextAllowedAt` on a discovery `429`) | +| `/api/*` | `routes[" "]`. Handlers get `{ u, b, q, req, res, sess, sk }` (`sess` = the login session record, `sk` its key) and return an object, which is sent as JSON. Throwing `HttpError(code, message, extra)` sends `{ error, ...extra }` with that status (for example `nextAllowedAt` on a discovery `429`) | | `/lib/*` | Image files from `LIBRARY`. Requires a session, allows image extensions only, and the path must resolve inside `LIBRARY`. `ETag` + `private, max-age=86400` | | everything else | Static files from `public/` (`/` → `index.html`). `ETag` + `no-cache` | @@ -194,7 +197,9 @@ All requests and responses are JSON. POSTs require `Content-Type: application/js | `POST /api/login` `{name, password}` | public | Starts a session (cookie `sid`) | | `POST /api/logout` | user | Ends the session | | `GET /api/me` | user | Name, admin flag, settings, unread count | -| `POST /api/settings` `{…partial}` | user | Validated update of the settings keys above | +| `POST /api/settings` `{…partial}` | user | Validated update of the settings keys above; reader keys go to the current session (6.7) | +| `GET /api/reader-sessions` | user | `{ enabled, current, latestFrom, latest, sessions: [{ id, device, created, seen, current, locked, reader }] }` | +| `POST /api/reader-sessions` `{id, action}` | user | `follow`: the session drops its own reader settings and follows the latest; `latest`: its settings become the latest | | `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 | @@ -270,6 +275,22 @@ The reader alone talks to AniList for discovery ([discovery.md](discovery.md)). (all reasons, strongest first) next to `reason`; seed evidence carries `fetchedAt`. - `DISCOVERY_ANILIST_URL` overrides the AniList endpoint (tests point it at a local fixture server). +### 6.7 Reader settings per session + +The reader keys (`startMode`, `lastMode`, `adapt`, `skipExtras`, `skipCovers`, `flowPageGap`, `pageTone`, `zoom`) +can differ per login session, so a phone and a desktop keep their own mode and zoom (`reader-sessions.js`): + +- `user.settings` holds the **latest** reader set. A session without its own set follows it. +- The first reader change in a session copies the latest set into `session.reader` with the change applied: the session + is now **locked** and keeps that set whatever other sessions do. The change also becomes the latest set. +- Loading a page (`GET /api/me`) from a locked session makes its set the latest again, so a new sign-in starts from the + session used last. A new sign-in has no set of its own until it changes something. +- Other settings (Reading Story, dates, discovery, webhook, …) stay per user. +- `readerPerSession: false` (Settings → Reading) brings back one shared set: changes go to `user.settings` and apply to + every session. Locked sets are kept and apply again when the switch is turned back on. + +Settings → Reading → *Reader settings per session* lists the user's sessions (device from the User-Agent, sign-in and last +activity, own or following) with "Follow latest again" and "Use for new sessions". ## 7. Frontend ### 7.1 Pages diff --git a/public/app.css b/public/app.css index 7111d70..228dfab 100644 --- a/public/app.css +++ b/public/app.css @@ -387,6 +387,8 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; } .login input { width: 100%; } .saved { color: #7fd3a0; font-size: 13px; } .reading-data { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--line); } +.reader-sessions { list-style: none; margin: 12px 0 0; padding: 0; } +.reader-sessions li { display: grid; gap: 4px; padding: 10px 0; border-top: 1px solid var(--line); overflow-wrap: anywhere; } .reading-data > summary { cursor: pointer; font-weight: 600; } .reading-danger { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); } .reading-danger p { margin: 3px 0 8px; } diff --git a/public/settings.js b/public/settings.js index 28e7166..11dc629 100644 --- a/public/settings.js +++ b/public/settings.js @@ -15,11 +15,61 @@ const saved = h('span', { class: 'saved', hidden: true }, 'Saved'); shell.titleRow.append(saved); let savedTimer = 0; + let refreshSessions = () => {}; async function save(patch) { Object.assign(S, (await api('api/settings', patch)).settings); saved.hidden = false; clearTimeout(savedTimer); savedTimer = setTimeout(() => { saved.hidden = true; }, 1500); + refreshSessions(); + } + + // Which login session holds which reader settings (reader-sessions.js on the server). + function readerSessionsCard() { + const TONE = { original: 'Original', inverted: 'Inverted', sepia: 'Sepia', dim: 'Dim', 'extra-dim': 'Extra dim' }; + const START = { last: 'last mode', flow: 'always Flow', page: 'always Page', book: 'always Book' }; + const summary = r => [`${LABEL[r.lastMode] || r.lastMode} (opens: ${START[r.startMode] || r.startMode})`, `tone ${TONE[r.pageTone] || r.pageTone}`, + 'zoom ' + (Object.keys(r.zoom || {}).length ? Object.entries(r.zoom).map(([k, z]) => `${LABEL[k] || k} ${Math.round(z * 100)}%`).join(', ') : 'fit'), + r.skipExtras ? 'skips extras' : null, r.skipCovers ? 'skips covers' : null, r.flowPageGap === false ? 'no Flow gap' : null].filter(Boolean).join(' · '); + const state = h('div', {}, h('p', { class: 'muted' }, 'Loading sessions…')); + const message = h('span', { class: 'small' }); + async function act(id, action, current) { + try { + draw(await api('api/reader-sessions', { id, action })); + // This page's controls show this session's settings; reload them when they changed. + if (current && action === 'follow') location.reload(); + } catch (error) { message.textContent = error.message; message.className = 'small error'; } + } + function draw(o) { + const current = o.sessions.find(s => s.current), source = o.sessions.find(s => s.id === o.latestFrom); + const latestText = !o.latestFrom ? 'one shared set (changed while per-session settings were off, or never changed)' + : source ? (source.current ? 'this session' : `${source.device}, active ${ago(source.seen)}`) : 'a session that has since signed out'; + const intent = !o.enabled ? 'Off: every session uses the same reader settings, and a change applies everywhere.' + : current?.locked ? 'This session has its own reader settings. Changes here stay here and become the starting point for new sessions.' + : 'This session follows the latest reader settings. Your first change here gives it its own copy, which stays put when other sessions change theirs.'; + state.replaceChildren( + h('p', {}, h('b', {}, 'Now: '), intent), + o.enabled ? h('p', { class: 'muted small' }, 'New sessions start from the latest settings: ', h('b', {}, summary(o.latest)), ` — from ${latestText}.`) + : h('p', { class: 'muted small' }, 'Shared settings: ', h('b', {}, summary(o.latest))), + h('ul', { class: 'reader-sessions' }, o.sessions.map(s => h('li', {}, + h('div', {}, h('b', {}, s.device), s.current ? ' (this session)' : '', ' · ', + h('span', { class: 'muted small' }, `signed in ${new Date(s.created).toLocaleDateString()}, active ${ago(s.seen)}`)), + h('div', { class: 'small' }, !o.enabled ? 'Uses the shared settings' + (s.locked ? ' (its own copy is kept for when this is on again)' : '') + : s.locked ? 'Own settings: ' + summary(s.reader) : 'Follows the latest settings'), + o.enabled ? h('div', { class: 'row' }, + s.locked ? h('button', { class: 'btn small', onclick: () => act(s.id, 'follow', s.current) }, 'Follow latest again') : null, + s.locked && s.id !== o.latestFrom ? h('button', { class: 'btn small', onclick: () => act(s.id, 'latest', s.current) }, 'Use for new sessions') : null) : null))), + message); + } + refreshSessions = () => api('api/reader-sessions').then(draw, error => { message.textContent = error.message; message.className = 'small error'; }); + refreshSessions(); + return h('section', { class: 'card' }, + h('h2', {}, 'Reader settings per session'), + // The controls above switch between this session's set and the shared one, so reload them. + h('label', { class: 'check' }, + h('input', { type: 'checkbox', checked: !!S.readerPerSession, onchange: async e => { await save({ readerPerSession: e.target.checked }); location.reload(); } }), + h('span', {}, 'Keep reader settings per session', h('div', { class: 'muted small' }, 'Each sign-in (device or browser) keeps its own reader mode, zoom, tone and the other reader preferences above once you change one there. A new sign-in copies the settings of the session you used last. Turn this off for one shared set.'))), + state); } const check = (key, text, hint) => h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: !!S[key], onchange: e => save({ [key]: e.target.checked }) }), @@ -90,6 +140,8 @@ h('button', { class: 'btn small', onclick: async () => { await save({ zoom: {} }); zoomText.textContent = zooms(); } }, 'Reset to fit'), h('div', { class: 'muted small' }, '100% = the page fits the screen. Change it in the reader with Ctrl + mouse wheel, a pinch, or the + / - keys.')))); + section('user/reading').append(readerSessionsCard()); + section('user/reading').append(h('section', { class: 'card' }, h('h2', {}, 'Library page'), check('continueRow', 'Show "Continue where you left off" at the top', diff --git a/reader-sessions.js b/reader-sessions.js new file mode 100644 index 0000000..79cab7a --- /dev/null +++ b/reader-sessions.js @@ -0,0 +1,88 @@ +'use strict'; +/* Reader settings per login session (docs/architecture.md, "Reader settings per session"). + * + * The user's own reader settings (`user.settings`) are the "latest" set. A session that never changed a + * reader setting follows the latest set; the first change copies the latest set into the session and + * locks it there, and also makes it the new latest set. Using a locked session makes its set the latest + * again, so a new login starts from the last active session. With `readerPerSession: false` there is + * one set per user, as before; locked sets are kept and return when the switch is turned on again. */ +const crypto = require('crypto'); + +const READER_KEYS = ['startMode', 'lastMode', 'adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'pageTone', 'zoom']; + +const perSession = settings => settings.readerPerSession !== false; +const publicId = key => crypto.createHash('sha256').update('reader-session:' + key).digest('hex').slice(0, 16); + +function pick(settings) { + const out = {}; + for (const k of READER_KEYS) if (settings[k] !== undefined) out[k] = k === 'zoom' ? { ...settings[k] } : settings[k]; + return out; +} + +// `base` is the user's merged settings (defaults + user.settings); the session's locked set wins over it. +function effective(base, session) { + if (!perSession(base) || !session?.reader) return base; + return { ...base, ...pick(session.reader), zoom: { ...session.reader.zoom } }; +} + +// Splits a validated settings patch: reader keys go to the session (locking it) and to the latest set. +function applyPatch({ user, base, session, key, patch }) { + const s = (user.settings = user.settings || {}); + const reader = pick(patch), rest = Object.fromEntries(Object.entries(patch).filter(([k]) => !READER_KEYS.includes(k))); + Object.assign(s, rest); + if (!Object.keys(reader).length) return; + if (perSession({ ...base, ...rest }) && session) { + session.reader = { ...pick(effective(base, session)), ...reader }; + Object.assign(s, pick(session.reader)); + user.readerFrom = publicId(key); + } else { + Object.assign(s, reader); + user.readerFrom = null; + } +} + +// A request from a locked session makes its set the latest one. Returns true when something changed. +function noteActive(user, base, session, key) { + if (!perSession(base) || !session?.reader) return false; + const id = publicId(key); + if (user.readerFrom === id) return false; + user.settings = { ...(user.settings || {}), ...pick(session.reader) }; + user.readerFrom = id; + return true; +} + +function deviceLabel(agent) { + const a = String(agent || ''); + if (!a) return 'Unknown device'; + const os = /iPhone/.test(a) ? 'iPhone' : /iPad/.test(a) ? 'iPad' : /Android/.test(a) ? 'Android' + : /Windows/.test(a) ? 'Windows' : /Mac OS X|Macintosh/.test(a) ? 'Mac' : /CrOS/.test(a) ? 'ChromeOS' : /Linux/.test(a) ? 'Linux' : ''; + const browser = /Edg\//.test(a) ? 'Edge' : /OPR\//.test(a) ? 'Opera' : /Firefox\//.test(a) ? 'Firefox' + : /Chrome\//.test(a) ? 'Chrome' : /Safari\//.test(a) ? 'Safari' : ''; + return [browser, os].filter(Boolean).join(' on ') || a.slice(0, 60); +} + +// What Settings shows: every login session of the user with its state and effective reader set. +function overview(user, base, sessions, currentKey) { + const list = Object.entries(sessions).filter(([, s]) => s.u === user.name).map(([key, s]) => ({ + id: publicId(key), device: deviceLabel(s.agent), created: s.t, seen: s.seen || s.t, + current: key === currentKey, locked: !!s.reader, reader: pick(effective(base, s)), + })).sort((a, b) => b.current - a.current || b.seen - a.seen); + return { enabled: perSession(base), current: currentKey ? publicId(currentKey) : null, + latestFrom: user.readerFrom || null, latest: pick(base), sessions: list }; +} + +// Settings actions on one of the user's sessions: 'follow' drops its lock, 'latest' makes its set the latest. +function act(user, base, sessions, id, action) { + const entry = Object.entries(sessions).find(([key, s]) => s.u === user.name && publicId(key) === id); + if (!entry) return false; + const [key, session] = entry; + if (action === 'follow') { delete session.reader; if (user.readerFrom === id) user.readerFrom = null; return true; } + if (action === 'latest') { + user.settings = { ...(user.settings || {}), ...pick(effective(base, session)) }; + user.readerFrom = session.reader ? publicId(key) : null; + return true; + } + return false; +} + +module.exports = { READER_KEYS, publicId, pick, effective, applyPatch, noteActive, deviceLabel, overview, act }; diff --git a/server.js b/server.js index c01ab2b..e018bd0 100644 --- a/server.js +++ b/server.js @@ -40,6 +40,7 @@ const { BackupService, BackupError } = require('./backup'); const moments = require('./moments'); const { Tracer, requestTrace } = require('./perf/trace'); const { ReadAhead } = require('./perf/read-ahead'); +const readerSessions = require('./reader-sessions'); const PORT = +process.env.PORT || 8080; const LIB = path.resolve(process.env.LIBRARY || 'library'); @@ -119,8 +120,10 @@ function checkPw(pw, stored) { } const DUMMY_HASH = hashPw('x'); const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, flowPageGap: true, pageTone: 'original', readingClock: false, readingStory: true, - continueRow: true, readDate: 'hour', releaseDate: true, discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '' }; -const settingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } }); + continueRow: true, readDate: 'hour', releaseDate: true, discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '', readerPerSession: true }; +const userSettingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } }); +// With a login session, its locked reader settings (reader-sessions.js) win over the user's latest set. +const settingsOf = (u, sess) => readerSessions.effective(userSettingsOf(u), sess); function createUser(name, pw, admin, replace) { name = String(name || '').trim().toLowerCase(); pw = String(pw || ''); @@ -132,13 +135,21 @@ function createUser(name, pw, admin, replace) { save(); return db.users[name]; } -function startSession(res, user) { +function startSession(req, res, user) { const token = crypto.randomBytes(32).toString('base64url'); - db.sessions[sha(token)] = { u: user.name, t: Date.now() }; + db.sessions[sha(token)] = { u: user.name, t: Date.now(), agent: String(req.headers['user-agent'] || '').slice(0, 300) }; save(); res.setHeader('Set-Cookie', `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${SECURE_COOKIE ? '; Secure' : ''}`); } const tokenOf = req => { const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || ''); return m ? m[1] : null; }; +function sessionKeyOf(req) { const t = tokenOf(req); return t ? sha(t) : null; } +// Last-seen time (coarse, to keep db.json writes rare) and device label of a login session. +function touchSession(u, sess, key, req) { + let changed = false; + if (!sess.agent && req.headers['user-agent']) { sess.agent = String(req.headers['user-agent']).slice(0, 300); changed = true; } + if (Date.now() - (sess.seen || 0) > 5 * 60e3) { sess.seen = Date.now(); changed = true; } + if (changed) save(); +} function userOf(req) { const t = tokenOf(req), s = t && db.sessions[sha(t)]; return (s && db.users[s.u]) || null; } for (const [k, s] of Object.entries(db.sessions)) if (Date.now() - s.t > 365 * 864e5 || !db.users[s.u]) delete db.sessions[k]; @@ -485,11 +496,11 @@ const PUBLIC = new Set(['/api/auth', '/api/login', '/api/signup']); const routes = { 'GET /api/health': () => ({ ok: !scanState.error, service: 'reader', uptime: Math.round(process.uptime()), scan: scanState }), 'GET /api/collections': ({ u }) => ({ collections: routes['GET /api/library']({ u }).collections }), - 'GET /api/collection': ({ u, q }) => { + 'GET /api/collection': ({ u, q, sess }) => { const view = routes['GET /api/library']({ u }).collections.find(c => c.id === q.get('id')) || bad(404, 'Collection not found'); const next = view.members.find(m => m.read < m.chapters) || view.members[0]; const manga = mangaOf(next.id); - return { ...view, continue: { m: next.id, ...continueOf(manga, progressOf(u, manga), settingsOf(u)) }, + return { ...view, continue: { m: next.id, ...continueOf(manga, progressOf(u, manga), settingsOf(u, sess)) }, relations: catalog.state.relations.filter(r => r.state === 'confirmed' && view.members.some(m => m.id === r.from || m.id === r.to)) }; }, 'GET /api/admin/catalog': ({ u }) => { admin(u); return { ...catalog.state, types: TYPES, roles: ROLES, mangas: lib.mangas.map(m => ({ id: m.id, title: m.title })) }; }, @@ -537,16 +548,16 @@ const routes = { }, 'GET /api/auth': () => ({ firstRun: !Object.keys(db.users).length, signup: OPEN_SIGNUP }), - 'POST /api/signup': ({ b, res }) => { + 'POST /api/signup': ({ req, b, res }) => { const first = !Object.keys(db.users).length; if (!first && !OPEN_SIGNUP) bad(403, 'Sign-up is closed. Ask an admin for an account.'); - startSession(res, createUser(b.name, b.password, first)); + startSession(req, res, createUser(b.name, b.password, first)); return { ok: true }; }, - 'POST /api/login': ({ b, res }) => { + 'POST /api/login': ({ req, b, res }) => { const user = db.users[String(b.name || '').trim().toLowerCase()], pw = String(b.password || ''); if (!checkPw(pw, user ? user.hash : DUMMY_HASH) || !user) bad(403, 'Wrong user name or password.'); - startSession(res, user); + startSession(req, res, user); return { ok: true }; }, 'POST /api/logout': ({ req, res }) => { @@ -555,7 +566,18 @@ const routes = { res.setHeader('Set-Cookie', 'sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'); return { ok: true }; }, - 'GET /api/me': ({ u }) => ({ name: u.name, admin: !!u.admin, settings: settingsOf(u), unread: unreadOf(u) }), + // Every page loads /api/me, so a page view marks its session as the last active one for reader settings. + 'GET /api/me': ({ u, sess, sk }) => { + if (sess && readerSessions.noteActive(u, userSettingsOf(u), sess, sk)) save(); + return { name: u.name, admin: !!u.admin, settings: settingsOf(u, sess), unread: unreadOf(u) }; + }, + 'GET /api/reader-sessions': ({ u, sk }) => readerSessions.overview(u, userSettingsOf(u), db.sessions, sk), + 'POST /api/reader-sessions': ({ u, b, sk }) => { + if (!['follow', 'latest'].includes(b.action)) bad(400, 'Unknown action'); + if (!readerSessions.act(u, userSettingsOf(u), db.sessions, String(b.id || ''), b.action)) bad(404, 'Session not found'); + save(); + return readerSessions.overview(u, userSettingsOf(u), db.sessions, sk); + }, 'GET /api/reading-schedule': () => publicSchedule(readingSchedule), 'GET /api/reading-access': ({ u, q }) => { mangaOf(q.get('m')); return readingAccess.status(u.name, true); }, @@ -572,22 +594,23 @@ const routes = { return { ok: true }; }, - 'POST /api/settings': ({ u, b }) => { + 'POST /api/settings': ({ u, b, sess, sk }) => { if (typeof b.webhook === 'string' && b.webhook.trim() && !/^https?:\/\/\S+$/i.test(b.webhook.trim())) bad(400, 'The webhook must be an http(s) URL.'); - const s = (u.settings = u.settings || {}); + const base = userSettingsOf(u), s = {}; if (['last', 'flow', 'page', 'book'].includes(b.startMode)) s.startMode = b.startMode; if (['flow', 'page', 'book'].includes(b.lastMode)) s.lastMode = b.lastMode; if (['original', 'inverted', 'sepia', 'dim', 'extra-dim'].includes(b.pageTone)) s.pageTone = b.pageTone; if (['off', 'date', 'hour'].includes(b.readDate)) s.readDate = b.readDate; for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory', 'releaseDate', 'continueRow', - 'discoveryEnabled', 'discoveryReadingActivity', 'discoveryMature']) if (typeof b[k] === 'boolean') s[k] = b[k]; + 'discoveryEnabled', 'discoveryReadingActivity', 'discoveryMature', 'readerPerSession']) if (typeof b[k] === 'boolean') s[k] = b[k]; if (b.zoom && typeof b.zoom === 'object') { s.zoom = {}; for (const k of ['flow', 'page', 'book', 'strip']) if (+b.zoom[k] > 0) s.zoom[k] = Math.min(8, Math.max(0.1, +b.zoom[k])); } if (typeof b.webhook === 'string') s.webhook = b.webhook.trim().slice(0, 500); + readerSessions.applyPatch({ user: u, base, session: sess, key: sk, patch: s }); save(); - return { settings: settingsOf(u) }; + return { settings: settingsOf(u, sess) }; }, 'POST /api/password': ({ u, b }) => { if (!checkPw(String(b.old || ''), u.hash)) bad(403, 'The current password is wrong.'); @@ -715,8 +738,8 @@ const routes = { return { results: await searchGreatestMangaDex(entry) }; }, - 'GET /api/manga': ({ u, q }) => { - const m = mangaOf(q.get('m')), prog = progressOf(u, m), s = settingsOf(u), showStory = s.readingStory; + 'GET /api/manga': ({ u, q, sess }) => { + const m = mangaOf(q.get('m')), prog = progressOf(u, m), s = settingsOf(u, sess), showStory = s.readingStory; const records = mangaReadingOf(u, m); return { chapterDates: { read: s.readDate, release: s.releaseDate }, @@ -733,7 +756,7 @@ const routes = { dates: s.releaseDate ? chapterDates(ch.versions) : null, })), reading: showStory ? mangaInsight(records, m.chapters.map(ch => ch.id)) : null, - continue: continueOf(m, prog, settingsOf(u)), + continue: continueOf(m, prog, s), }; }, @@ -774,8 +797,8 @@ const routes = { return { ok: true, deleted: m.id }; }, - 'GET /api/chapter': ({ u, q }) => { - const m = mangaOf(q.get('m')), ch = chapterOf(m, q.get('c')), s = settingsOf(u), prog = progressOf(u, m); + 'GET /api/chapter': ({ u, q, sess }) => { + const m = mangaOf(q.get('m')), ch = chapterOf(m, q.get('c')), s = settingsOf(u, sess), prog = progressOf(u, m); if (!readingAccess.status(u.name).allowed) bad(423, 'Reading is outside the deployment schedule. Complete the five-question challenge to unlock it.'); const v = ch.versions.find(x => x.id === q.get('v')) || ranked(m, ch)[0].v; // Opening a chapter clears its "new chapter" notification. @@ -1129,7 +1152,9 @@ async function handleApi(req, res, u, route, q) { if (!/^application\/json/.test(req.headers['content-type'] || '') || req.headers['sec-fetch-site'] === 'cross-site') bad(403, 'Bad request origin'); b = (await readBody(req)) || {}; } - sendJson(res, 200, await route({ req, res, u, b, q })); + const sk = u && sessionKeyOf(req), sess = sk && db.sessions[sk]; + if (sess) touchSession(u, sess, sk, req); + sendJson(res, 200, await route({ req, res, u, b, q, sess, sk })); } function serveFile(req, res, root, rel, cacheControl, only, trace) { let file; diff --git a/test/reader-sessions.test.js b/test/reader-sessions.test.js new file mode 100644 index 0000000..c5166b5 --- /dev/null +++ b/test/reader-sessions.test.js @@ -0,0 +1,106 @@ +'use strict'; +// Reader settings per login session: a session follows the latest set until it changes one, then keeps its own. +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 readerSessions = require('../reader-sessions'); + +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('device labels name browser and system', () => { + assert.equal(readerSessions.deviceLabel('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1'), 'Safari on iPhone'); + assert.equal(readerSessions.deviceLabel('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0 Safari/537.36 Edg/130.0'), 'Edge on Windows'); + assert.equal(readerSessions.deviceLabel(''), 'Unknown device'); +}); + +test('sessions lock their reader settings on the first change, new ones copy the last active one', { timeout: 20000 }, async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-reader-sessions-')); + let child; + try { + const library = path.join(root, 'library'), data = path.join(root, 'data'); + fs.mkdirSync(library); 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 json = async r => { const body = await r.json(); assert.equal(r.status, 200, JSON.stringify(body)); return body; }; + const post = (cookie, url, body) => fetch(base + url, { method: 'POST', + headers: { 'content-type': 'application/json', 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) Firefox/130.0', ...(cookie ? { cookie } : {}) }, + body: JSON.stringify(body) }).then(async r => (url.endsWith('login') || url.endsWith('signup') ? r : json(r))); + const me = async cookie => (await json(await fetch(base + '/api/me', { headers: { cookie } }))).settings; + const overview = cookie => fetch(base + '/api/reader-sessions', { headers: { cookie } }).then(json); + const login = async () => (await post(null, '/api/login', { name: 'reader', password: 'reader-password' })).headers.get('set-cookie').split(';')[0]; + + const a = (await post(null, '/api/signup', { name: 'reader', password: 'reader-password' })).headers.get('set-cookie').split(';')[0]; + // First session: defaults, following the latest set. + assert.equal((await me(a)).lastMode, 'page'); + assert.equal((await overview(a)).sessions[0].locked, false); + + // Session B exists before A changes anything: it follows A's change. + const b = await login(); + await post(a, '/api/settings', { lastMode: 'flow', zoom: { flow: 1.5 } }); + assert.equal((await me(b)).lastMode, 'flow'); + assert.equal((await me(b)).zoom.flow, 1.5); + + // B changes its own: B locks, A keeps its own set. + await post(b, '/api/settings', { pageTone: 'sepia' }); + assert.equal((await me(b)).pageTone, 'sepia'); + assert.equal((await me(b)).lastMode, 'flow'); + assert.equal((await me(a)).pageTone, 'original'); + + // A non-reader setting is shared and does not lock anything. + const c = await login(); + await post(c, '/api/settings', { continueRow: false }); + assert.equal((await me(a)).continueRow, false); + assert.equal((await overview(c)).sessions.find(s => s.current).locked, false); + // C follows the last active session, which is A (it loaded /api/me last). + assert.equal((await me(c)).pageTone, 'original'); + await me(b); + assert.equal((await me(c)).pageTone, 'sepia'); + + // Overview: three sessions, labels, intent. + const o = await overview(a); + assert.equal(o.enabled, true); + assert.equal(o.sessions.length, 3); + assert.equal(o.sessions[0].current, true); + assert.equal(o.sessions[0].device, 'Firefox on Linux'); + assert.deepEqual(o.sessions.map(s => s.locked).sort(), [false, true, true]); + + // "Follow latest again" drops A's lock; "Use for new sessions" makes a set the latest. + const idA = o.sessions[0].id, idB = o.sessions.find(s => !s.current && s.locked).id; + await post(a, '/api/reader-sessions', { id: idA, action: 'follow' }); + assert.equal((await me(a)).pageTone, 'sepia'); + assert.equal((await fetch(base + '/api/reader-sessions', { method: 'POST', headers: { 'content-type': 'application/json', cookie: a }, + body: JSON.stringify({ id: 'nope', action: 'follow' }) })).status, 404); + assert.equal((await post(a, '/api/reader-sessions', { id: idB, action: 'latest' })).latestFrom, idB); + + // Off: one shared set; a change applies everywhere and locks nothing. On again: kept locks return. + await post(b, '/api/settings', { readerPerSession: false }); + await post(a, '/api/settings', { pageTone: 'dim' }); + assert.equal((await me(b)).pageTone, 'dim'); + assert.equal((await me(c)).pageTone, 'dim'); + await post(a, '/api/settings', { readerPerSession: true }); + assert.equal((await me(b)).pageTone, 'sepia'); + assert.equal((await me(a)).pageTone, 'sepia'); + } finally { + if (child && child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); } + fs.rmSync(root, { recursive: true, force: true }); + } +});