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:
1 parent
cfb2d79103
commit
f3304732fb
6 files changed
+257
-10
No files matched your search
@@ -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();
|
||||
Reference in new issue
Block a user