// The rules for a read-only link: it can expire, and it can need a passcode. Kept separate from the routes // so they can be tested on their own. import bcrypt from 'bcryptjs'; export const MAX_PASSCODE = 100; // "never" (null/0) or a number of days, up to a year -> an expiry time in ms (or null) export function expiryFromDays(days, now = Date.now()) { const n = Number(days); if (!days || !Number.isFinite(n) || n <= 0) return null; return now + Math.min(365, Math.round(n)) * 86_400_000; } export async function hashPasscode(passcode) { const p = String(passcode ?? '').slice(0, MAX_PASSCODE); return p.trim() ? bcrypt.hash(p.trim(), 10) : null; } // -> { ok: true } | { ok: false, status: 410, error } (expired) | { ok: false, status: 401, needsPasscode: true, error } export async function checkShareAccess({ expiresAt, passHash, supplied }, now = Date.now()) { if (expiresAt && expiresAt <= now) return { ok: false, status: 410, error: 'This link has expired. Ask the person who shared it for a new one.' }; if (!passHash) return { ok: true }; const given = String(supplied ?? '').slice(0, MAX_PASSCODE).trim(); if (!given) return { ok: false, status: 401, needsPasscode: true, error: 'This link needs a passcode.' }; if (!(await bcrypt.compare(given, passHash))) return { ok: false, status: 401, needsPasscode: true, error: 'That passcode is not right.' }; return { ok: true }; } // "Hide my notes until…": before the reveal time a shared study shows only the passages and the discussion questions // (no notes, outline, script, words or group notes), and says when the rest appears. The strip is done here, on the // server, so the hidden text never reaches the browser. After the time (or with none set) the study is sent whole. const OPEN_CHUNK_FIELDS = ['id', 'startVerse', 'endVerse', 'spilloverEndVerse', 'episodeNumber', 'episodeTitle', 'discussionQuestions']; export function hideUntilReveal(project, revealAt, now = Date.now()) { if (!revealAt || revealAt <= now || !project || typeof project !== 'object') return project; return { id: project.id, title: project.title, translation: project.translation, series: project.series, outline: [], hiddenUntil: revealAt, chapters: (Array.isArray(project.chapters) ? project.chapters : []).map((ch) => ({ book: ch.book, bookAbbrev: ch.bookAbbrev, chapter: ch.chapter, verses: ch.verses, chunks: (Array.isArray(ch.chunks) ? ch.chunks : []).map((chunk) => Object.fromEntries(OPEN_CHUNK_FIELDS.filter((f) => f in chunk).map((f) => [f, chunk[f]]))), })), }; } // A reveal time from the browser -> ms in the future (up to a year), or null for "no hiding" export function revealFromInput(value, now = Date.now()) { const n = Number(value); if (!value || !Number.isFinite(n) || n <= now) return null; return Math.min(n, now + 365 * 86_400_000); }