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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user