Files
nmemmertandClaude Sonnet 5.5 bdb04cdedc
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
Add cross-reference map, admin health page, passkeys and device list, settings tabs
- Reader: chapter cross-reference map (M) from IQ Bible's reference graph
- Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs
- Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts
- Settings split into Account, Preferences, Notifications, Security, Your data tabs
- ntfy server field: catch a topic typed into the server box

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 12:52:49 -04:00

188 lines
9.5 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, getUsersWithPrayerReminder, setLastPrayerDate, getPrayers } from './db.js';
import { openPrayers } from './prayers.js';
import { logEvent } from './health.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);
logEvent('notify', `Reading reminder: ${err.message}`, { userId: user.id });
}
}
}
// 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);
logEvent('notify', `Verse of the day: ${err.message}`, { userId: user.id });
}
}
}
// The message for the daily prayer reminder: how many requests are open, and the first few.
export function prayerMessage(open) {
const lines = open.slice(0, 3).map((p) => `• ${p.forWhom ? `${p.forWhom}: ` : ''}${p.text.replace(/\s+/g, ' ').slice(0, 80)}${p.text.length > 80 ? '…' : ''}`);
const more = open.length - lines.length;
return {
title: open.length === 1 ? 'Time to pray: 1 request' : `Time to pray: ${open.length} requests`,
body: `${lines.join('\n')}${more > 0 ? `\n…and ${more} more` : ''}`,
tags: 'pray',
};
}
// Sends each person who asked for it a nudge to pray, with what they are praying about. Nothing is sent when
// the journal has no open requests.
export async function checkPrayerReminders(now = new Date()) {
for (const user of getUsersWithPrayerReminder().filter((u) => isDue({ reminderTime: u.prayerTime, reminderTz: u.reminderTz, lastReminderDate: u.lastPrayerDate }, now))) {
const open = openPrayers(getPrayers(user.id));
if (open.length === 0) continue;
const key = `prayer:${user.id}`;
if (now.getTime() - (_lastAttempt.get(key) ?? 0) < RETRY_AFTER_MS) continue;
_lastAttempt.set(key, now.getTime());
try {
await sendNtfy(user, prayerMessage(open));
_lastAttempt.delete(key);
setLastPrayerDate(user.id, zonedNow(now, user.reminderTz).date);
} catch (err) {
console.error(`Prayer reminder failed for user ${user.id}:`, err.message);
logEvent('notify', `Prayer reminder: ${err.message}`, { userId: user.id });
}
}
}
/** 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));
checkPrayerReminders().catch((err) => console.error('Prayer reminder scheduler error:', err));
}, 60 * 1000);
}