Optional Senpai integration: "Who's here" side sheet and /api/senpai proxy

- server.js: GET /api/senpai/v1/{locate,xray,characters/<ref>[/timeline],portraits/<ref>} forwarded to
  SENPAI_URL (plain http/https, no dependencies) with SENPAI_TOKEN, X-Senpai-User and the user's horizon
  (furthest chapter with progress, its version and furthest page); off when SENPAI_URL is unset
- progress records "top", the furthest page read in a version, so paging back does not lower the horizon
- /api/me reports senpai: true|false
- reader: Who's here button (key x) with This page / This chapter / So far, character card, timeline with
  jump links, figure outlines on the page; options "only up to this page" and "show unconfirmed"
- read.html?...&p=<index> opens a page; readUrl takes p
This commit is contained in:
Maashkinoozhe committed 2026-10-17 16:41:46 +02:00
1 parent cfb2d79103
commit f3304732fb
6 files changed
+257 -10

No files matched your search

+51 -4
View File
@@ -7,8 +7,10 @@
* SIGNUP=closed|open (the very first account can always be created and becomes admin)
* SCAN_INTERVAL=10 minutes between automatic library rescans, 0 = off
* COOKIE_SECURE=1 mark the session cookie Secure (when served over HTTPS)
* SENPAI_URL=http://senpai-api:8780 optional story companion ("Who's here"); off when unset
* SENPAI_TOKEN=... service token for SENPAI_URL
*/
const http = require('http'), fs = require('fs'), path = require('path'), crypto = require('crypto');
const http = require('http'), https = require('https'), fs = require('fs'), path = require('path'), crypto = require('crypto');
const { scanLibrary } = require('./library');
const PORT = +process.env.PORT || 8080;
@@ -17,6 +19,8 @@ const DATA = path.resolve(process.env.DATA || 'data');
const OPEN_SIGNUP = process.env.SIGNUP === 'open';
const SCAN_MINUTES = +(process.env.SCAN_INTERVAL || 10);
const SECURE_COOKIE = process.env.COOKIE_SECURE === '1';
const SENPAI = process.env.SENPAI_URL ? process.env.SENPAI_URL.replace(/\/?$/, '/') : '';
const SENPAI_TOKEN = process.env.SENPAI_TOKEN || '';
const PUB = path.join(__dirname, 'public');
const IMG = /\.(jpe?g|png|webp|gif|avif|bmp)$/i;
const MIME = {
@@ -182,7 +186,7 @@ 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) }),
'GET /api/me': ({ u }) => ({ name: u.name, admin: !!u.admin, settings: settingsOf(u), unread: unreadOf(u), senpai: !!SENPAI }),
'POST /api/settings': ({ u, b }) => {
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.');
@@ -256,9 +260,12 @@ const routes = {
'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 v = String(b.v || ''), p = Math.max(0, b.p | 0);
mp[ch.id] = {
v: String(b.v || ''), p: Math.max(0, b.p | 0), n: Math.max(0, b.n | 0), t: Date.now(), done: !!(old.done || b.done),
v, p, n: Math.max(0, b.n | 0), t: Date.now(), done: !!(old.done || b.done),
shift: Array.isArray(b.shift) ? b.shift.filter(Number.isInteger).slice(0, 1000) : old.shift || [],
// furthest page read in this version (paging back to re-read does not lower the reading horizon)
top: Math.max(p, old.v === v ? old.top | 0 || old.p | 0 : 0),
};
save();
return { ok: true };
@@ -318,6 +325,42 @@ const routes = {
},
};
// ---------- Senpai proxy (optional) ----------
// GET /api/senpai/v1/... is forwarded to SENPAI_URL with the service token, the user name and the user's reading
// horizon in this manga: the furthest chapter (in reading order) with saved progress, its version and page.
// Senpai never sees Circus sessions, and the browser can't raise its own horizon.
const SENPAI_PATHS = /^v1\/(locate|xray|characters\/[A-Za-z0-9-]{1,80}(\/timeline)?|portraits\/[A-Za-z0-9-]{1,80})$/;
function horizonOf(u, m) {
const prog = progressOf(u, m);
let last = null;
for (const ch of m.chapters) if (prog[ch.id]) last = { ch, p: prog[ch.id] };
if (!last) return '';
const enc = encodeURIComponent;
const page = Math.max(last.p.p | 0, last.p.top | 0);
return `m=${enc(m.id)};c=${enc(last.ch.id)};v=${enc(last.p.v || '')};p=${page};done=${last.p.done ? 1 : 0}`;
}
function senpaiProxy(req, res, u, rest, q) {
if (!SENPAI) bad(404, 'Senpai is not enabled');
if (req.method !== 'GET') bad(405, 'Method not allowed');
if (!SENPAI_PATHS.test(rest)) bad(404, 'Unknown endpoint');
const m = mangaOf(q.get('m'));
const target = new URL(rest + '?' + q.toString(), SENPAI);
const headers = { Authorization: 'Bearer ' + SENPAI_TOKEN, 'X-Senpai-User': u.name, 'X-Senpai-Horizon': horizonOf(u, m), Accept: req.headers.accept || '*/*' };
const up = (target.protocol === 'https:' ? https : http).request(target, { method: 'GET', headers, timeout: 15000 }, r => {
const out = { 'Content-Type': r.headers['content-type'] || 'application/octet-stream', 'Cache-Control': r.headers['cache-control'] || 'no-store' };
if (r.headers['content-length']) out['Content-Length'] = r.headers['content-length'];
res.writeHead(r.statusCode, out);
r.pipe(res);
});
up.on('timeout', () => up.destroy(new Error('timeout')));
up.on('error', e => {
console.warn(`[senpai] ${e.message}`);
if (!res.headersSent) sendJson(res, 502, { error: 'Senpai is not reachable' });
else res.destroy();
});
up.end();
}
// ---------- HTTP ----------
function sendJson(res, code, obj) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
@@ -355,6 +398,10 @@ http.createServer(async (req, res) => {
try {
const { pathname, searchParams } = new URL(req.url, 'http://localhost');
const u = userOf(req);
if (pathname.startsWith('/api/senpai/')) {
if (!u) bad(401, 'Please log in');
return senpaiProxy(req, res, u, pathname.slice('/api/senpai/'.length), searchParams);
}
if (pathname.startsWith('/api/')) {
const route = routes[req.method + ' ' + pathname];
if (!route) bad(404, 'Unknown endpoint');
@@ -371,7 +418,7 @@ http.createServer(async (req, res) => {
if (!(e instanceof HttpError)) console.error(e);
if (!res.headersSent) sendJson(res, e.code || 500, { error: e instanceof HttpError ? e.message : 'Internal error' });
}
}).listen(PORT, () => console.log(`MangaCircus on http://localhost:${PORT} (library: ${LIB}, data: ${DATA})`));
}).listen(PORT, () => console.log(`MangaCircus on http://localhost:${PORT} (library: ${LIB}, data: ${DATA})${SENPAI ? ' senpai: ' + SENPAI : ''}`));
rescan();
if (SCAN_MINUTES > 0) setInterval(() => rescan(), SCAN_MINUTES * 60000).unref();