Build and Push Docker Image / build-and-push (push) Successful in 6m47s
- Admin: sign-up Open / Invite only / Closed with single-use invite links - Present mode for scripts (auto-scroll) and outlines (step through, pace vs planned minutes) - Series: group studies on the dashboard, filter by series, calendar file for a study's sessions - Lesson handout: printable page per session (passage, outline, questions, writing lines) - Save a chunk's notes as your own study template (synced) - Voice dictation for notes and scripts (browser speech recognition) - Reading progress: map of the 66 books and a calendar of days read, synced and merged across devices - Export highlights and bookmarks as Markdown or Word, grouped by named colour - Verse of the day push to the ntfy topic (needs IQBIBLE_API_KEY) - Sharing: co-teacher editing via single-use invite links; read-only links can expire and take a passcode Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
152 lines
7.6 KiB
JavaScript
152 lines
7.6 KiB
JavaScript
// Daily "don't forget to read" reminders, sent via ntfy.sh (https://ntfy.sh) —
|
|
// a free pub/sub push service: POST a message to a topic URL, anyone
|
|
// subscribed to that topic (via the ntfy app or a browser) gets notified.
|
|
// No API keys or push-subscription plumbing needed.
|
|
import { lookup } from 'dns/promises';
|
|
import { isIP } from 'net';
|
|
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate } from './db.js';
|
|
import { fetchVerseOfDay, votdMessage } from './votd.js';
|
|
|
|
// A reminder still goes out if the server was briefly down (or restarting) at the
|
|
// exact minute — as long as it's within this window after the chosen time.
|
|
const GRACE_MINUTES = 180;
|
|
const SEND_TIMEOUT_MS = 10_000;
|
|
// A failed send is retried at most this often, so a dead ntfy server isn't hit every minute for hours.
|
|
const RETRY_AFTER_MS = 15 * 60 * 1000;
|
|
const _lastAttempt = new Map(); // userId -> timestamp of the last failed send
|
|
|
|
// True for loopback, private, link-local and other non-public addresses (IPv4 and IPv6).
|
|
export function isPrivateAddress(addr) {
|
|
const v = isIP(addr);
|
|
if (v === 4) {
|
|
const [a, b] = addr.split('.').map(Number);
|
|
return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
|
|
|| (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224;
|
|
}
|
|
if (v === 6) {
|
|
const l = addr.toLowerCase();
|
|
if (l === '::' || l === '::1') return true;
|
|
if (l.startsWith('::ffff:')) return isPrivateAddress(l.slice(7));
|
|
return /^f[cd]/.test(l) || /^fe[89ab]/.test(l);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Rejects server URLs that would make this server call itself or its private network.
|
|
// A self-hosted ntfy on the LAN can be allowed by the server's admin with ALLOW_PRIVATE_NTFY=1.
|
|
export async function assertPublicUrl(raw) {
|
|
if (process.env.ALLOW_PRIVATE_NTFY === '1') return;
|
|
let u;
|
|
try { u = new URL(raw); } catch { throw new Error('Server URL is not valid.'); }
|
|
if (!/^https?:$/.test(u.protocol)) throw new Error('Server URL must start with http:// or https://.');
|
|
const host = u.hostname.replace(/^\[|\]$/g, '');
|
|
if (/^localhost$|\.localhost$|\.local$|\.internal$/i.test(host)) throw new Error('Server URL must be a public address.');
|
|
const addrs = isIP(host) ? [host] : (await lookup(host, { all: true }).catch(() => [])).map((a) => a.address);
|
|
if (addrs.length === 0) throw new Error(`Could not find ${host}.`);
|
|
if (addrs.some(isPrivateAddress)) throw new Error('Server URL must be a public address.');
|
|
}
|
|
|
|
export function isValidTimeZone(tz) {
|
|
if (!tz) return false;
|
|
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
|
|
}
|
|
|
|
// The calendar date ("YYYY-MM-DD") and minutes-since-midnight in a timezone; the
|
|
// server's own timezone when none (or an invalid one) is saved.
|
|
export function zonedNow(date = new Date(), tz) {
|
|
const timeZone = isValidTimeZone(tz) ? tz : undefined;
|
|
const parts = Object.fromEntries(
|
|
new Intl.DateTimeFormat('en-CA', {
|
|
timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
|
}).formatToParts(date).map((p) => [p.type, p.value]),
|
|
);
|
|
return { date: `${parts.year}-${parts.month}-${parts.day}`, minutes: Number(parts.hour) * 60 + Number(parts.minute) };
|
|
}
|
|
|
|
// Is this user's reminder due right now (and not already sent today)?
|
|
export function isDue(user, now = new Date()) {
|
|
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(user.reminderTime ?? '');
|
|
if (!match) return false;
|
|
const target = Number(match[1]) * 60 + Number(match[2]);
|
|
const local = zonedNow(now, user.reminderTz);
|
|
if (user.lastReminderDate === local.date) return false;
|
|
return local.minutes >= target && local.minutes < target + GRACE_MINUTES;
|
|
}
|
|
|
|
// POSTs one notification to the user's ntfy topic. Throws with a readable message on failure.
|
|
export async function sendNtfy(user, { title, body, tags = 'open_book' }) {
|
|
const base = (user.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '');
|
|
const url = `${base}/${encodeURIComponent(user.ntfyTopic)}`;
|
|
await assertPublicUrl(url);
|
|
const headers = { Title: title, Tags: tags };
|
|
if (process.env.APP_URL) headers.Click = process.env.APP_URL; // tapping the notification opens the app
|
|
let res;
|
|
try {
|
|
res = await fetch(url, { method: 'POST', headers, body, redirect: 'error', signal: AbortSignal.timeout(SEND_TIMEOUT_MS) });
|
|
} catch (err) {
|
|
throw new Error(err.name === 'TimeoutError' ? `Timed out reaching ${base}` : `Could not reach ${base} (${err.cause?.code ?? err.message})`, { cause: err });
|
|
}
|
|
if (!res.ok) throw new Error(`ntfy responded ${res.status}`);
|
|
}
|
|
|
|
// Has the user already finished a reading day on `date` ("YYYY-MM-DD", in their timezone)?
|
|
export function readToday(progress, date) {
|
|
return Array.isArray(progress?.activity) && progress.activity.includes(date);
|
|
}
|
|
|
|
export function reminderBody(progress) {
|
|
const active = (Array.isArray(progress?.progress) ? progress.progress : []).filter((p) => !p.completedAt).length;
|
|
return active > 1 ? `Don't forget today's reading! You have ${active} plans in progress.` : "Don't forget today's reading!";
|
|
}
|
|
|
|
/** Sends a one-off notification so the user can confirm their topic/server work. */
|
|
export function sendTestReminder(user) {
|
|
return sendNtfy(user, { title: 'Reading reminders are working', body: 'This is a test from Bible Study Project. Your daily reminder will arrive like this.', tags: 'white_check_mark' });
|
|
}
|
|
|
|
export async function checkReminders(now = new Date()) {
|
|
for (const user of getUsersWithRemindersEnabled().filter((u) => isDue(u, now))) {
|
|
try {
|
|
const progress = getReadingProgress(user.id);
|
|
const hasActivePlan = Array.isArray(progress?.progress) && progress.progress.some((p) => !p.completedAt);
|
|
if (!hasActivePlan) continue; // nothing to nudge them about
|
|
if (readToday(progress, zonedNow(now, user.reminderTz).date)) continue; // already read today: no nudge
|
|
if (now.getTime() - (_lastAttempt.get(user.id) ?? 0) < RETRY_AFTER_MS) continue;
|
|
_lastAttempt.set(user.id, now.getTime());
|
|
await sendNtfy(user, { title: 'Reading Plan Reminder', body: reminderBody(progress) });
|
|
_lastAttempt.delete(user.id);
|
|
setLastReminderDate(user.id, zonedNow(now, user.reminderTz).date);
|
|
} catch (err) {
|
|
console.error(`Reading reminder failed for user ${user.id}:`, err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sends the verse of the day to everyone who asked for it, at their own time. One verse a day, the same for all.
|
|
export async function checkVotd(now = new Date()) {
|
|
const due = getUsersWithVotdEnabled().filter((u) => isDue({ reminderTime: u.votdTime, reminderTz: u.reminderTz, lastReminderDate: u.lastVotdDate }, now));
|
|
if (due.length === 0) return;
|
|
const verse = await fetchVerseOfDay(now);
|
|
if (!verse) return; // no key, or IQ Bible didn't answer: try again next minute (within the grace window)
|
|
for (const user of due) {
|
|
const key = `votd:${user.id}`;
|
|
if (now.getTime() - (_lastAttempt.get(key) ?? 0) < RETRY_AFTER_MS) continue;
|
|
_lastAttempt.set(key, now.getTime());
|
|
try {
|
|
await sendNtfy(user, votdMessage(verse));
|
|
_lastAttempt.delete(key);
|
|
setLastVotdDate(user.id, zonedNow(now, user.reminderTz).date);
|
|
} catch (err) {
|
|
console.error(`Verse of the day failed for user ${user.id}:`, err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Checks once a minute whether any user's reminder time has arrived. */
|
|
export function startReminderScheduler() {
|
|
setInterval(() => {
|
|
checkReminders().catch((err) => console.error('Reminder scheduler error:', err));
|
|
checkVotd().catch((err) => console.error('Verse of the day scheduler error:', err));
|
|
}, 60 * 1000);
|
|
}
|