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>
This commit is contained in:
1 parent
03767d5704
commit
e3e57b60bd
9 files changed
+326
-27
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user