Build and Push Docker Image / build-and-push (push) Successful in 5m37s
Routes move to server/routes/ (auth, account, projects, reader, sharing, admin); index.js keeps setup only. A smoke test walks 90 API calls against the real server and compares every status with the pre-split behaviour. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
134 lines
7.0 KiB
JavaScript
134 lines
7.0 KiB
JavaScript
// Account settings: profile, reminder settings, test notification, and the full data export.
|
|
import { getAllProjects, getAllReaderInk, getPrayers, getProject, getReaderHistory, getReaderMarks, getReadingProgress, getUserById, getUserPrefs, setPodcastName, setPrayerReminder, setReminderSettings, setVotdSettings } from '../db.js';
|
|
import { requireAuth } from '../auth.js';
|
|
import { votdAvailable } from '../votd.js';
|
|
import { assertPublicUrl, isValidTimeZone, sendTestReminder } from '../reminders.js';
|
|
import { accountExportName, buildAccountExport } from '../accountExport.js';
|
|
|
|
export function registerAccountRoutes(app) {
|
|
// ---------------------------------------------------------------------------
|
|
// PATCH /api/auth/profile — update account-level settings (currently just podcastName)
|
|
// ---------------------------------------------------------------------------
|
|
app.patch('/api/auth/profile', requireAuth, (req, res) => {
|
|
try {
|
|
const podcastName = String(req.body?.podcastName ?? '').trim().slice(0, 200);
|
|
setPodcastName(req.session.userId, podcastName || null);
|
|
res.json({ podcastName: podcastName || null });
|
|
} catch (err) {
|
|
console.error('PATCH /api/auth/profile error:', err);
|
|
res.status(500).json({ error: 'Failed to save profile.' });
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh
|
|
// ---------------------------------------------------------------------------
|
|
app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
|
|
try {
|
|
const reminderEnabled = !!req.body?.reminderEnabled;
|
|
const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim();
|
|
const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim();
|
|
const reminderTime = String(req.body?.reminderTime ?? '').trim();
|
|
const reminderTz = isValidTimeZone(req.body?.reminderTz) ? String(req.body.reminderTz) : null;
|
|
|
|
if (ntfyTopic && !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
|
|
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
|
|
}
|
|
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
|
|
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
|
|
}
|
|
if (ntfyUrl) {
|
|
try { await assertPublicUrl(ntfyUrl); } catch (err) { return res.status(400).json({ error: err.message }); }
|
|
}
|
|
if (reminderEnabled) {
|
|
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to enable reminders.' });
|
|
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderTime)) {
|
|
return res.status(400).json({ error: 'Reminder time must be in HH:MM format.' });
|
|
}
|
|
}
|
|
|
|
// the verse of the day is optional, and only offered when the server can look it up
|
|
const votdEnabled = !!req.body?.votdEnabled && votdAvailable();
|
|
const votdTime = String(req.body?.votdTime ?? '').trim();
|
|
if (votdEnabled) {
|
|
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the verse of the day.' });
|
|
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(votdTime)) return res.status(400).json({ error: 'The verse of the day time must be in HH:MM format.' });
|
|
}
|
|
setVotdSettings(req.session.userId, { votdEnabled, votdTime: votdTime || null });
|
|
const prayerEnabled = !!req.body?.prayerEnabled;
|
|
const prayerTime = String(req.body?.prayerTime ?? '').trim();
|
|
if (prayerEnabled) {
|
|
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the prayer reminder.' });
|
|
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(prayerTime)) return res.status(400).json({ error: 'The prayer reminder time must be in HH:MM format.' });
|
|
}
|
|
setPrayerReminder(req.session.userId, { prayerEnabled, prayerTime: prayerTime || null });
|
|
setReminderSettings(req.session.userId, {
|
|
ntfyUrl: ntfyUrl || null,
|
|
ntfyTopic: ntfyTopic || null,
|
|
reminderTime: reminderTime || null,
|
|
reminderEnabled,
|
|
reminderTz,
|
|
});
|
|
res.json({
|
|
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null,
|
|
votdEnabled, votdTime: votdTime || null, prayerEnabled, prayerTime: prayerTime || null,
|
|
});
|
|
} catch (err) {
|
|
console.error('PATCH /api/auth/reminder-settings error:', err);
|
|
res.status(500).json({ error: 'Failed to save reminder settings.' });
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/account/export — everything this account holds, as one restorable file
|
|
// ---------------------------------------------------------------------------
|
|
app.get('/api/account/export', requireAuth, (req, res) => {
|
|
try {
|
|
const userId = req.session.userId;
|
|
const user = getUserById(userId);
|
|
const backup = buildAccountExport(user, {
|
|
projects: () => getAllProjects(userId).map((p) => getProject(p.id, userId)).filter(Boolean),
|
|
prefs: () => getUserPrefs(userId),
|
|
marks: () => getReaderMarks(userId),
|
|
ink: () => getAllReaderInk(userId),
|
|
plans: () => getReadingProgress(userId),
|
|
history: () => getReaderHistory(userId),
|
|
prayers: () => getPrayers(userId).filter((p) => !p.deleted),
|
|
});
|
|
res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`);
|
|
res.json(backup);
|
|
} catch (err) {
|
|
console.error('GET /api/account/export error:', err);
|
|
res.status(500).json({ error: 'Failed to export your data.' });
|
|
}
|
|
});
|
|
|
|
// POST /api/auth/reminder-test — sends a test notification to the topic in the request
|
|
// body (so it can be tried before saving), or to the saved settings.
|
|
const _lastReminderTest = new Map(); // userId -> timestamp, to keep test sends from being spammed
|
|
app.post('/api/auth/reminder-test', requireAuth, async (req, res) => {
|
|
const userId = req.session.userId;
|
|
if (Date.now() - (_lastReminderTest.get(userId) ?? 0) < 5000) {
|
|
return res.status(429).json({ error: 'Wait a few seconds before sending another test.' });
|
|
}
|
|
_lastReminderTest.set(userId, Date.now());
|
|
const saved = getUserById(userId);
|
|
const ntfyTopic = String(req.body?.ntfyTopic ?? saved?.ntfyTopic ?? '').trim();
|
|
const ntfyUrl = String(req.body?.ntfyUrl ?? saved?.ntfyUrl ?? '').trim();
|
|
if (!ntfyTopic) return res.status(400).json({ error: 'Enter a topic first.' });
|
|
if (!/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
|
|
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
|
|
}
|
|
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
|
|
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
|
|
}
|
|
try {
|
|
await sendTestReminder({ ntfyTopic, ntfyUrl });
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
res.status(502).json({ error: err.message });
|
|
}
|
|
});
|
|
}
|