Add full portal expansion: 2FA, change password, history, announcements, recording guidelines, Seafile link, draft saving, file validation; v2.0.17
Publish Container / docker (push) Successful in 14m36s
Publish Container / docker (push) Successful in 14m36s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
afff859d17
commit
ef1d9fd8fc
6 files changed
+930
-132
No files matched your search
@@ -6,16 +6,23 @@ 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, sendNuggetsHostWelcomeEmail } from '../email.js'
|
||||
import {
|
||||
sendNuggetsUploadNotification,
|
||||
sendNuggetsHostWelcomeEmail,
|
||||
sendNuggetsHostOtpEmail,
|
||||
} from '../email.js'
|
||||
import { markSeafileFileSeen } from '../nuggets-seafile-watcher.js'
|
||||
import {
|
||||
NUGGETS_UPLOAD_HOSTS_FILE,
|
||||
NUGGETS_UPLOAD_HISTORY_FILE,
|
||||
NUGGETS_PORTAL_ANNOUNCEMENT_FILE,
|
||||
NUGGETS_NOTIFY_EMAIL,
|
||||
SEAFILE_URL,
|
||||
SEAFILE_TOKEN,
|
||||
SEAFILE_NUGGETS_REPO_ID,
|
||||
SEAFILE_EPISODE_INFO_DIR,
|
||||
SEAFILE_EPISODE_UPLOAD_DIR,
|
||||
SEAFILE_NUGGETS_SHARE_URL,
|
||||
HOST_SESSION_COOKIE,
|
||||
HOST_SESSION_TTL_MS,
|
||||
DATA_DIR,
|
||||
@@ -64,6 +71,15 @@ function requireHostAuth(req, res, next) {
|
||||
next()
|
||||
}
|
||||
|
||||
// ── In-memory pending OTP store ───────────────────────────────────────────
|
||||
const OTP_TTL_MS = 10 * 60 * 1000 // 10 minutes
|
||||
const pendingOtpStore = new Map() // otpToken → { hostId, hostName, code, expiresAt, attempts }
|
||||
|
||||
function generateOtpCode() {
|
||||
// 6-digit numeric code
|
||||
return String(Math.floor(100000 + Math.random() * 900000))
|
||||
}
|
||||
|
||||
// ── Host credentials data ─────────────────────────────────────────────────
|
||||
function hashPassword(pw) {
|
||||
return createHash('sha256').update(String(pw)).digest('hex')
|
||||
@@ -89,6 +105,40 @@ async function saveUploadHosts(data) {
|
||||
await writeFile(NUGGETS_UPLOAD_HOSTS_FILE, JSON.stringify(data, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
// ── History helpers ───────────────────────────────────────────────────────
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const raw = await readFile(NUGGETS_UPLOAD_HISTORY_FILE, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return { entries: [] }
|
||||
}
|
||||
}
|
||||
|
||||
async function appendHistory(entry) {
|
||||
const data = await loadHistory()
|
||||
data.entries.unshift({ ...entry, uploadedAt: new Date().toISOString() })
|
||||
// Keep last 1000 entries
|
||||
if (data.entries.length > 1000) data.entries = data.entries.slice(0, 1000)
|
||||
await writeFile(NUGGETS_UPLOAD_HISTORY_FILE, JSON.stringify(data, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
// ── Announcement helpers ──────────────────────────────────────────────────
|
||||
async function loadAnnouncement() {
|
||||
try {
|
||||
const raw = await readFile(NUGGETS_PORTAL_ANNOUNCEMENT_FILE, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return { text: '', updatedAt: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAnnouncement(text) {
|
||||
const data = { text: String(text ?? '').trim(), updatedAt: new Date().toISOString() }
|
||||
await writeFile(NUGGETS_PORTAL_ANNOUNCEMENT_FILE, JSON.stringify(data, null, 2), 'utf8')
|
||||
return data
|
||||
}
|
||||
|
||||
// ── Multer (disk storage, temp dir) ──────────────────────────────────────
|
||||
const UPLOAD_TEMP_DIR = path.join(DATA_DIR, 'upload-tmp')
|
||||
|
||||
@@ -135,7 +185,7 @@ async function uploadToSeafile(uploadLink, fileBuffer, filename, parentDir) {
|
||||
await res.text()
|
||||
}
|
||||
|
||||
// ── Rate limiter ──────────────────────────────────────────────────────────
|
||||
// ── Rate limiters ─────────────────────────────────────────────────────────
|
||||
const loginRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
@@ -145,17 +195,32 @@ const loginRateLimiter = rateLimit({
|
||||
skipSuccessfulRequests: true,
|
||||
})
|
||||
|
||||
const otpRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { message: 'Too many OTP attempts. Wait 15 minutes and try again.' },
|
||||
})
|
||||
|
||||
// ── Route registration ────────────────────────────────────────────────────
|
||||
export function register(app) {
|
||||
// ── Host auth ─────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/nuggets-upload/me', (req, res) => {
|
||||
app.get('/api/nuggets-upload/me', async (req, res) => {
|
||||
const session = getHostSession(req)
|
||||
if (!session) {
|
||||
res.json({ authenticated: false })
|
||||
return
|
||||
}
|
||||
res.json({ authenticated: true, hostName: session.hostName, configured: seafileConfigured() })
|
||||
const data = await loadUploadHosts()
|
||||
const host = data.hosts.find(h => h.id === session.hostId)
|
||||
res.json({
|
||||
authenticated: true,
|
||||
hostName: session.hostName,
|
||||
configured: seafileConfigured(),
|
||||
twoFaEnabled: Boolean(host?.twoFa),
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/nuggets-upload/login', loginRateLimiter, async (req, res) => {
|
||||
@@ -174,11 +239,63 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if 2FA is enabled for this host
|
||||
if (host.twoFa) {
|
||||
const code = generateOtpCode()
|
||||
const otpToken = randomUUID()
|
||||
pendingOtpStore.set(otpToken, {
|
||||
hostId: host.id,
|
||||
hostName: host.name,
|
||||
code,
|
||||
expiresAt: Date.now() + OTP_TTL_MS,
|
||||
attempts: 0,
|
||||
})
|
||||
sendNuggetsHostOtpEmail({ to: host.email, name: host.name, code }).catch(
|
||||
err => console.error('[nuggets-otp] send error:', err),
|
||||
)
|
||||
res.json({ requiresOtp: true, otpToken })
|
||||
return
|
||||
}
|
||||
|
||||
const token = createHostSession(host.id, host.name)
|
||||
setHostSessionCookie(res, token)
|
||||
res.json({ ok: true, hostName: host.name })
|
||||
})
|
||||
|
||||
app.post('/api/nuggets-upload/verify-otp', otpRateLimiter, (req, res) => {
|
||||
const otpToken = typeof req.body?.otpToken === 'string' ? req.body.otpToken : ''
|
||||
const code = typeof req.body?.code === 'string' ? req.body.code.trim() : ''
|
||||
|
||||
if (!otpToken || !code) {
|
||||
res.status(400).json({ message: 'OTP token and code are required.' })
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingOtpStore.get(otpToken)
|
||||
if (!pending || pending.expiresAt <= Date.now()) {
|
||||
pendingOtpStore.delete(otpToken)
|
||||
res.status(401).json({ message: 'Code expired. Please sign in again.' })
|
||||
return
|
||||
}
|
||||
|
||||
pending.attempts += 1
|
||||
if (pending.attempts > 5) {
|
||||
pendingOtpStore.delete(otpToken)
|
||||
res.status(429).json({ message: 'Too many attempts. Please sign in again.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (pending.code !== code) {
|
||||
res.status(401).json({ message: 'Incorrect code. Try again.' })
|
||||
return
|
||||
}
|
||||
|
||||
pendingOtpStore.delete(otpToken)
|
||||
const sessionToken = createHostSession(pending.hostId, pending.hostName)
|
||||
setHostSessionCookie(res, sessionToken)
|
||||
res.json({ ok: true, hostName: pending.hostName })
|
||||
})
|
||||
|
||||
app.post('/api/nuggets-upload/logout', (req, res) => {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const token = cookies[HOST_SESSION_COOKIE]
|
||||
@@ -187,6 +304,63 @@ export function register(app) {
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/nuggets-upload/change-password', requireHostAuth, async (req, res) => {
|
||||
const session = getHostSession(req)
|
||||
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
|
||||
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
res.status(400).json({ message: 'Current and new password are required.' })
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
res.status(400).json({ message: 'New password must be at least 8 characters.' })
|
||||
return
|
||||
}
|
||||
|
||||
const data = await loadUploadHosts()
|
||||
const idx = data.hosts.findIndex(h => h.id === session.hostId)
|
||||
if (idx === -1) {
|
||||
res.status(404).json({ message: 'Host account not found.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!passwordsMatch(currentPassword, data.hosts[idx].passwordHash)) {
|
||||
res.status(401).json({ message: 'Current password is incorrect.' })
|
||||
return
|
||||
}
|
||||
|
||||
data.hosts[idx].passwordHash = hashPassword(newPassword)
|
||||
await saveUploadHosts(data)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/nuggets-upload/2fa/enable', requireHostAuth, async (req, res) => {
|
||||
const session = getHostSession(req)
|
||||
const data = await loadUploadHosts()
|
||||
const idx = data.hosts.findIndex(h => h.id === session.hostId)
|
||||
if (idx === -1) {
|
||||
res.status(404).json({ message: 'Host account not found.' })
|
||||
return
|
||||
}
|
||||
data.hosts[idx].twoFa = true
|
||||
await saveUploadHosts(data)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/nuggets-upload/2fa/disable', requireHostAuth, async (req, res) => {
|
||||
const session = getHostSession(req)
|
||||
const data = await loadUploadHosts()
|
||||
const idx = data.hosts.findIndex(h => h.id === session.hostId)
|
||||
if (idx === -1) {
|
||||
res.status(404).json({ message: 'Host account not found.' })
|
||||
return
|
||||
}
|
||||
data.hosts[idx].twoFa = false
|
||||
await saveUploadHosts(data)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ── Episode upload (audio file + metadata) ───────────────────────────
|
||||
|
||||
app.post('/api/nuggets-upload/upload', requireHostAuth, upload.single('file'), async (req, res) => {
|
||||
@@ -248,6 +422,17 @@ export function register(app) {
|
||||
// Mark audio file as seen so the Seafile watcher doesn't double-notify
|
||||
await markSeafileFileSeen(originalName)
|
||||
|
||||
// Persist to history
|
||||
await appendHistory({
|
||||
hostId: session?.hostId ?? '',
|
||||
hostName: session?.hostName ?? '',
|
||||
episodeName,
|
||||
passage,
|
||||
description,
|
||||
filename: originalName,
|
||||
infoFile: infoFilename,
|
||||
})
|
||||
|
||||
// Send admin notification (fire-and-forget)
|
||||
sendNuggetsUploadNotification({
|
||||
to: NUGGETS_NOTIFY_EMAIL,
|
||||
@@ -260,7 +445,13 @@ export function register(app) {
|
||||
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,
|
||||
seafileUrl: SEAFILE_NUGGETS_SHARE_URL,
|
||||
})
|
||||
} catch (err) {
|
||||
await unlink(tempPath).catch(() => {})
|
||||
console.error('[nuggets-upload] upload error:', err.message)
|
||||
@@ -268,6 +459,22 @@ export function register(app) {
|
||||
}
|
||||
})
|
||||
|
||||
// ── Host: episode history ─────────────────────────────────────────────
|
||||
|
||||
app.get('/api/nuggets-upload/history', requireHostAuth, async (req, res) => {
|
||||
const session = getHostSession(req)
|
||||
const data = await loadHistory()
|
||||
const entries = data.entries.filter(e => e.hostId === session.hostId)
|
||||
res.json({ entries })
|
||||
})
|
||||
|
||||
// ── Public: announcement ──────────────────────────────────────────────
|
||||
|
||||
app.get('/api/nuggets-upload/announcement', async (_req, res) => {
|
||||
const data = await loadAnnouncement()
|
||||
res.json(data)
|
||||
})
|
||||
|
||||
// ── Admin: manage upload host accounts ───────────────────────────────
|
||||
|
||||
app.get('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (_req, res) => {
|
||||
@@ -303,6 +510,7 @@ export function register(app) {
|
||||
email,
|
||||
passwordHash: hashPassword(password),
|
||||
active: true,
|
||||
twoFa: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
data.hosts.push(host)
|
||||
@@ -371,4 +579,28 @@ export function register(app) {
|
||||
await saveUploadHosts(data)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ── Admin: announcement ───────────────────────────────────────────────
|
||||
|
||||
app.post('/api/admin/nuggets-upload/announcement', requireAdminAuth, async (req, res) => {
|
||||
const text = typeof req.body?.text === 'string' ? req.body.text : ''
|
||||
if (text.length > 500) {
|
||||
res.status(400).json({ message: 'Announcement must be 500 characters or fewer.' })
|
||||
return
|
||||
}
|
||||
const data = await saveAnnouncement(text)
|
||||
res.json({ ok: true, ...data })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/nuggets-upload/announcement', requireAdminAuth, async (_req, res) => {
|
||||
const data = await saveAnnouncement('')
|
||||
res.json({ ok: true, ...data })
|
||||
})
|
||||
|
||||
// ── Admin: upload history ─────────────────────────────────────────────
|
||||
|
||||
app.get('/api/admin/nuggets-upload/history', requireAdminAuth, async (_req, res) => {
|
||||
const data = await loadHistory()
|
||||
res.json({ entries: data.entries })
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user