Add Nuggets host upload portal with Seafile integration; v2.0.4
- New /host-upload page: per-host login (username + password) with session cookie, drag-and-drop file upload to Seafile via server proxy - Two upload targets: Episode Upload (audio) and Episode Info (docs) - Server route: /api/nuggets-upload/* for host auth + file proxy, /api/admin/nuggets-upload/hosts for admin CRUD (create/disable/reset pw/delete) - Admin panel: Nuggets → Upload Portal view to manage host accounts - Seafile config via SEAFILE_URL / SEAFILE_TOKEN / SEAFILE_NUGGETS_REPO_ID env vars - Directories default to /Episode Upload and /Episode Info (configurable) - Uses multer for multipart uploads; 500 MB per-file limit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
29d2ace1b2
commit
afdc00114a
9 files changed
+1074
-3
No files matched your search
@@ -58,12 +58,23 @@ export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json')
|
||||
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 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')
|
||||
export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
|
||||
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||
|
||||
// Seafile integration for Nuggets episode uploads
|
||||
export const SEAFILE_URL = (process.env.SEAFILE_URL ?? '').replace(/\/$/, '')
|
||||
export const SEAFILE_TOKEN = process.env.SEAFILE_TOKEN ?? ''
|
||||
export const SEAFILE_NUGGETS_REPO_ID = process.env.SEAFILE_NUGGETS_REPO_ID ?? ''
|
||||
export const SEAFILE_EPISODE_INFO_DIR = process.env.SEAFILE_EPISODE_INFO_DIR ?? '/Episode Info'
|
||||
export const SEAFILE_EPISODE_UPLOAD_DIR = process.env.SEAFILE_EPISODE_UPLOAD_DIR ?? '/Episode Upload'
|
||||
|
||||
export const HOST_SESSION_COOKIE = 'vbn_host_session'
|
||||
export const HOST_SESSION_TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
|
||||
export const INDEX_FILE = path.join(DIST_DIR, 'index.html')
|
||||
export const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images')
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFile, writeFile, unlink } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import multer from 'multer'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||
import { parseCookies, cookieSecureFlag } from '../helpers.js'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import {
|
||||
NUGGETS_UPLOAD_HOSTS_FILE,
|
||||
SEAFILE_URL,
|
||||
SEAFILE_TOKEN,
|
||||
SEAFILE_NUGGETS_REPO_ID,
|
||||
SEAFILE_EPISODE_INFO_DIR,
|
||||
SEAFILE_EPISODE_UPLOAD_DIR,
|
||||
HOST_SESSION_COOKIE,
|
||||
HOST_SESSION_TTL_MS,
|
||||
DATA_DIR,
|
||||
} from '../config.js'
|
||||
|
||||
// ── In-memory host sessions ───────────────────────────────────────────────
|
||||
const hostSessions = new Map() // token → { hostId, hostName, expiresAt }
|
||||
|
||||
function createHostSession(hostId, hostName) {
|
||||
const token = randomUUID()
|
||||
hostSessions.set(token, { hostId, hostName, expiresAt: Date.now() + HOST_SESSION_TTL_MS })
|
||||
return token
|
||||
}
|
||||
|
||||
function getHostSession(req) {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const token = cookies[HOST_SESSION_COOKIE]
|
||||
if (!token) return null
|
||||
const session = hostSessions.get(token)
|
||||
if (!session || session.expiresAt <= Date.now()) {
|
||||
hostSessions.delete(token)
|
||||
return null
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
function setHostSessionCookie(res, token) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${HOST_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(HOST_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
|
||||
)
|
||||
}
|
||||
|
||||
function clearHostSessionCookie(res) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${HOST_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
|
||||
)
|
||||
}
|
||||
|
||||
function requireHostAuth(req, res, next) {
|
||||
if (!getHostSession(req)) {
|
||||
res.status(401).json({ message: 'Not authenticated.' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// ── Host credentials data ─────────────────────────────────────────────────
|
||||
function hashPassword(pw) {
|
||||
return createHash('sha256').update(String(pw)).digest('hex')
|
||||
}
|
||||
|
||||
function passwordsMatch(plain, hash) {
|
||||
const a = Buffer.from(hashPassword(plain), 'utf8')
|
||||
const b = Buffer.from(hash, 'utf8')
|
||||
if (a.length !== b.length) return false
|
||||
return timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
async function loadUploadHosts() {
|
||||
try {
|
||||
const raw = await readFile(NUGGETS_UPLOAD_HOSTS_FILE, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return { hosts: [] }
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUploadHosts(data) {
|
||||
await writeFile(NUGGETS_UPLOAD_HOSTS_FILE, JSON.stringify(data, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
// ── Multer (disk storage, temp dir) ──────────────────────────────────────
|
||||
const UPLOAD_TEMP_DIR = path.join(DATA_DIR, 'upload-tmp')
|
||||
|
||||
const upload = multer({
|
||||
dest: UPLOAD_TEMP_DIR,
|
||||
limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB
|
||||
})
|
||||
|
||||
// ── Seafile helpers ───────────────────────────────────────────────────────
|
||||
function seafileConfigured() {
|
||||
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
|
||||
}
|
||||
|
||||
async function getSeafileUploadLink(dir) {
|
||||
const url = `${SEAFILE_URL}/api/v2.1/repos/${SEAFILE_NUGGETS_REPO_ID}/upload-link/?path=${encodeURIComponent(dir)}`
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`Seafile upload-link failed (${res.status}): ${body}`)
|
||||
}
|
||||
// Seafile returns a quoted string: "https://..."
|
||||
const text = await res.text()
|
||||
return text.replace(/^"|"$/g, '')
|
||||
}
|
||||
|
||||
async function uploadToSeafile(uploadLink, fileBuffer, filename, parentDir) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', new Blob([fileBuffer]), filename)
|
||||
formData.append('parent_dir', parentDir)
|
||||
formData.append('filename', filename)
|
||||
formData.append('replace', '1')
|
||||
|
||||
const res = await fetch(uploadLink, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
|
||||
body: formData,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`Seafile upload failed (${res.status}): ${body}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ── Rate limiter ──────────────────────────────────────────────────────────
|
||||
const loginRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { message: 'Too many login attempts. Wait 15 minutes and try again.' },
|
||||
skipSuccessfulRequests: true,
|
||||
})
|
||||
|
||||
// ── Route registration ────────────────────────────────────────────────────
|
||||
export function register(app) {
|
||||
// ── Host auth ─────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/nuggets-upload/me', (req, res) => {
|
||||
const session = getHostSession(req)
|
||||
if (!session) {
|
||||
res.json({ authenticated: false })
|
||||
return
|
||||
}
|
||||
res.json({ authenticated: true, hostName: session.hostName, configured: seafileConfigured() })
|
||||
})
|
||||
|
||||
app.post('/api/nuggets-upload/login', loginRateLimiter, async (req, res) => {
|
||||
const username = typeof req.body?.username === 'string' ? req.body.username.trim() : ''
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||||
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ message: 'Username and password are required.' })
|
||||
return
|
||||
}
|
||||
|
||||
const data = await loadUploadHosts()
|
||||
const host = data.hosts.find(h => h.username === username && h.active)
|
||||
if (!host || !passwordsMatch(password, host.passwordHash)) {
|
||||
res.status(401).json({ message: 'Invalid username or password.' })
|
||||
return
|
||||
}
|
||||
|
||||
const token = createHostSession(host.id, host.name)
|
||||
setHostSessionCookie(res, token)
|
||||
res.json({ ok: true, hostName: host.name })
|
||||
})
|
||||
|
||||
app.post('/api/nuggets-upload/logout', (req, res) => {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const token = cookies[HOST_SESSION_COOKIE]
|
||||
if (token) hostSessions.delete(token)
|
||||
clearHostSessionCookie(res)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ── File upload ───────────────────────────────────────────────────────
|
||||
|
||||
app.post('/api/nuggets-upload/upload', requireHostAuth, upload.single('file'), async (req, res) => {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ message: 'No file received.' })
|
||||
return
|
||||
}
|
||||
|
||||
const targetDir = req.body?.targetDir === 'info' ? SEAFILE_EPISODE_INFO_DIR : SEAFILE_EPISODE_UPLOAD_DIR
|
||||
const originalName = req.file.originalname || req.file.filename
|
||||
const tempPath = req.file.path
|
||||
|
||||
if (!seafileConfigured()) {
|
||||
await unlink(tempPath).catch(() => {})
|
||||
res.status(503).json({ message: 'Seafile is not configured on this server. Contact the admin.' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { readFile: rf } = await import('node:fs/promises')
|
||||
const fileBuffer = await rf(tempPath)
|
||||
|
||||
const uploadLink = await getSeafileUploadLink(targetDir)
|
||||
await uploadToSeafile(uploadLink, fileBuffer, originalName, targetDir)
|
||||
|
||||
await unlink(tempPath).catch(() => {})
|
||||
|
||||
const session = getHostSession(req)
|
||||
console.log(`[nuggets-upload] ${session?.hostName ?? 'unknown'} uploaded "${originalName}" to ${targetDir}`)
|
||||
res.json({ ok: true, filename: originalName, dir: targetDir })
|
||||
} catch (err) {
|
||||
await unlink(tempPath).catch(() => {})
|
||||
console.error('[nuggets-upload] upload error:', err.message)
|
||||
res.status(500).json({ message: `Upload failed: ${err.message}` })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Admin: manage upload host accounts ───────────────────────────────
|
||||
|
||||
app.get('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (_req, res) => {
|
||||
const data = await loadUploadHosts()
|
||||
// Strip password hashes from the response
|
||||
const hosts = data.hosts.map(({ passwordHash: _ph, ...h }) => h)
|
||||
res.json({ hosts })
|
||||
})
|
||||
|
||||
app.post('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (req, res) => {
|
||||
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''
|
||||
const username = typeof req.body?.username === 'string' ? req.body.username.trim().toLowerCase() : ''
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||||
|
||||
if (!name || !username || !password) {
|
||||
res.status(400).json({ message: 'Name, username, and password are required.' })
|
||||
return
|
||||
}
|
||||
if (password.length < 8) {
|
||||
res.status(400).json({ message: 'Password must be at least 8 characters.' })
|
||||
return
|
||||
}
|
||||
|
||||
const data = await loadUploadHosts()
|
||||
if (data.hosts.some(h => h.username === username)) {
|
||||
res.status(409).json({ message: 'Username already taken.' })
|
||||
return
|
||||
}
|
||||
|
||||
const host = {
|
||||
id: randomUUID(),
|
||||
name,
|
||||
username,
|
||||
passwordHash: hashPassword(password),
|
||||
active: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
data.hosts.push(host)
|
||||
await saveUploadHosts(data)
|
||||
|
||||
const { passwordHash: _ph, ...safeHost } = host
|
||||
res.json({ ok: true, host: safeHost })
|
||||
})
|
||||
|
||||
app.put('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
|
||||
const { id } = req.params
|
||||
const data = await loadUploadHosts()
|
||||
const idx = data.hosts.findIndex(h => h.id === id)
|
||||
if (idx === -1) {
|
||||
res.status(404).json({ message: 'Host not found.' })
|
||||
return
|
||||
}
|
||||
|
||||
const patch = {}
|
||||
if (typeof req.body?.name === 'string') patch.name = req.body.name.trim()
|
||||
if (typeof req.body?.active === 'boolean') patch.active = req.body.active
|
||||
if (typeof req.body?.password === 'string' && req.body.password.length > 0) {
|
||||
if (req.body.password.length < 8) {
|
||||
res.status(400).json({ message: 'Password must be at least 8 characters.' })
|
||||
return
|
||||
}
|
||||
patch.passwordHash = hashPassword(req.body.password)
|
||||
}
|
||||
|
||||
data.hosts[idx] = { ...data.hosts[idx], ...patch }
|
||||
await saveUploadHosts(data)
|
||||
|
||||
const { passwordHash: _ph, ...safeHost } = data.hosts[idx]
|
||||
res.json({ ok: true, host: safeHost })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
|
||||
const { id } = req.params
|
||||
const data = await loadUploadHosts()
|
||||
const idx = data.hosts.findIndex(h => h.id === id)
|
||||
if (idx === -1) {
|
||||
res.status(404).json({ message: 'Host not found.' })
|
||||
return
|
||||
}
|
||||
data.hosts.splice(idx, 1)
|
||||
await saveUploadHosts(data)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user