Compare commits

...
1 Commits
Author SHA1 Message Date
MaashkinoozheandClaude Opus 5.5 e3e57b60bd Reader settings per login session
Each sign-in keeps its own reader mode, zoom, page tone and skipping once
it changes one; sessions that never changed follow the latest set, and a
new sign-in starts from the session used last. Settings -> Reading shows
every session's state, lets a session follow the latest again or become
the latest, and turns the feature off for one shared set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:45:44 +01:00
9 changed files with 326 additions and 27 deletions

No files matched your search

+1
View File
@@ -11,6 +11,7 @@
!reading-analytics.js
!reading-access.js
!moments.js
!reader-sessions.js
!public/
!public/**
!greatest/
+1 -1
View File
@@ -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
+4
View File
@@ -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
+26 -5
View File
@@ -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": { "<sha256 of token>": { "u": "admin", "t": 1790796794000 } },
"sessions": { "<sha256 of token>": { "u": "admin", "t": 1790796794000, "seen": 1790796794000,
"agent": "<User-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["<METHOD> <path>"]`. 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["<METHOD> <path>"]`. 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 |
@@ -269,6 +274,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
+2
View File
@@ -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; }
+52
View File
@@ -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',
+88
View File
@@ -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 };
+46 -21
View File
@@ -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];
@@ -471,11 +482,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 })) }; },
@@ -523,16 +534,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 }) => {
@@ -541,7 +552,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); },
@@ -558,22 +580,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.');
@@ -701,8 +724,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 },
@@ -719,7 +742,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),
};
},
@@ -760,8 +783,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.
@@ -1102,7 +1125,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;
+106
View File
@@ -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 });
}
});