- A study share link can hide the owner's notes until a chosen time: the server sends only passages and discussion questions until then, with a banner in the shared view (new share_reveal column, migrated on start). - Outline: a session length with "N of M min planned, X min over" and a marker on the point where the plan runs past it. - Export > Slides: printable 16:9 slides (title, passage, outline points with their verse, discussion questions) for one session or all; light or dark. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
52 lines
2.8 KiB
JavaScript
52 lines
2.8 KiB
JavaScript
// 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);
|
|
}
|