Add upload email notifications with Seafile direct-upload watcher; v2.0.6
- Portal uploads send one email immediately (host, episode, passage, description, filenames) via Resend to NUGGETS_NOTIFY_EMAIL - Seafile watcher polls /Episode Upload every 15 min for direct uploads and sends a notification for any new file not already seen via portal - Files uploaded through the portal are marked seen so the watcher never sends a duplicate notification - Credentials kept out of git: SEAFILE_TOKEN, SEAFILE_NUGGETS_REPO_ID, NUGGETS_NOTIFY_EMAIL now sourced from docker .env file Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
6a03813914
commit
1e396644a9
7 files changed
+177
-5
No files matched your search
+5
-4
@@ -11,11 +11,12 @@ services:
|
|||||||
- ADMIN_PASSWORD=`generate a random password and set it here`
|
- ADMIN_PASSWORD=`generate a random password and set it here`
|
||||||
- RESEND_API_KEY=`set your Resend API key here`
|
- RESEND_API_KEY=`set your Resend API key here`
|
||||||
# Seafile integration for Nuggets host upload portal
|
# Seafile integration for Nuggets host upload portal
|
||||||
# Get SEAFILE_TOKEN from: Seafile → Profile → Settings → API Token → Generate
|
# Set SEAFILE_TOKEN and SEAFILE_NUGGETS_REPO_ID in a .env file alongside this compose file
|
||||||
# Get SEAFILE_NUGGETS_REPO_ID from the URL when viewing the Nuggets library in Seafile
|
|
||||||
- SEAFILE_URL=https://seafile.necloud.us
|
- SEAFILE_URL=https://seafile.necloud.us
|
||||||
- SEAFILE_TOKEN=your_seafile_api_token_here
|
- SEAFILE_TOKEN=${SEAFILE_TOKEN}
|
||||||
- SEAFILE_NUGGETS_REPO_ID=your_repo_id_here
|
- 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:
|
volumes:
|
||||||
- /media/ZimaOS-HD/AppData/siteforge/data:/app/data
|
- /media/ZimaOS-HD/AppData/siteforge/data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.5",
|
"version": "2.0.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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 registerEpisodes } from './server/routes/episodes.js'
|
||||||
import { register as registerNuggets } from './server/routes/nuggets.js'
|
import { register as registerNuggets } from './server/routes/nuggets.js'
|
||||||
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.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 registerFeed } from './server/routes/feed.js'
|
||||||
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
||||||
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
|
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
|
||||||
@@ -174,6 +175,7 @@ Promise.all([
|
|||||||
}, BACKUP_INTERVAL_MS)
|
}, BACKUP_INTERVAL_MS)
|
||||||
|
|
||||||
startReminderScheduler()
|
startReminderScheduler()
|
||||||
|
startNuggetsSeafileWatcher()
|
||||||
|
|
||||||
// Purge expired study sessions every hour
|
// Purge expired study sessions every hour
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|||||||
@@ -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 EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json')
|
||||||
export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.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_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 EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json')
|
||||||
export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.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')
|
export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json')
|
||||||
|
|||||||
@@ -712,3 +712,55 @@ export async function sendStudyWeeklyDigestEmail(email, displayName, lessons, un
|
|||||||
console.error('[study-digest] send exception:', err)
|
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 = `
|
||||||
|
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:15px;color:#2c2417;">A new episode file has arrived in Seafile.</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-family:Arial,sans-serif;font-size:14px;color:#2c2417;">
|
||||||
|
<tr><td style="padding:6px 0;color:#7a7060;width:120px;">Source</td><td style="padding:6px 0;font-weight:600;">${escapeHtml(sourceLabel)}</td></tr>
|
||||||
|
${hostName ? `<tr><td style="padding:6px 0;color:#7a7060;">Host</td><td style="padding:6px 0;">${escapeHtml(hostName)}</td></tr>` : ''}
|
||||||
|
${episodeName ? `<tr><td style="padding:6px 0;color:#7a7060;">Episode</td><td style="padding:6px 0;font-weight:600;">${escapeHtml(episodeName)}</td></tr>` : ''}
|
||||||
|
${passage ? `<tr><td style="padding:6px 0;color:#7a7060;">Passage</td><td style="padding:6px 0;">${escapeHtml(passage)}</td></tr>` : ''}
|
||||||
|
<tr><td style="padding:6px 0;color:#7a7060;">Audio File</td><td style="padding:6px 0;font-family:monospace;font-size:13px;">${escapeHtml(filename)}</td></tr>
|
||||||
|
${infoFile ? `<tr><td style="padding:6px 0;color:#7a7060;">Info File</td><td style="padding:6px 0;font-family:monospace;font-size:13px;">${escapeHtml(infoFile)}</td></tr>` : ''}
|
||||||
|
</table>
|
||||||
|
${description ? `<p style="margin:16px 0 0;font-family:Arial,sans-serif;font-size:14px;color:#2c2417;"><strong>Description:</strong><br/>${escapeHtml(description).replace(/\n/g, '<br/>')}</p>` : ''}
|
||||||
|
`
|
||||||
|
|
||||||
|
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: '<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;color:#7a7060;">This notification was sent because a file was uploaded to the Nuggets Seafile library.</p>',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (error) console.error('[nuggets-notify] send error:', error)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[nuggets-notify] send exception:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||
@@ -6,8 +6,11 @@ import rateLimit from 'express-rate-limit'
|
|||||||
import { createHash, timingSafeEqual } from 'node:crypto'
|
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||||
import { parseCookies, cookieSecureFlag } from '../helpers.js'
|
import { parseCookies, cookieSecureFlag } from '../helpers.js'
|
||||||
import { requireAdminAuth } from '../auth.js'
|
import { requireAdminAuth } from '../auth.js'
|
||||||
|
import { sendNuggetsUploadNotification } from '../email.js'
|
||||||
|
import { markSeafileFileSeen } from '../nuggets-seafile-watcher.js'
|
||||||
import {
|
import {
|
||||||
NUGGETS_UPLOAD_HOSTS_FILE,
|
NUGGETS_UPLOAD_HOSTS_FILE,
|
||||||
|
NUGGETS_NOTIFY_EMAIL,
|
||||||
SEAFILE_URL,
|
SEAFILE_URL,
|
||||||
SEAFILE_TOKEN,
|
SEAFILE_TOKEN,
|
||||||
SEAFILE_NUGGETS_REPO_ID,
|
SEAFILE_NUGGETS_REPO_ID,
|
||||||
@@ -241,6 +244,22 @@ export function register(app) {
|
|||||||
await uploadToSeafile(infoLink, infoBuffer, infoFilename, SEAFILE_EPISODE_INFO_DIR)
|
await uploadToSeafile(infoLink, infoBuffer, infoFilename, SEAFILE_EPISODE_INFO_DIR)
|
||||||
|
|
||||||
console.log(`[nuggets-upload] ${session?.hostName ?? 'unknown'} uploaded "${episodeName}" (${originalName})`)
|
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 })
|
res.json({ ok: true, episodeName, filename: originalName, infoFile: infoFilename })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await unlink(tempPath).catch(() => {})
|
await unlink(tempPath).catch(() => {})
|
||||||
|
|||||||
Reference in new issue
Block a user