Files
Siteforge/server/nuggets-seafile-watcher.js
nmemmertandClaude Sonnet 5.5 2a64f29a00
Publish Container / build (push) Successful in 15m46s
Second hardening pass: atomic data writes, bible-passage limits, webhook SSRF guard, note sanitizing, CI gates; v2.10.1
- Atomic temp-file + rename for all JSON data writes (no truncated files on crash)
- /api/bible-passage: rate limit, range caps (60 verses), bounded cache
- Admin webhooks: https-only, block private/internal/Tailscale addresses (checked at registration and delivery), no redirects
- Resend webhook token compare is timing-safe
- DOMPurify on rendered study notes and broadcast preview
- CI runs lint + tests before build
- Permissions-Policy and HSTS (production) headers; JSON error handler for /api
- Tests for URL safety and atomic writes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 09:00:28 -04:00

95 lines
2.8 KiB
JavaScript

import { readFile } from 'node:fs/promises'
import { writeFile } from './atomic-write.js'
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}/api2/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`)
}