diff --git a/docker-compose.yml b/docker-compose.yml index 9666a14..e7c6921 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,11 +11,12 @@ services: - ADMIN_PASSWORD=`generate a random password and set it here` - RESEND_API_KEY=`set your Resend API key here` # Seafile integration for Nuggets host upload portal - # Get SEAFILE_TOKEN from: Seafile → Profile → Settings → API Token → Generate - # Get SEAFILE_NUGGETS_REPO_ID from the URL when viewing the Nuggets library in Seafile + # Set SEAFILE_TOKEN and SEAFILE_NUGGETS_REPO_ID in a .env file alongside this compose file - SEAFILE_URL=https://seafile.necloud.us - - SEAFILE_TOKEN=your_seafile_api_token_here - - SEAFILE_NUGGETS_REPO_ID=your_repo_id_here + - SEAFILE_TOKEN=${SEAFILE_TOKEN} + - SEAFILE_NUGGETS_REPO_ID=${SEAFILE_NUGGETS_REPO_ID} + # Upload notification email (defaults to RESEND_TO if not set) + - NUGGETS_NOTIFY_EMAIL=${NUGGETS_NOTIFY_EMAIL} volumes: - /media/ZimaOS-HD/AppData/siteforge/data:/app/data restart: unless-stopped diff --git a/package.json b/package.json index b01955b..cce3169 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.0.5", + "version": "2.0.6", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index a5b43ff..e07e9c0 100644 --- a/server.js +++ b/server.js @@ -64,6 +64,7 @@ import { register as registerDownloads } from './server/routes/downloads.js' import { register as registerEpisodes } from './server/routes/episodes.js' import { register as registerNuggets } from './server/routes/nuggets.js' import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js' +import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js' import { register as registerFeed } from './server/routes/feed.js' import { register as registerQrCodes } from './server/routes/qr-codes.js' import { register as registerBiblePassage } from './server/routes/bible-passage.js' @@ -174,6 +175,7 @@ Promise.all([ }, BACKUP_INTERVAL_MS) startReminderScheduler() + startNuggetsSeafileWatcher() // Purge expired study sessions every hour setInterval(() => { diff --git a/server/config.js b/server/config.js index f348c47..3db51e3 100644 --- a/server/config.js +++ b/server/config.js @@ -59,6 +59,11 @@ export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json') export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json') export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json') export const NUGGETS_UPLOAD_HOSTS_FILE = path.join(DATA_DIR, 'nuggets-upload-hosts.json') +export const NUGGETS_SEAFILE_SEEN_FILE = path.join(DATA_DIR, 'nuggets-seafile-seen.json') + +// Email address for Nuggets upload notifications (defaults to the admin inbox) +export const NUGGETS_NOTIFY_EMAIL = process.env.NUGGETS_NOTIFY_EMAIL ?? process.env.RESEND_TO ?? 'hello@versebyversewithnate.us' +export const SEAFILE_POLL_INTERVAL_MS = 15 * 60 * 1000 // 15 minutes export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json') export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.json') export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json') diff --git a/server/email.js b/server/email.js index 3b482bf..7e67f73 100644 --- a/server/email.js +++ b/server/email.js @@ -712,3 +712,55 @@ export async function sendStudyWeeklyDigestEmail(email, displayName, lessons, un console.error('[study-digest] send exception:', err) } } + +// ── Nuggets upload notifications ─────────────────────────────────────────── + +export async function sendNuggetsUploadNotification({ to, source, hostName, episodeName, passage, description, filename, infoFile }) { + if (!process.env.RESEND_API_KEY) return + const resend = new Resend(process.env.RESEND_API_KEY) + const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM + const sourceLabel = source === 'portal' ? 'Upload Portal' : 'Direct to Seafile' + const subject = `New Nuggets Episode Upload: ${episodeName || filename}` + + const bodyHtml = ` +

A new episode file has arrived in Seafile.

+ + + ${hostName ? `` : ''} + ${episodeName ? `` : ''} + ${passage ? `` : ''} + + ${infoFile ? `` : ''} +
Source${escapeHtml(sourceLabel)}
Host${escapeHtml(hostName)}
Episode${escapeHtml(episodeName)}
Passage${escapeHtml(passage)}
Audio File${escapeHtml(filename)}
Info File${escapeHtml(infoFile)}
+ ${description ? `

Description:
${escapeHtml(description).replace(/\n/g, '
')}

` : ''} + ` + + const textBody = [ + `New Nuggets Episode Upload (${sourceLabel})`, + '', + hostName ? `Host: ${hostName}` : '', + episodeName ? `Episode: ${episodeName}` : '', + passage ? `Passage: ${passage}` : '', + `Audio File: ${filename}`, + infoFile ? `Info File: ${infoFile}` : '', + description ? `\nDescription:\n${description}` : '', + ].filter(Boolean).join('\n') + + try { + const { error } = await resend.emails.send({ + from, + to: [to], + subject, + text: textBody, + html: buildBrandedEmailHtml({ + title: 'New Episode Upload', + eyebrow: 'Nuggets', + bodyHtml, + footerHtml: '

This notification was sent because a file was uploaded to the Nuggets Seafile library.

', + }), + }) + if (error) console.error('[nuggets-notify] send error:', error) + } catch (err) { + console.error('[nuggets-notify] send exception:', err) + } +} diff --git a/server/nuggets-seafile-watcher.js b/server/nuggets-seafile-watcher.js new file mode 100644 index 0000000..d8dcdf0 --- /dev/null +++ b/server/nuggets-seafile-watcher.js @@ -0,0 +1,93 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { sendNuggetsUploadNotification } from './email.js' +import { + SEAFILE_URL, + SEAFILE_TOKEN, + SEAFILE_NUGGETS_REPO_ID, + SEAFILE_EPISODE_UPLOAD_DIR, + SEAFILE_EPISODE_INFO_DIR, + SEAFILE_POLL_INTERVAL_MS, + NUGGETS_SEAFILE_SEEN_FILE, + NUGGETS_NOTIFY_EMAIL, +} from './config.js' + +async function loadSeen() { + try { + return JSON.parse(await readFile(NUGGETS_SEAFILE_SEEN_FILE, 'utf8')) + } catch { + return { files: [] } + } +} + +async function saveSeen(data) { + await writeFile(NUGGETS_SEAFILE_SEEN_FILE, JSON.stringify(data, null, 2), 'utf8') +} + +// Called by the upload route after a successful portal upload so the poller +// doesn't also fire a notification for the same file. +export async function markSeafileFileSeen(filename) { + const data = await loadSeen() + if (!data.files.includes(filename)) { + data.files.push(filename) + await saveSeen(data) + } +} + +async function listSeafileDir(dir) { + if (!SEAFILE_URL || !SEAFILE_TOKEN || !SEAFILE_NUGGETS_REPO_ID) return [] + try { + const url = `${SEAFILE_URL}/api/v2.1/repos/${SEAFILE_NUGGETS_REPO_ID}/dir/?p=${encodeURIComponent(dir)}` + const res = await fetch(url, { + headers: { Authorization: `Token ${SEAFILE_TOKEN}` }, + signal: AbortSignal.timeout(10_000), + }) + if (!res.ok) return [] + const data = await res.json() + return (data.dirent ?? []).filter(e => e.type === 'file').map(e => e.name) + } catch { + return [] + } +} + +async function poll() { + if (!SEAFILE_URL || !SEAFILE_TOKEN || !SEAFILE_NUGGETS_REPO_ID) return + + const [uploadFiles, seen] = await Promise.all([ + listSeafileDir(SEAFILE_EPISODE_UPLOAD_DIR), + loadSeen(), + ]) + + const seenSet = new Set(seen.files) + const newFiles = uploadFiles.filter(f => !seenSet.has(f)) + + if (newFiles.length === 0) return + + for (const filename of newFiles) { + console.log(`[nuggets-watcher] new direct upload detected: ${filename}`) + await sendNuggetsUploadNotification({ + to: NUGGETS_NOTIFY_EMAIL, + source: 'direct', + filename, + }) + seenSet.add(filename) + } + + seen.files = [...seenSet] + await saveSeen(seen) +} + +export function startNuggetsSeafileWatcher() { + if (!SEAFILE_URL || !SEAFILE_TOKEN || !SEAFILE_NUGGETS_REPO_ID) { + console.log('[nuggets-watcher] Seafile not configured — watcher disabled.') + return + } + // Initial poll after 2 min (let the server settle), then every 15 min + setTimeout(() => { + poll().catch(err => console.error('[nuggets-watcher] poll error:', err)) + setInterval(() => { + poll().catch(err => console.error('[nuggets-watcher] poll error:', err)) + }, SEAFILE_POLL_INTERVAL_MS) + }, 2 * 60 * 1000) + + console.log(`[nuggets-watcher] started — polling every ${SEAFILE_POLL_INTERVAL_MS / 60000} min`) +} diff --git a/server/routes/nuggets-host-upload.js b/server/routes/nuggets-host-upload.js index ab50f25..fb642b1 100644 --- a/server/routes/nuggets-host-upload.js +++ b/server/routes/nuggets-host-upload.js @@ -6,8 +6,11 @@ import rateLimit from 'express-rate-limit' import { createHash, timingSafeEqual } from 'node:crypto' import { parseCookies, cookieSecureFlag } from '../helpers.js' import { requireAdminAuth } from '../auth.js' +import { sendNuggetsUploadNotification } from '../email.js' +import { markSeafileFileSeen } from '../nuggets-seafile-watcher.js' import { NUGGETS_UPLOAD_HOSTS_FILE, + NUGGETS_NOTIFY_EMAIL, SEAFILE_URL, SEAFILE_TOKEN, SEAFILE_NUGGETS_REPO_ID, @@ -241,6 +244,22 @@ export function register(app) { await uploadToSeafile(infoLink, infoBuffer, infoFilename, SEAFILE_EPISODE_INFO_DIR) console.log(`[nuggets-upload] ${session?.hostName ?? 'unknown'} uploaded "${episodeName}" (${originalName})`) + + // Mark audio file as seen so the Seafile watcher doesn't double-notify + await markSeafileFileSeen(originalName) + + // Send admin notification (fire-and-forget) + sendNuggetsUploadNotification({ + to: NUGGETS_NOTIFY_EMAIL, + source: 'portal', + hostName: session?.hostName ?? '', + episodeName, + passage, + description, + filename: originalName, + infoFile: infoFilename, + }).catch(err => console.error('[nuggets-upload] notify error:', err)) + res.json({ ok: true, episodeName, filename: originalName, infoFile: infoFilename }) } catch (err) { await unlink(tempPath).catch(() => {})