Add deployment reading windows and progress-aware quiz unlock
This commit is contained in:
1 parent
edbc9c6056
commit
d8eba9169f
15 files changed
+471
-5
No files matched your search
@@ -7,6 +7,7 @@
|
||||
!chapter-identity.js
|
||||
!catalog.js
|
||||
!reading-analytics.js
|
||||
!reading-access.js
|
||||
!public/
|
||||
!public/**
|
||||
!greatest/
|
||||
|
||||
@@ -9,4 +9,11 @@ SENTINEL_DATA_PATH=./data/sentinel
|
||||
SCRAPER_TOKEN_PATH=./.secrets/scraper_token
|
||||
TZ=Europe/Berlin
|
||||
COOKIE_SECURE=0
|
||||
# Optional server-enforced reading windows. Day keys: 0=Sunday through 6=Saturday.
|
||||
# Ranges may cross midnight. Multiple ranges per day are supported.
|
||||
# READING_WINDOWS_JSON='{"0":[["08:00","01:00"]],"1":[["07:00","24:00"]],"2":[["07:00","24:00"]],"3":[["07:00","24:00"]],"4":[["07:00","24:00"]],"5":[["07:00","24:00"]],"6":[["08:00","01:00"]]}'
|
||||
READING_TIMEZONE=Europe/Berlin
|
||||
READING_QUIZ_UNLOCK_MINUTES=60
|
||||
# Optional deployment-managed JSON file under DATA_PATH, e.g. /data/reading-quiz.json.
|
||||
# READING_QUIZ_CATALOG_PATH=/data/reading-quiz.json
|
||||
PROXY_NETWORK=mangacircus-edge
|
||||
+1
-1
@@ -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 reading-analytics.js catalog.js ./
|
||||
COPY package.json server.js library.js reading-direction.js chapter-identity.js reading-analytics.js reading-access.js catalog.js ./
|
||||
COPY public ./public
|
||||
COPY greatest ./greatest
|
||||
COPY discovery ./discovery
|
||||
|
||||
@@ -30,6 +30,9 @@ The current Windows installation uses Docker Compose; its old `MangaCircus Super
|
||||
Scheduled Task is disabled. Do not run a native duplicate writer. See
|
||||
[docs/local-deployment.md](docs/local-deployment.md) for its observed layout, health
|
||||
checks, restart procedure, and recovery point.
|
||||
Deployment-controlled reading hours and the optional progress-aware quiz are
|
||||
documented in [reading limiter](docs/reading-limiter.md). They are disabled
|
||||
unless configured on the reader container.
|
||||
See [Greatest of All Time](docs/greatest.md) for catalog ownership, voting, and metadata refresh.
|
||||
|
||||
### Docker
|
||||
|
||||
@@ -6,6 +6,7 @@ services:
|
||||
- ./server.js:/app/server.js:ro
|
||||
- ./catalog.js:/app/catalog.js:ro
|
||||
- ./reading-analytics.js:/app/reading-analytics.js:ro
|
||||
- ./reading-access.js:/app/reading-access.js:ro
|
||||
- ./library.js:/app/library.js:ro
|
||||
scraper:
|
||||
volumes:
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": 1,
|
||||
"manga": {
|
||||
"Exact library manga folder name": [
|
||||
{
|
||||
"id": "unique-question-id",
|
||||
"prompt": "Which character is revealed to be the captain in chapter 12?",
|
||||
"answers": ["Full Character Name", "Commonly Used Name"],
|
||||
"requires": ["1/12"],
|
||||
"evidence": "Reviewed source URL or chapter citation; never sent to the browser"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,10 @@ services:
|
||||
SIGNUP: closed
|
||||
SCAN_INTERVAL: "10"
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-0}
|
||||
READING_WINDOWS_JSON: ${READING_WINDOWS_JSON:-}
|
||||
READING_TIMEZONE: ${READING_TIMEZONE:-Europe/Berlin}
|
||||
READING_QUIZ_UNLOCK_MINUTES: ${READING_QUIZ_UNLOCK_MINUTES:-60}
|
||||
READING_QUIZ_CATALOG_PATH: ${READING_QUIZ_CATALOG_PATH:-}
|
||||
SCRAPER_URL: http://scraper:8091
|
||||
SENTINEL_URL: http://sentinel:8092
|
||||
SCRAPER_TOKEN_FILE: /run/secrets/scraper_token
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Deployment-controlled reading hours and quiz
|
||||
|
||||
The reader enforces reading hours on both `GET /api/chapter` and chapter page-image
|
||||
requests under `/lib/` (HTTP 423 outside hours). The dashboard, manga metadata,
|
||||
covers, progress, reading-time summaries, scraper and administrator pages remain
|
||||
available. A reader tab already open when a window ends checks access every 15
|
||||
seconds and returns to the gate. Chapter images use `Cache-Control: no-store` when
|
||||
the limiter is configured. Already downloaded images in a browser or files available
|
||||
directly on disk cannot be recalled; this is a self-imposed server access rule, not
|
||||
DRM. There is no schedule-changing API or UI.
|
||||
|
||||
## Docker configuration
|
||||
|
||||
Set these deployment variables on the reader service, then recreate **only the
|
||||
reader**. Leaving `READING_WINDOWS_JSON` empty disables the limiter. Day keys
|
||||
are `0` Sunday through `6` Saturday. Each day has any number of `[start,end]`
|
||||
intervals; an end earlier than its start carries into the next day. `24:00` is
|
||||
valid only as an end. Intervals are start-inclusive and end-exclusive, resolved
|
||||
using the IANA timezone, including daylight-saving changes.
|
||||
|
||||
```dotenv
|
||||
READING_WINDOWS_JSON='{"0":[["08:00","01:00"]],"1":[["07:00","12:00"],["13:00","24:00"]],"2":[["07:00","24:00"]],"3":[["07:00","24:00"]],"4":[["07:00","24:00"]],"5":[["07:00","24:00"]],"6":[["08:00","01:00"]]}'
|
||||
READING_TIMEZONE=Europe/Berlin
|
||||
READING_QUIZ_UNLOCK_MINUTES=60
|
||||
READING_QUIZ_CATALOG_PATH=/data/reading-quiz.json
|
||||
```
|
||||
|
||||
The Monday split above illustrates multiple on/off periods; replace it with
|
||||
`[["07:00","24:00"]]` for an uninterrupted weekday. The Saturday and Sunday
|
||||
windows extend to 01:00 on the following mornings. A malformed timezone,
|
||||
schedule, unlock length or catalog stops the reader at startup rather than
|
||||
silently removing the restriction. `READING_QUIZ_UNLOCK_MINUTES` accepts 1–1440.
|
||||
The scraper is independent and must not be restarted for this configuration.
|
||||
|
||||
## Question bank and progress watermark
|
||||
|
||||
The bank is a deployment-managed JSON file, separate from `manga.json` and from
|
||||
the scraper. See [`config/reading-quiz.example.json`](../config/reading-quiz.example.json).
|
||||
Its `manga` keys are **exact library folder IDs**. Each question has a stable ID,
|
||||
prompt, accepted answer variants, an evidence citation, and at least one
|
||||
`requires` chapter ID. The chapter IDs are the reader's IDs (for example `1/12`,
|
||||
not the on-disk `v01/c0012`). Every required chapter must be marked completed
|
||||
for that user before the question enters their pool. This supports a single
|
||||
watermark or a set of specific sections. Five eligible questions are needed to
|
||||
offer a quiz; an empty bank simply leaves outside-hours reading locked. A
|
||||
question's answers and evidence never go to the browser.
|
||||
|
||||
The challenge randomly samples five progress-eligible questions across *all*
|
||||
library manga, not only the one being opened. It asks them sequentially, accepts
|
||||
unlimited retries, and has no answer timer. Accepted variants are normalized
|
||||
for case, punctuation and accents; one typo is tolerated for answers of five
|
||||
or more characters, two for answers of eleven or more. Starting a new visit
|
||||
invalidates the previous challenge, and leaving the page abandons it. Passing
|
||||
all five grants that user reading access across manga for the configured number
|
||||
of minutes, persisted in `db.json` across reader restarts. Questions are not
|
||||
shown before their watermark; this depends on the reader's existing
|
||||
self-reported completion records.
|
||||
|
||||
Validate a proposed file with
|
||||
`node tools/validate-reading-quiz.js <catalog.json>` before pointing the reader
|
||||
at it. For an eventual research agent: write **drafts** with source citations,
|
||||
aliases and proposed chapter watermarks, then have a human verify identity,
|
||||
answer and spoiler boundary. Only a reviewed catalog should be copied to the
|
||||
configured path and loaded by restarting the reader. No wiki or transcript
|
||||
fetching is performed by the reader itself.
|
||||
|
||||
## Testing and rollback
|
||||
|
||||
`node tools/test-compose.js --reading-limit-ui` creates a disposable Compose
|
||||
fixture with two progress-qualified sample manga, tests chapter and image
|
||||
denial, quiz flow and the one-hour unlock, then tears it down. Add `--keep`
|
||||
to leave an isolated playable instance locked for manual review; its printed
|
||||
URL and fixture path identify it. Main data and the running main scraper are
|
||||
not mounted. To roll back a future main deployment, restore the previous
|
||||
reader image and configuration, recreate only the reader, and preserve current
|
||||
`db.json` and library. The additional `readingAccess` field is backward-safe.
|
||||
@@ -389,6 +389,14 @@ body.reader { position: fixed; top: 0; left: 0; right: 0; bottom: 0; overflow: h
|
||||
#stage.pan { cursor: grab; }
|
||||
#stage.drag { cursor: grabbing; }
|
||||
#canvas { position: relative; }
|
||||
.reading-gate { box-sizing: border-box; max-width: 620px; margin: min(13vh, 100px) auto 100px; padding: 24px;
|
||||
border: 1px solid var(--line); border-radius: 14px; background: var(--card); color: var(--fg); line-height: 1.5; }
|
||||
.reading-gate h1 { margin-top: 0; }
|
||||
.reading-gate-question { margin: 20px 0; }
|
||||
.reading-gate-question input { display: block; box-sizing: border-box; width: 100%; margin: 14px 0; padding: 10px; }
|
||||
.reading-gate-actions { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.reading-gate-message { color: var(--accent); min-height: 1.5em; }
|
||||
@media (max-width: 660px) { .reading-gate { margin: 28px 12px 100px; padding: 18px; } }
|
||||
.pg { position: absolute; overflow: hidden; background: #19191c; }
|
||||
.pg img { display: block; width: 100%; height: 100%; -webkit-user-drag: none; }
|
||||
#canvas[data-page-tone="inverted"] .pg img { filter: invert(1); }
|
||||
|
||||
+57
-1
@@ -12,9 +12,65 @@
|
||||
const P = new URLSearchParams(location.search);
|
||||
const stage = $('#stage'), canvas = $('#canvas'), slider = $('#slider'), ver = $('#ver');
|
||||
let me, D;
|
||||
try { [me, D] = await Promise.all([api('api/me'), api('api/chapter?' + qsa({ m: P.get('m'), c: P.get('c'), v: P.get('v') }))]); }
|
||||
try {
|
||||
me = await api('api/me');
|
||||
const response = await fetch('api/chapter?' + qsa({ m: P.get('m'), c: P.get('c'), v: P.get('v') }));
|
||||
if (response.status === 423) {
|
||||
const access = await api('api/reading-access?' + qsa({ m: P.get('m') }));
|
||||
canvas.textContent = '';
|
||||
const card = h('section', { class: 'reading-gate' },
|
||||
h('h1', {}, 'Reading is closed right now'),
|
||||
h('p', {}, `The reading hours are set by this server's deployment (${access.timezone}). The library and your progress remain available.`),
|
||||
h('p', {}, `Answer five questions drawn from manga you have already read to unlock ${access.unlockMinutes} minutes of reading. There is no answer timer or retry limit.`));
|
||||
const message = h('p', { class: 'reading-gate-message', role: 'status' });
|
||||
const question = h('div', { class: 'reading-gate-question' });
|
||||
const start = h('button', { class: 'btn', type: 'button' }, 'Start five-question challenge');
|
||||
const back = h('a', { class: 'btn', href: mangaUrl(P.get('m')) }, 'Back to manga');
|
||||
card.append(message, question, h('div', { class: 'reading-gate-actions' }, start, back));
|
||||
canvas.append(card);
|
||||
let challenge = null;
|
||||
const showQuestion = data => {
|
||||
challenge = data.challenge;
|
||||
question.textContent = '';
|
||||
const input = h('input', { type: 'text', autocomplete: 'off', 'aria-label': 'Your answer', maxlength: '120', required: true });
|
||||
const form = h('form', {}, h('p', {}, `Question ${data.number} of ${data.total}`),
|
||||
h('p', { class: 'muted' }, data.mangaTitle), h('h2', {}, data.prompt), input, h('button', { class: 'btn', type: 'submit' }, 'Check answer'));
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const result = await api('api/reading-quiz/answer', { m: P.get('m'), challenge, answer: input.value });
|
||||
if (!result.correct) { message.textContent = 'Not quite. Try another spelling or answer—there is no attempt limit.'; input.select(); return; }
|
||||
if (result.complete) { message.textContent = 'Unlocked. Opening your chapter…'; location.reload(); return; }
|
||||
message.textContent = 'Correct.';
|
||||
showQuestion(result);
|
||||
} catch (error) { message.textContent = error.message; }
|
||||
});
|
||||
question.append(form); input.focus();
|
||||
};
|
||||
start.addEventListener('click', async () => {
|
||||
try { showQuestion(await api('api/reading-quiz/start', { m: P.get('m') })); start.hidden = true; message.textContent = ''; }
|
||||
catch (error) { message.textContent = error.message; }
|
||||
});
|
||||
if (!access.quizAvailable) { start.hidden = true; message.textContent = 'Fewer than five questions match your completed chapters. Please return during reading hours.'; }
|
||||
window.addEventListener('pagehide', () => {
|
||||
if (challenge) navigator.sendBeacon('api/reading-quiz/abandon', new Blob([JSON.stringify({ challenge })], { type: 'application/json' }));
|
||||
});
|
||||
setInterval(async () => {
|
||||
try { if ((await api('api/reading-access?' + qsa({ m: P.get('m') }))).allowed) location.reload(); } catch { /* keep the gate visible */ }
|
||||
}, 15000);
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || response.statusText);
|
||||
D = payload;
|
||||
}
|
||||
catch (e) { canvas.textContent = ''; canvas.append(h('p', { class: 'loading' }, e.message + ' · ', h('a', { href: './' }, 'Library'))); return; }
|
||||
|
||||
// An already-open chapter also closes when a window ends or an unlock expires.
|
||||
setInterval(async () => {
|
||||
try { if (!(await api('api/reading-access?' + qsa({ m: P.get('m') }))).allowed) location.reload(); } catch { /* transient check failure */ }
|
||||
}, 15000);
|
||||
|
||||
const S = me.settings, rtl = D.direction === 'rtl', ttb = D.direction === 'ttb';
|
||||
const GAP = ttb || S.flowPageGap === false ? 0 : 8, END_H = 280;
|
||||
document.title = `${D.chapter.label} · ${D.manga.title}`;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function minute(value, end = false) {
|
||||
if (end && value === '24:00') return 1440;
|
||||
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value)) throw new Error(`Invalid reading-window time: ${value}`);
|
||||
return Number(value.slice(0, 2)) * 60 + Number(value.slice(3));
|
||||
}
|
||||
|
||||
function parseSchedule(json, timezone) {
|
||||
if (!json) return null;
|
||||
// Validate the IANA zone at startup; a bad deployment must not silently use UTC.
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format();
|
||||
const raw = JSON.parse(json);
|
||||
if (!raw || Array.isArray(raw) || typeof raw !== 'object' ||
|
||||
Object.keys(raw).some(key => !/^[0-6]$/.test(key))) throw new Error('READING_WINDOWS_JSON must map weekdays 0–6 to time ranges');
|
||||
const days = Array.from({ length: 7 }, (_, day) => {
|
||||
const ranges = raw[day] || [];
|
||||
if (!Array.isArray(ranges)) throw new Error(`Reading windows for day ${day} must be an array`);
|
||||
return ranges.map(range => {
|
||||
if (!Array.isArray(range) || range.length !== 2) throw new Error(`Invalid reading window on day ${day}`);
|
||||
const start = minute(range[0]), end = minute(range[1], true);
|
||||
if (start === end) throw new Error(`Zero-length reading window on day ${day}`);
|
||||
return { start, end, label: range };
|
||||
});
|
||||
});
|
||||
return { days, timezone };
|
||||
}
|
||||
|
||||
function localParts(date, timezone) {
|
||||
const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
||||
.formatToParts(date);
|
||||
const get = type => parts.find(part => part.type === type).value;
|
||||
return { day: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(get('weekday')),
|
||||
minute: Number(get('hour')) * 60 + Number(get('minute')) };
|
||||
}
|
||||
|
||||
function scheduled(schedule, date = new Date()) {
|
||||
if (!schedule) return true;
|
||||
const { day, minute: now } = localParts(date, schedule.timezone);
|
||||
return schedule.days[day].some(({ start, end }) => end > start && now >= start && now < end || end < start && now >= start) ||
|
||||
schedule.days[(day + 6) % 7].some(({ start, end }) => end < start && now < end);
|
||||
}
|
||||
|
||||
function normalizeAnswer(value) {
|
||||
return String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ').trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function distance(a, b, cap) {
|
||||
if (Math.abs(a.length - b.length) > cap) return cap + 1;
|
||||
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
const row = [i];
|
||||
for (let j = 1; j <= b.length; j++) row[j] = Math.min(row[j - 1] + 1, prev[j] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
||||
if (Math.min(...row) > cap) return cap + 1;
|
||||
prev = row;
|
||||
}
|
||||
return prev[b.length];
|
||||
}
|
||||
|
||||
function answerMatches(input, answers) {
|
||||
const given = normalizeAnswer(input);
|
||||
if (!given || given.length > 120) return false;
|
||||
return answers.some(answer => {
|
||||
const target = normalizeAnswer(answer), tolerance = target.length >= 11 ? 2 : target.length >= 5 ? 1 : 0;
|
||||
return target && distance(given, target, tolerance) <= tolerance;
|
||||
});
|
||||
}
|
||||
|
||||
function loadQuestionBank(file) {
|
||||
if (!file) return {};
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
if (raw.version !== 1 || !raw.manga || Array.isArray(raw.manga) || typeof raw.manga !== 'object')
|
||||
throw new Error('Reading quiz catalog must have version 1 and a manga object');
|
||||
for (const [id, questions] of Object.entries(raw.manga)) {
|
||||
if (!id || !Array.isArray(questions)) throw new Error('Invalid reading quiz manga entry');
|
||||
const ids = new Set();
|
||||
for (const q of questions) {
|
||||
if (!q || typeof q.id !== 'string' || !q.id || ids.has(q.id) || typeof q.prompt !== 'string' || !q.prompt.trim() ||
|
||||
!Array.isArray(q.requires) || !q.requires.length || q.requires.some(chapter => typeof chapter !== 'string' || !chapter) ||
|
||||
typeof q.evidence !== 'string' || !q.evidence.trim() ||
|
||||
!Array.isArray(q.answers) || !q.answers.length || q.answers.some(a => typeof a !== 'string' || !normalizeAnswer(a)))
|
||||
throw new Error(`Invalid reading quiz question for ${id}`);
|
||||
ids.add(q.id);
|
||||
}
|
||||
}
|
||||
return raw.manga;
|
||||
}
|
||||
|
||||
function createReadingAccess({ schedule, bank = {}, unlockMinutes = 60, grants, save, getProgress = () => ({}), getMangaTitle = id => id, now = () => Date.now() }) {
|
||||
if (!Number.isInteger(unlockMinutes) || unlockMinutes < 1 || unlockMinutes > 1440) throw new Error('READING_QUIZ_UNLOCK_MINUTES must be 1–1440');
|
||||
const challenges = new Map();
|
||||
const eligibleQuestions = user => Object.entries(bank).flatMap(([mangaId, questions]) => {
|
||||
const title = getMangaTitle(mangaId), progress = getProgress(user, mangaId);
|
||||
if (!title) return [];
|
||||
return questions.filter(q => q.requires.every(chapter => progress?.[chapter]?.done === true))
|
||||
.map(q => ({ ...q, mangaTitle: title }));
|
||||
});
|
||||
const status = (user, withQuiz = false) => {
|
||||
const scheduledNow = scheduled(schedule, new Date(now()));
|
||||
const unlockUntil = Number(grants[user] || 0);
|
||||
return { allowed: scheduledNow || unlockUntil > now(), scheduled: scheduledNow,
|
||||
unlockUntil: unlockUntil > now() ? unlockUntil : null,
|
||||
quizAvailable: withQuiz ? eligibleQuestions(user).length >= 5 : undefined,
|
||||
unlockMinutes, timezone: schedule?.timezone || null };
|
||||
};
|
||||
const publicQuestion = (challenge) => ({ challenge: challenge.id, number: challenge.index + 1, total: 5,
|
||||
prompt: challenge.questions[challenge.index].prompt, mangaTitle: challenge.questions[challenge.index].mangaTitle });
|
||||
return {
|
||||
status,
|
||||
start(user) {
|
||||
if (status(user).allowed) throw new Error('Reading is already available');
|
||||
const pool = eligibleQuestions(user);
|
||||
if (pool.length < 5) throw new Error('Five progress-eligible questions are not available yet');
|
||||
const shuffled = [...pool];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) { const j = crypto.randomInt(i + 1); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; }
|
||||
const challenge = { id: crypto.randomBytes(16).toString('hex'), questions: shuffled.slice(0, 5), index: 0 };
|
||||
challenges.set(user, challenge); // A new visit starts at question one; older tokens cannot resume.
|
||||
return publicQuestion(challenge);
|
||||
},
|
||||
answer(user, id, answer) {
|
||||
const challenge = challenges.get(user);
|
||||
if (!challenge || challenge.id !== id) throw new Error('Challenge expired; start again at question one');
|
||||
if (!answerMatches(answer, challenge.questions[challenge.index].answers)) return { correct: false, ...publicQuestion(challenge) };
|
||||
challenge.index++;
|
||||
if (challenge.index < 5) return { correct: true, ...publicQuestion(challenge) };
|
||||
challenges.delete(user);
|
||||
grants[user] = now() + unlockMinutes * 60000;
|
||||
save();
|
||||
return { correct: true, complete: true, unlockUntil: grants[user] };
|
||||
},
|
||||
abandon(user, id) { if (challenges.get(user)?.id === id) challenges.delete(user); },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { parseSchedule, scheduled, normalizeAnswer, answerMatches, loadQuestionBank, createReadingAccess };
|
||||
@@ -25,6 +25,7 @@ const { generate: generateDiscovery } = require('./discovery/engine');
|
||||
const {
|
||||
applyPulse, applyVisit, chapterInsight, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary,
|
||||
} = require('./reading-analytics');
|
||||
const { parseSchedule, loadQuestionBank, createReadingAccess } = require('./reading-access');
|
||||
|
||||
const PORT = +process.env.PORT || 8080;
|
||||
const LIB = path.resolve(process.env.LIBRARY || 'library');
|
||||
@@ -55,7 +56,7 @@ const catalog = new Catalog(DATA);
|
||||
const greatestCatalog = loadGreatestCatalog();
|
||||
const greatestById = new Map(greatestCatalog.entries.map(entry => [entry.id, entry]));
|
||||
const DB_FILE = path.join(DATA, 'db.json'), CACHE_FILE = path.join(DATA, 'scan-cache.json'), INDEX_FILE = path.join(DATA, 'library-index.json');
|
||||
const db = { users: {}, sessions: {}, progress: {}, reading: {}, follows: {}, notes: {}, votes: {}, greatestRatings: {}, discoveryFeedback: {}, known: {} };
|
||||
const db = { users: {}, sessions: {}, progress: {}, reading: {}, readingAccess: {}, follows: {}, notes: {}, votes: {}, greatestRatings: {}, discoveryFeedback: {}, known: {} };
|
||||
// A corrupt db.json stops the server instead of silently starting empty and overwriting it.
|
||||
if (fs.existsSync(DB_FILE)) Object.assign(db, JSON.parse(fs.readFileSync(DB_FILE, 'utf8')));
|
||||
function writeAtomic(file, text) { fs.writeFileSync(file + '.tmp', text); fs.renameSync(file + '.tmp', file); }
|
||||
@@ -63,6 +64,13 @@ let saveTimer = null;
|
||||
function save() { saveTimer = saveTimer || setTimeout(flush, 500); }
|
||||
function flush() { clearTimeout(saveTimer); saveTimer = null; writeAtomic(DB_FILE, JSON.stringify(db)); }
|
||||
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { flush(); process.exit(0); });
|
||||
db.readingAccess ||= {};
|
||||
const readingSchedule = parseSchedule(process.env.READING_WINDOWS_JSON || '', process.env.READING_TIMEZONE || process.env.TZ || 'Europe/Berlin');
|
||||
const readingAccess = createReadingAccess({ schedule: readingSchedule,
|
||||
bank: loadQuestionBank(process.env.READING_QUIZ_CATALOG_PATH || ''),
|
||||
unlockMinutes: Number(process.env.READING_QUIZ_UNLOCK_MINUTES || 60), grants: db.readingAccess, save,
|
||||
getProgress: (user, mangaId) => db.progress[user]?.[mangaId] || {},
|
||||
getMangaTitle: mangaId => lib.byId.get(mangaId)?.title || null });
|
||||
|
||||
class HttpError extends Error { constructor(code, msg) { super(msg); this.code = code; } }
|
||||
function bad(code, msg) { throw new HttpError(code, msg); }
|
||||
@@ -393,6 +401,20 @@ const routes = {
|
||||
},
|
||||
'GET /api/me': ({ u }) => ({ name: u.name, admin: !!u.admin, settings: settingsOf(u), unread: unreadOf(u) }),
|
||||
|
||||
'GET /api/reading-access': ({ u, q }) => { mangaOf(q.get('m')); return readingAccess.status(u.name, true); },
|
||||
'POST /api/reading-quiz/start': ({ u, b }) => {
|
||||
try { return readingAccess.start(u.name); }
|
||||
catch (e) { bad(409, e.message); }
|
||||
},
|
||||
'POST /api/reading-quiz/answer': ({ u, b }) => {
|
||||
try { return readingAccess.answer(u.name, String(b.challenge || ''), String(b.answer || '')); }
|
||||
catch (e) { bad(409, e.message); }
|
||||
},
|
||||
'POST /api/reading-quiz/abandon': ({ u, b }) => {
|
||||
readingAccess.abandon(u.name, String(b.challenge || ''));
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
'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.');
|
||||
const s = (u.settings = u.settings || {});
|
||||
@@ -576,6 +598,7 @@ const routes = {
|
||||
|
||||
'GET /api/chapter': ({ u, q }) => {
|
||||
const m = mangaOf(q.get('m')), ch = chapterOf(m, q.get('c')), s = settingsOf(u), 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.
|
||||
let seen = false;
|
||||
@@ -890,7 +913,13 @@ http.createServer(async (req, res) => {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') bad(405, 'Method not allowed');
|
||||
if (pathname.startsWith('/lib/')) {
|
||||
if (!u) bad(401, 'Please log in');
|
||||
return serveFile(req, res, LIB, pathname.slice(5), 'private, max-age=86400', IMG);
|
||||
let imagePath;
|
||||
try { imagePath = path.resolve(LIB, decodeURIComponent(pathname.slice(5))); } catch { bad(400, 'Bad image path'); }
|
||||
const relative = path.relative(LIB, imagePath);
|
||||
const parts = relative.split(path.sep);
|
||||
if (parts.length >= 4 && !readingAccess.status(u.name).allowed)
|
||||
bad(423, 'Reading is outside the deployment schedule.');
|
||||
return serveFile(req, res, LIB, pathname.slice(5), readingSchedule && parts.length >= 4 ? 'private, no-store' : 'private, max-age=86400', IMG);
|
||||
}
|
||||
serveFile(req, res, PUB, pathname === '/' ? 'index.html' : pathname.slice(1), 'no-cache');
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { parseSchedule, scheduled, answerMatches, createReadingAccess } = require('../reading-access');
|
||||
|
||||
const windows = parseSchedule(JSON.stringify({
|
||||
0: [['08:00', '01:00']], 1: [['07:00', '12:00'], ['13:00', '24:00']],
|
||||
2: [['07:00', '24:00']], 3: [['07:00', '24:00']], 4: [['07:00', '24:00']],
|
||||
5: [['07:00', '24:00']], 6: [['08:00', '01:00']],
|
||||
}), 'Europe/Berlin');
|
||||
const at = iso => new Date(iso);
|
||||
|
||||
test('multiple daily windows and weekend overnight carry in local time', () => {
|
||||
assert.equal(scheduled(windows, at('2026-10-12T08:00:00Z')), true); // Monday 10:00
|
||||
assert.equal(scheduled(windows, at('2026-10-12T10:30:00Z')), false); // lunch gap 12:30
|
||||
assert.equal(scheduled(windows, at('2026-10-12T11:30:00Z')), true);
|
||||
assert.equal(scheduled(windows, at('2026-10-10T22:30:00Z')), true); // Sunday 00:30 from Saturday
|
||||
assert.equal(scheduled(windows, at('2026-10-11T22:30:00Z')), true); // Monday 00:30 from Sunday
|
||||
assert.equal(scheduled(windows, at('2026-10-11T23:30:00Z')), false); // Monday 01:30
|
||||
});
|
||||
|
||||
test('schedule config rejects malformed and zero-length windows', () => {
|
||||
assert.throws(() => parseSchedule('{"1":[["12:00","12:00"]]}', 'Europe/Berlin'));
|
||||
assert.throws(() => parseSchedule('{"7":[]}', 'Europe/Berlin'));
|
||||
assert.throws(() => parseSchedule('{"1":[["25:00","26:00"]]}', 'Europe/Berlin'));
|
||||
});
|
||||
|
||||
test('answers accept aliases and small typos but not unrelated words', () => {
|
||||
assert.equal(answerMatches(' luffy ', ['Monkey D. Luffy', 'Luffy']), true);
|
||||
assert.equal(answerMatches('Lufy', ['Luffy']), true);
|
||||
assert.equal(answerMatches('pirate', ['Luffy']), false);
|
||||
});
|
||||
|
||||
test('five ordered answers grant time, allow retries and reset on a new visit', () => {
|
||||
let clock = Date.parse('2026-10-14T01:00:00Z'), saves = 0;
|
||||
const grants = {}, bank = { moon: Array.from({ length: 5 }, (_, i) => ({ id: `q${i}`, prompt: `Question ${i}`, answers: [`answer${i}`], requires: ['v01/c0001'] })) };
|
||||
let finished = false;
|
||||
const gate = createReadingAccess({ schedule: parseSchedule('{"0":[],"1":[],"2":[],"3":[],"4":[],"5":[],"6":[]}', 'UTC'),
|
||||
bank, grants, save: () => saves++, now: () => clock,
|
||||
getProgress: () => ({ 'v01/c0001': { done: finished } }), getMangaTitle: () => 'Moon Garden' });
|
||||
assert.equal(gate.status('reader', true).quizAvailable, false);
|
||||
assert.throws(() => gate.start('reader'));
|
||||
finished = true;
|
||||
assert.equal(gate.status('reader', true).quizAvailable, true);
|
||||
const first = gate.start('reader');
|
||||
assert.equal(gate.answer('reader', first.challenge, 'wrong').correct, false);
|
||||
assert.equal(gate.start('reader').number, 1);
|
||||
assert.throws(() => gate.answer('reader', first.challenge, 'answer0'));
|
||||
let current = gate.start('reader');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const answer = bank.moon.find(q => q.prompt === current.prompt).answers[0];
|
||||
current = gate.answer('reader', current.challenge, answer);
|
||||
}
|
||||
assert.equal(current.complete, true);
|
||||
assert.equal(grants.reader, clock + 3600000);
|
||||
assert.equal(saves, 1);
|
||||
assert.equal(gate.status('reader').allowed, true);
|
||||
clock += 3600000;
|
||||
assert.equal(gate.status('reader').allowed, false);
|
||||
});
|
||||
+58
-1
@@ -19,6 +19,11 @@ if (peerGatewayQa) {
|
||||
new PeeringState(peerData).generateIdentity('ComposeQA');
|
||||
}
|
||||
const pausedConfirmQa = process.argv.includes('--paused-confirm');
|
||||
const readingGateQa = process.argv.includes('--reading-limit-ui');
|
||||
if (readingGateQa) fs.writeFileSync(path.join(data, 'reading-quiz.json'), JSON.stringify({ version: 1, manga: {
|
||||
'Moon Garden': [0, 1, 2].map(i => ({ id: `moon-${i}`, prompt: `Moon Garden question ${i}?`, answers: [`moonanswer${i}`], requires: ['1/1'], evidence: 'Disposable Compose fixture' })),
|
||||
'Before the Rain': [0, 1, 2].map(i => ({ id: `rain-${i}`, prompt: `Before the Rain question ${i}?`, answers: [`rainanswer${i}`], requires: ['1/1'], evidence: 'Disposable Compose fixture' })),
|
||||
} }));
|
||||
fs.writeFileSync(path.join(data, 'scraper', 'queue.json'), JSON.stringify({ paused: true,
|
||||
sourcePaused: pausedConfirmQa ? { weebcentral: true } : {},
|
||||
queue: pausedConfirmQa ? [{ id: 'ready-fixture', source: 'weebcentral',
|
||||
@@ -44,6 +49,8 @@ for (const [index, name] of ['Moon Garden', 'Before the Rain', 'Iron Harbor'].en
|
||||
}
|
||||
const env = { ...process.env, LIBRARY_PATH: library, DATA_PATH: data, SCRAPER_DATA_PATH: path.join(data, 'scraper'), SENTINEL_DATA_PATH: path.join(data, 'sentinel'),
|
||||
SCRAPER_TOKEN_PATH: tokenFile, READER_PORT: port, READER_BIND: '127.0.0.1', COOKIE_SECURE: '0', APP_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
|
||||
...(readingGateQa ? { READING_WINDOWS_JSON: '{"0":[],"1":[],"2":[],"3":[],"4":[],"5":[],"6":[]}',
|
||||
READING_TIMEZONE: 'Europe/Berlin', READING_QUIZ_UNLOCK_MINUTES: '60', READING_QUIZ_CATALOG_PATH: '/data/reading-quiz.json' } : {}),
|
||||
...(peerGatewayQa ? { PEER_DATA_PATH: path.join(data, 'peering'), PEER_USAGE_PATH: path.join(data, 'peering-usage'), PEER_PORT: process.env.QA_PEER_PORT || '8193' } : {}) };
|
||||
const args = ['compose', '-p', project, '-f', path.join(root, 'docker-compose.yml'), ...(peerGatewayQa ? ['--profile', 'peering'] : [])];
|
||||
const docker = (...command) => new Promise((resolve, reject) => {
|
||||
@@ -77,6 +84,54 @@ let keep = false;
|
||||
let inventory;
|
||||
await until(async () => { inventory = await (await api('/api/library')).json(); return inventory.mangas?.length === 3; }, 'Fixture inventory missing');
|
||||
const byTitle = title => inventory.mangas.find(m => m.title === title).id;
|
||||
if (readingGateQa) {
|
||||
const manga = byTitle('Moon Garden'), other = byTitle('Before the Rain');
|
||||
const chapter = (await (await api('/api/manga?m=' + encodeURIComponent(manga))).json()).chapters[0].id;
|
||||
const chapterUrl = '/api/chapter?m=' + encodeURIComponent(manga) + '&c=' + encodeURIComponent(chapter);
|
||||
const pageUrl = '/lib/' + encodeURIComponent(manga) + '/v01/c0001%20The%20Beginning/0001.png';
|
||||
assert.equal((await api(chapterUrl)).status, 423, 'chapter JSON must be gated');
|
||||
assert.equal((await api(pageUrl)).status, 423, 'chapter image must be gated');
|
||||
assert.equal((await api('/lib/' + encodeURIComponent(manga) + '/cover.png')).status, 200);
|
||||
assert.equal((await (await api('/api/reading-access?m=' + encodeURIComponent(manga))).json()).quizAvailable, false);
|
||||
for (const title of [manga, other]) {
|
||||
const id = (await (await api('/api/manga?m=' + encodeURIComponent(title))).json()).chapters[0].id;
|
||||
assert.equal((await api('/api/progress', { m: title, c: id, p: 0, n: 1, done: true })).status, 200);
|
||||
}
|
||||
assert.equal((await (await api('/api/reading-access?m=' + encodeURIComponent(manga))).json()).quizAvailable, true);
|
||||
const { chromium } = require('playwright');
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
const [name, ...values] = cookie.split('=');
|
||||
await context.addCookies([{ name, value: values.join('='), url }]);
|
||||
const page = await context.newPage();
|
||||
await page.goto(url + '/read.html?m=' + encodeURIComponent(manga) + '&c=' + encodeURIComponent(chapter));
|
||||
await page.getByRole('heading', { name: 'Reading is closed right now' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Start five-question challenge' }).click();
|
||||
await page.getByText('Question 1 of 5').waitFor();
|
||||
await page.goto(url + '/manga.html?m=' + encodeURIComponent(manga));
|
||||
await page.goto(url + '/read.html?m=' + encodeURIComponent(manga) + '&c=' + encodeURIComponent(chapter));
|
||||
await page.getByRole('button', { name: 'Start five-question challenge' }).waitFor();
|
||||
} finally { await browser.close(); }
|
||||
if (!process.argv.includes('--keep')) {
|
||||
let question = await (await api('/api/reading-quiz/start', {})).json();
|
||||
const wrong = await api('/api/reading-quiz/answer', { challenge: question.challenge, answer: 'incorrect' });
|
||||
assert.equal((await wrong.json()).correct, false);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const match = /(?:Moon Garden|Before the Rain) question (\d)/.exec(question.prompt);
|
||||
const answer = (question.mangaTitle === 'Moon Garden' ? 'moonanswer' : 'rainanswer') + match[1];
|
||||
question = await (await api('/api/reading-quiz/answer', { challenge: question.challenge, answer })).json();
|
||||
}
|
||||
assert.equal(question.complete, true);
|
||||
assert.equal((await api(chapterUrl)).status, 200);
|
||||
const image = await api(pageUrl);
|
||||
assert.equal(image.status, 200);
|
||||
assert.equal(image.headers.get('cache-control'), 'private, no-store');
|
||||
console.log('PASS: schedule blocks chapter and page bytes, progress-gated mixed-manga quiz unlocks reading');
|
||||
} else {
|
||||
console.log('PASS: isolated playable gate stays locked with progress-qualified sample questions');
|
||||
}
|
||||
}
|
||||
const catalog = { revision: 0, collections: [{ id: 'moon-garden', title: 'Moon Garden Universe', coverMode: 'mosaic', members: [
|
||||
{ mangaId: byTitle('Moon Garden'), role: 'main', readingOrder: 1 }, { mangaId: byTitle('Before the Rain'), role: 'prequel', readingOrder: 2 }] }],
|
||||
relations: [{ from: byTitle('Moon Garden'), to: byTitle('Before the Rain'), type: 'prequel', state: 'confirmed' }] };
|
||||
@@ -159,7 +214,9 @@ let keep = false;
|
||||
keep = process.argv.includes('--keep');
|
||||
if (keep) {
|
||||
const envFile = path.join(dir, 'qa.env');
|
||||
fs.writeFileSync(envFile, ['LIBRARY_PATH', 'DATA_PATH', 'SCRAPER_DATA_PATH', 'SENTINEL_DATA_PATH', 'SCRAPER_TOKEN_PATH', 'READER_PORT', 'READER_BIND', 'COOKIE_SECURE', 'APP_IMAGE'].map(k => `${k}=${env[k].replaceAll('\\', '/')}`).join('\n') + '\n');
|
||||
fs.writeFileSync(envFile, ['LIBRARY_PATH', 'DATA_PATH', 'SCRAPER_DATA_PATH', 'SENTINEL_DATA_PATH', 'SCRAPER_TOKEN_PATH', 'READER_PORT', 'READER_BIND', 'COOKIE_SECURE', 'APP_IMAGE',
|
||||
...(readingGateQa ? ['READING_WINDOWS_JSON', 'READING_TIMEZONE', 'READING_QUIZ_UNLOCK_MINUTES', 'READING_QUIZ_CATALOG_PATH'] : [])]
|
||||
.map(k => `${k}=${k === 'READING_WINDOWS_JSON' ? `'${env[k]}'` : env[k].replaceAll('\\', '/')}`).join('\n') + '\n');
|
||||
console.log(JSON.stringify({ project, url, envFile, fixture: dir }));
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
const path = require('path');
|
||||
const { loadQuestionBank } = require('../reading-access');
|
||||
const file = process.argv[2];
|
||||
if (!file) { console.error('Usage: node tools/validate-reading-quiz.js <catalog.json>'); process.exit(2); }
|
||||
try {
|
||||
const bank = loadQuestionBank(path.resolve(file));
|
||||
console.log(JSON.stringify({ works: Object.keys(bank).length,
|
||||
questions: Object.values(bank).reduce((count, questions) => count + questions.length, 0) }));
|
||||
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
Reference in new issue
Block a user