From f3304732fb30d8c9fdb23c53d644ec3a7effe911 Mon Sep 17 00:00:00 2001 From: maashkinoozhe Date: Sat, 17 Oct 2026 16:41:46 +0200 Subject: [PATCH] Optional Senpai integration: "Who's here" side sheet and /api/senpai proxy - server.js: GET /api/senpai/v1/{locate,xray,characters/[/timeline],portraits/} 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= opens a page; readUrl takes p --- README.md | 13 ++++ docs/architecture.md | 9 ++- public/app.css | 29 ++++++++ public/common.js | 3 +- public/reader.js | 158 ++++++++++++++++++++++++++++++++++++++++++- server.js | 55 +++++++++++++-- 6 files changed, 257 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 86ebb4e..bebb14f 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ docker compose up -d --build | `SIGNUP` | `closed` | `open` lets anyone create an account. Otherwise the admin adds users under Settings | | `SCAN_INTERVAL` | `10` | Minutes between automatic library rescans (`0` = only at start and on "Rescan") | | `COOKIE_SECURE` | – | `1` marks the login cookie `Secure` (use it when you serve over HTTPS) | +| `SENPAI_URL` | – | Optional: base URL of a [Senpai](#whos-here-senpai-optional) story companion, e.g. `http://senpai-api:8780`. Unset = feature off | +| `SENPAI_TOKEN` | – | Service token for `SENPAI_URL` | Serve it behind a reverse proxy with HTTPS if it is reachable from the internet. All pages use relative URLs, so a sub-path like `/manga/` works. @@ -95,6 +97,17 @@ library/ - **Progress** is saved per user and chapter version: the last page, whether the chapter is done, and book-mode shifts. The manga page offers *Continue*. Tap a chapter's status to mark it read/unread. - **Notifications:** press *Notify me* on a manga. When a rescan finds new chapter numbers, they show up under the bell. Optionally, the server also POSTs a short text to a webhook URL of your choice (e.g. an ntfy.sh or Gotify topic, set in Settings). +## Who's here (Senpai, optional) + +With `SENPAI_URL` set, the reader's top bar gets a **Who's here** button (key `x`). It opens a side sheet with +*This page* / *This chapter* / *So far*: the characters on the visible page(s) (hover or tap outlines them on the +page), the cast of the chapter and of the series with their prominence, and a character card with the names known so +far, first appearance and a timeline whose links jump to the page. Everything is limited to what you have read: the +server forwards `GET /api/senpai/v1/…` to Senpai with your reading horizon in this manga (the furthest page of the +furthest chapter you have opened; paging back to re-read does not lower it). Options in the sheet: *Only up to this +page* (for re-reading) and *Show unconfirmed* (machine findings). Without `SENPAI_URL` nothing changes. +Running both together: Senpai's `docker-compose.circus.yml` and `docs/runbook.md`. + ## Files ``` diff --git a/docs/architecture.md b/docs/architecture.md index d481b39..4478c68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -104,7 +104,8 @@ This keeps ids human-readable and derivable without a database. The price: renam "p": 4, "n": 14, // page index in that version (all pages), page count "t": 1790796800000, // last activity "done": false, // sticky once true (reset via "mark unread") - "shift": [] // book mode: run starts shown alone + "shift": [], // book mode: run starts shown alone + "top": 4 // furthest page read in this version (Senpai horizon) } } } }, "follows": { "admin": { "Moon Garden": true } }, "notes": { "admin": [ { "id": "a1b2c3d4e5f6", "t": 1790797300000, "m": "Moon Garden", "c": "2/6", @@ -154,6 +155,7 @@ All requests and responses are JSON. POSTs require `Content-Type: application/js | `POST /api/admin/rescan` `{full}` | admin | Scan now, and wait for the result | | `POST /api/admin/user` `{name, password, admin}` | admin | Create a user or reset a password | | `POST /api/admin/user/delete` `{name}` | admin | Delete a user and their data | +| `GET /api/senpai/v1/{locate,xray,characters/[/timeline],portraits/}?m=…` | user | Optional proxy to Senpai (`SENPAI_URL`): adds `Authorization: Bearer SENPAI_TOKEN`, `X-Senpai-User` and `X-Senpai-Horizon` (furthest chapter with progress in this manga, its version and `max(p, top)`, percent-encoded). 404 when `SENPAI_URL` is unset; other paths are not forwarded | ### 6.3 Authentication and security @@ -239,7 +241,7 @@ mode = adapt && intended ∉ compatible ? compatible[0] : intended | Horizontal swipe ≥ 50 px within 800 ms (paged, not zoomed in) | Previous/next | | Ctrl + wheel, touchpad pinch, Safari gesture events, two-finger pinch | Page zoom 20–600 %, snapping to 100 % within ±3 %, anchored at the pointer or finger midpoint. Browser zoom is suppressed only over the pages | | Mouse drag (moved ≥ 6 px) | Pan; the following click is swallowed. Hand cursor when the content is larger than the screen | -| Keys | Arrows follow the direction; Space/PageDown/PageUp (paged); `+` `-` `0` zoom; `m`/Esc bars; `f` fullscreen | +| Keys | Arrows follow the direction; Space/PageDown/PageUp (paged); `+` `-` `0` zoom; `m`/Esc bars; `f` fullscreen; `x` Who's here (Esc closes it) | **Bars:** - Shown on open. @@ -272,7 +274,8 @@ docker compose up -d --build - **Volumes:** - `/library`: read-only is enough. Files must be readable by uid 1000. - `/data`: must be writable. -- **Configuration:** only environment variables (`PORT`, `LIBRARY`, `DATA`, `SIGNUP`, `SCAN_INTERVAL`, `COOKIE_SECURE`). +- **Configuration:** only environment variables (`PORT`, `LIBRARY`, `DATA`, `SIGNUP`, `SCAN_INTERVAL`, `COOKIE_SECURE`, optional `SENPAI_URL` + `SENPAI_TOKEN`). +- **With Senpai:** the Senpai repository's `docker-compose.circus.yml` builds this branch next to Senpai and sets `SENPAI_URL=http://senpai-api:8780`; Senpai is reached only through the proxy, never by the browser. - **Without Docker:** `node server.js` with Node ≥ 18. - **Behind a reverse proxy:** terminate TLS, set `COOKIE_SECURE=1`. A sub-path works. - **Upgrades:** replace the code and restart. There are no migrations: unknown fields in `db.json` are kept, and missing settings fall back to defaults. diff --git a/public/app.css b/public/app.css index 33b5489..d983f4a 100644 --- a/public/app.css +++ b/public/app.css @@ -173,3 +173,32 @@ body.nobars #bot { transform: translateY(100%); opacity: 0; pointer-events: none } #toast.show { opacity: 1; } @media (max-width: 560px) { .seg button { padding: 7px 10px; } #bot .btn { padding: 6px 10px; } .wide-only { display: none; } } + +/* Who's here (Senpai): a side sheet in the .panel style */ +.panel.sheet { + position: fixed; top: 0; right: 0; bottom: 0; width: min(380px, 100vw); max-height: none; border-radius: 0; border-width: 0 0 0 1px; + padding: max(6px, env(safe-area-inset-top)) 0 0; display: flex; flex-direction: column; z-index: 25; +} +.sheet-head { display: flex; align-items: flex-start; gap: 8px; padding: 6px 12px; } +.sheet .seg { display: flex; margin: 2px 12px 8px; } +.sheet .seg button { flex: 1; padding: 6px 4px; } +.sheet-body { flex: 1; min-height: 0; overflow: auto; padding: 0 8px 10px; } +.sheet-foot { border-top: 1px solid var(--line); padding: 8px 12px max(8px, env(safe-area-inset-bottom)); } +.sheet-foot .check { margin: 4px 0; } +.who { display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px; border: 0; border-radius: 8px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; } +.who:hover { background: var(--card2); } +.who .pic { width: 44px; height: 44px; flex: none; border-radius: 50%; object-fit: cover; object-position: top; background: var(--card2); } +.pic.none, .big.none { display: flex; align-items: center; justify-content: center; color: var(--muted); font-weight: 700; } +.who .nm { font-weight: 600; overflow: hidden; text-overflow: ellipsis; } +.who .nm.unnamed { color: var(--muted); font-style: italic; font-weight: 400; } +.sp-chip { flex: none; font-size: 11px; border: 1px solid var(--line); border-radius: 6px; padding: 0 5px; color: var(--muted); } +.sp-more { margin: 6px; } +.tier { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); padding: 12px 6px 4px; } +.sp-card { display: flex; gap: 12px; align-items: flex-start; margin: 10px 4px; } +.sp-card h2 { margin: 0 0 6px; font-size: 18px; overflow-wrap: anywhere; } +.sp-card .big { width: 96px; height: 120px; flex: none; border-radius: 10px; object-fit: cover; object-position: top; background: var(--card2); } +.sp-names { margin: 0; padding-left: 22px; } +.sp-time a { display: block; padding: 6px; border-radius: 8px; text-decoration: none; } +.sp-time a:hover { background: var(--card2); } +.sp-box { position: absolute; border: 3px solid var(--accent); border-radius: 6px; box-shadow: 0 0 0 2px rgba(0, 0, 0, .5); pointer-events: none; } +#who.on { border-color: var(--accent); color: #ffb3bd; } diff --git a/public/common.js b/public/common.js index 1749399..c497c54 100644 --- a/public/common.js +++ b/public/common.js @@ -21,6 +21,7 @@ function h(tag, props, ...kids) { function icon(svg) { const t = document.createElement('template'); t.innerHTML = svg.trim(); return t.content.firstChild; } const ICONS = { bell: '', + people: '', }; async function api(path, body) { @@ -50,7 +51,7 @@ function ago(t) { } const qsa = obj => { const p = new URLSearchParams(); for (const k in obj) if (obj[k] != null && obj[k] !== '') p.set(k, obj[k]); return p.toString(); }; -const readUrl = (m, c, v) => 'read.html?' + qsa({ m, c, v }); +const readUrl = (m, c, v, p) => 'read.html?' + qsa({ m, c, v, p }); const mangaUrl = m => 'manga.html?' + qsa({ m }); const safeHref = u => (/^https?:\/\//i.test(u || '') ? u : null); diff --git a/public/reader.js b/public/reader.js index ae58de1..7421002 100644 --- a/public/reader.js +++ b/public/reader.js @@ -51,6 +51,11 @@ const k = list.findIndex(x => x.i >= p); cur = k < 0 ? list.length - 1 : k; } + // read.html?...&p= opens that page (jump links from Who's here). + if (P.get('p') !== null && /^\d+$/.test(P.get('p'))) { + const k = list.findIndex(x => x.i >= +P.get('p')); + if (k >= 0) cur = k; + } // Book mode: pages pair up inside each run of single pages. Runs restart after every double page / cover, // so one missing pair never shifts the rest of the chapter. "Shift" makes a run start with a lone page. @@ -341,8 +346,10 @@ else if (k === '+' || k === '=') setZoom(zoom() * 1.15); else if (k === '-') setZoom(zoom() / 1.15); else if (k === '0') setZoom(1); + else if (k === 'Escape' && senpai && senpai.close()) return; else if (k === 'm' || k === 'Escape') toggleBars(); else if (k === 'f') toggleFullscreen(); + else if (k === 'x' && senpai) senpai.toggle(); }); let scrollRaf = 0; @@ -478,7 +485,7 @@ $('#zoom').textContent = Math.round(zoom() * 100) + '%'; } - let saveTimer = 0; + let saveTimer = 0, senpai = null; function moved() { if (mode === 'flow') { const b = boxes[boxes.length - 1]; @@ -487,11 +494,12 @@ updateUI(); clearTimeout(saveTimer); saveTimer = setTimeout(saveProgress, 700); + if (senpai) senpai.moved(); } function saveProgress(keepalive) { clearTimeout(saveTimer); const body = JSON.stringify({ m: D.manga.id, c: D.chapter.id, v: D.version, p: list[cur].i, n: all.length, done, shift: [...shift] }); - fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body }).catch(() => {}); + return fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body }).catch(() => {}); } addEventListener('pagehide', () => saveProgress(true)); @@ -532,6 +540,152 @@ // Keep keyboard focus on the pages so Space/arrows never "press" the last clicked button again. for (const bar of [$('#top'), $('#bot')]) bar.addEventListener('click', e => { if (e.target.closest('button')) stage.focus({ preventScroll: true }); }); + // ---------- Who's here (Senpai, optional: only when the server has SENPAI_URL) ---------- + // A side sheet in the .panel style with three tabs and a character card. Everything comes from + // api/senpai/v1/..., which the server answers only up to this user's reading horizon. + function whosHere() { + const sheet = h('aside', { class: 'panel sheet', hidden: true, 'aria-label': "Who's here" }); + const body = h('div', { class: 'sheet-body' }), sub = h('div', { class: 'muted small' }); + const tabs = [['page', 'This page'], ['chapter', 'This chapter'], ['so_far', 'So far']]; + const tabBtns = tabs.map(([id, label]) => h('button', { 'data-tab': id, onclick: () => { tab = id; store.set('senpai.tab', id); card = null; unmark(); render(); } }, label)); + const option = (key, label, on) => h('label', { class: 'check small' }, + h('input', { type: 'checkbox', checked: store.get(key) === on, onchange: e => { store.set(key, e.target.checked ? on : ''); card = null; refresh(); } }), h('span', {}, label)); + sheet.append( + h('div', { class: 'sheet-head' }, h('div', { class: 'grow' }, h('b', {}, "Who's here"), sub), + h('button', { class: 'btn icon', title: 'Close (Esc)', 'aria-label': 'Close', onclick: () => close() }, '×')), + h('div', { class: 'seg' }, tabBtns), body, + h('div', { class: 'sheet-foot' }, option('senpai.asof', 'Only up to this page (for re-reading)', 'page'), option('senpai.proposed', 'Show unconfirmed (machine) findings', '1'))); + document.body.append(sheet); + let tab = ['page', 'chapter', 'so_far'].includes(store.get('senpai.tab')) ? store.get('senpai.tab') : 'page'; + let data = null, card = null, loadToken = 0, movedTimer = 0, marks = []; + const opts = extra => qsa({ m: D.manga.id, asof: store.get('senpai.asof') === 'page' ? 'page' : null, proposed: store.get('senpai.proposed') === '1' ? '1' : null, ...extra }); + const pagesShown = () => (mode === 'flow' || atEnd || !shown.length ? [list[cur]] : shown.map(k => list[k])).filter(Boolean).map(p => p.i); + const sp = path => 'api/senpai/' + path; + const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`; + + function unmark() { for (const m of marks) m.remove(); marks = []; } + // Outline a character's figures on the visible page(s); boxes are in page pixels. + function mark(item) { + unmark(); + if (!item.boxes || !item.page_size || !item.page_size[0]) return; + const [w, hh] = item.page_size; + for (const b of item.boxes) { + for (const el of els) { + if (el._p.i !== b.page || el._p.r) continue; + const [x1, y1, x2, y2] = b.box; + const box = h('div', { class: 'sp-box' }); + box.style.cssText = `left:${(x1 / w) * 100}%;top:${(y1 / hh) * 100}%;width:${((x2 - x1) / w) * 100}%;height:${((y2 - y1) / hh) * 100}%`; + el.append(box); + marks.push(box); + } + } + } + function pic(item, cls) { + const none = () => h('div', { class: (cls || 'pic') + ' none' }, item.name ? item.name.slice(0, 1).toUpperCase() : '?'); + return item.portrait_url ? h('img', { class: cls || 'pic', src: sp(item.portrait_url), alt: '', loading: 'lazy', onerror: e => e.target.replaceWith(none()) }) : none(); + } + function who(item, detail) { + return h('button', { class: 'who', onclick: () => { mark(item); openCard(item); }, onmouseenter: () => mark(item), onmouseleave: () => { if (!card) unmark(); } }, + pic(item), + h('div', { class: 'grow' }, h('div', { class: item.name ? 'nm' : 'nm unnamed' }, item.name || item.descriptor || 'unknown'), h('div', { class: 'muted small' }, detail)), + item.prominence ? h('span', { class: 'sp-chip' }, item.prominence) : null, + item.confirmed === false ? h('span', { class: 'sp-chip' }, 'unconfirmed') : null); + } + // Jump links stay inside the reader: same chapter and version -> go to the page, otherwise open it. + function jumpLink(j, label, text) { + const same = j.c === D.chapter.id && j.v === D.version; + return h('a', { href: readUrl(j.m, j.c, j.v, j.p), onclick: e => { + if (!same) { saveProgress(true); return; } + e.preventDefault(); + const k = list.findIndex(x => x.i >= j.p); + if (k >= 0) goTo(k); + } }, h('div', { class: 'muted small' }, label), text ? h('div', {}, text) : null); + } + + function render() { + for (const b of tabBtns) b.classList.toggle('on', b.dataset.tab === tab && !card); + body.textContent = ''; + if (card) return renderCard(); + if (!data) return body.append(h('p', { class: 'muted' }, 'Loading…')); + if (data.error) return body.append(h('p', { class: 'error' }, data.error)); + sub.textContent = data.horizon && data.horizon.label ? 'What you know up to ' + data.horizon.label : 'Nothing read yet'; + if (tab === 'page') { + const named = data.page.filter(x => x.kind === 'character'), figures = data.page.filter(x => x.kind !== 'character'); + if (data.beyond_horizon) body.append(h('p', { class: 'muted' }, "You haven't read this far yet.")); + else if (!data.page.length) body.append(h('p', { class: 'muted' }, data.coverage.page_has_evidence ? 'Nobody on this page.' : 'Senpai has no notes for this page yet.')); + for (const x of named) body.append(who(x, x.voice_only ? 'heard, not seen' : x.lines ? plural(x.lines, 'line') : 'seen')); + if (figures.length) { + const more = h('div', { hidden: true }, figures.map(x => who(x, x.lines ? plural(x.lines, 'line') : 'seen'))); + body.append(h('button', { class: 'btn small sp-more', onclick: () => { more.hidden = !more.hidden; } }, `${plural(figures.length, 'unknown figure')} on this page`), more); + } + if (data.crowd_on_page) body.append(h('p', { class: 'muted small' }, 'and a crowd')); + } else if (tab === 'chapter') { + if (!data.chapter.length) body.append(h('p', { class: 'muted' }, 'Senpai has no notes for this chapter up to here.')); + for (const x of data.chapter) body.append(who(x, `${plural(x.pages, 'page')} · ${plural(x.lines, 'line')} in this chapter`)); + if (data.chapter_unnamed_figures) body.append(h('p', { class: 'muted small' }, `and ${plural(data.chapter_unnamed_figures, 'unnamed figure')}`)); + } else { + let any = false; + for (const [key, label] of [['lead', 'Lead'], ['frequent', 'Frequent'], ['occasional', 'Occasional'], ['minor', 'Minor']]) { + if (!data.so_far[key].length) continue; + any = true; + body.append(h('div', { class: 'tier' }, label)); + for (const x of data.so_far[key]) body.append(who(x, `${plural(x.pages, 'page')} · ${plural(x.lines, 'line')} so far`)); + } + if (!any) body.append(h('p', { class: 'muted' }, 'Senpai has no notes up to here.')); + body.append(h('p', { class: 'muted small' }, `Based on ${plural(data.coverage.pages_so_far, 'page')} with notes, up to where you are.`)); + } + } + async function openCard(item) { + const mine = (card = { item, info: null, time: null, error: null }); + render(); + try { + const [info, time] = await Promise.all([api(sp(`v1/characters/${item.ref}?` + opts())), api(sp(`v1/characters/${item.ref}/timeline?` + opts()))]); + Object.assign(mine, { info, time }); + } catch (e) { mine.error = e.message; } + if (card === mine) render(); + } + function renderCard() { + const { item, info, time, error } = card, c = info || item; + body.append(h('button', { class: 'btn small', onclick: () => { card = null; unmark(); render(); } }, '‹ Back'), + h('div', { class: 'sp-card' }, pic(c, 'big'), h('div', { class: 'grow' }, h('h2', {}, c.name || c.descriptor || 'unknown'), + c.prominence ? h('div', {}, h('span', { class: 'sp-chip' }, c.prominence)) : null, + info ? h('div', { class: 'muted small' }, `${plural(info.counts.pages, 'page')} · ${plural(info.counts.lines, 'line')} so far`) : null))); + if (error) return body.append(h('p', { class: 'error' }, error)); + if (!info) return body.append(h('p', { class: 'muted' }, 'Loading…')); + if (info.names_known.length > 1) { + body.append(h('div', { class: 'tier' }, 'Names so far'), h('ul', { class: 'sp-names' }, info.names_known.map(n => h('li', {}, n.name, h('span', { class: 'muted small' }, ' · since ' + n.since))))); + } + if (info.first_seen) body.append(h('div', { class: 'tier' }, 'First seen'), h('div', { class: 'sp-time' }, jumpLink(info.first_seen.jump, info.first_seen.label))); + if (time && time.items.length) body.append(h('div', { class: 'tier' }, 'Timeline'), h('div', { class: 'sp-time' }, time.items.map(t => jumpLink(t.jump, t.label, t.text)))); + body.append(h('p', { class: 'muted small' }, 'Only what you have read, up to ' + info.horizon.label + '.')); + } + + async function refresh() { + const token = ++loadToken; + await saveProgress(); // the server's horizon must include the page on screen + let x; + try { x = await api(sp('v1/xray?' + opts({ c: D.chapter.id, v: D.version, p: pagesShown().join(',') }))); } + catch (e) { x = { error: e.message }; } + if (token === loadToken) { data = x; render(); } + } + const btn = h('button', { id: 'who', class: 'btn', title: "Who's here (x)", 'aria-label': "Who's here", onclick: () => toggle() }, + icon(ICONS.people), h('span', { class: 'wide-only' }, ' Who’s here')); + function show() { sheet.hidden = false; btn.classList.add('on'); card = null; data = null; render(); refresh(); } + function close() { if (sheet.hidden) return false; sheet.hidden = true; btn.classList.remove('on'); unmark(); stage.focus({ preventScroll: true }); return true; } + function toggle() { if (sheet.hidden) show(); else close(); } + $('#top').insertBefore(btn, $('#fs')); + return { + toggle, close, + moved() { + if (sheet.hidden) return; + clearTimeout(movedTimer); + unmark(); + movedTimer = setTimeout(() => { if (!card) refresh(); }, 500); + }, + }; + } + if (me.senpai) senpai = whosHere(); + setMode(mode, false); stage.focus({ preventScroll: true }); setBars(true); diff --git a/server.js b/server.js index 9208e0d..2cbe393 100644 --- a/server.js +++ b/server.js @@ -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();