diff --git a/docker-compose.yml b/docker-compose.yml index 81b3854..9666a14 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,12 @@ services: - SITEFORGE_DATA_DIR=/app/data - 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 + - SEAFILE_URL=https://seafile.necloud.us + - SEAFILE_TOKEN=your_seafile_api_token_here + - SEAFILE_NUGGETS_REPO_ID=your_repo_id_here volumes: - /media/ZimaOS-HD/AppData/siteforge/data:/app/data restart: unless-stopped diff --git a/package-lock.json b/package-lock.json index f307729..3f87ed0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "express-rate-limit": "^8.4.1", "fuse.js": "^7.4.2", "mammoth": "^1.12.0", + "multer": "^2.3.0", "otplib": "^13.4.0", "prop-types": "^15.8.1", "qrcode": "^1.5.4", @@ -3903,6 +3904,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4163,9 +4170,19 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -4417,6 +4434,35 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/concurrently": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", @@ -8332,6 +8378,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", + "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -10096,6 +10204,14 @@ "node": ">= 0.4" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -10605,6 +10721,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index baea84c..aa71f90 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.0.3", + "version": "2.0.4", "type": "module", "scripts": { "dev": "vite", @@ -24,6 +24,7 @@ "express-rate-limit": "^8.4.1", "fuse.js": "^7.4.2", "mammoth": "^1.12.0", + "multer": "^2.3.0", "otplib": "^13.4.0", "prop-types": "^15.8.1", "qrcode": "^1.5.4", diff --git a/server.js b/server.js index eec6fee..a5b43ff 100644 --- a/server.js +++ b/server.js @@ -63,6 +63,7 @@ import { register as registerAnalytics } from './server/routes/analytics.js' 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 { 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' @@ -112,6 +113,7 @@ registerAnalytics(app) registerDownloads(app) registerEpisodes(app) registerNuggets(app) +registerNuggetsHostUpload(app) registerFeed(app) registerQrCodes(app) registerBiblePassage(app) diff --git a/server/config.js b/server/config.js index 3503e9b..f348c47 100644 --- a/server/config.js +++ b/server/config.js @@ -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') diff --git a/server/routes/nuggets-host-upload.js b/server/routes/nuggets-host-upload.js new file mode 100644 index 0000000..cd7dfa6 --- /dev/null +++ b/server/routes/nuggets-host-upload.js @@ -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 }) + }) +} diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 54a6460..9a23c76 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -666,7 +666,7 @@ type AdminView = | 'about:show-copy' | 'about:bio' | 'about:testimonials' | 'contact:profile' | 'contact:copy' | 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss' - | 'nuggets:settings' | 'nuggets:hosts' + | 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources' | 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes' | 'subscribers' | 'study-users' | 'email-templates' | 'broadcast' @@ -721,6 +721,7 @@ const ADMIN_NAV: NavGroup[] = [ { kind: 'parent', id: 'nuggets', label: 'Nuggets', children: [ { kind: 'leaf', view: 'nuggets:settings', label: 'Show Settings' }, { kind: 'leaf', view: 'nuggets:hosts', label: 'Hosts' }, + { kind: 'leaf', view: 'nuggets:upload-portal', label: 'Upload Portal' }, ]}, { kind: 'leaf', view: 'colossians-study', label: 'Studies' }, { kind: 'leaf', view: 'downloads', label: 'Resources' }, @@ -1095,6 +1096,183 @@ function QrCodeImage({ url, label }: { url: string; label: string }) { ) } +// ── Nuggets Upload Portal admin panel ───────────────────────────────────── +interface UploadHost { + id: string + name: string + username: string + active: boolean + createdAt: string +} + +function NuggetsUploadPortalAdmin() { + const [hosts, setHosts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [addName, setAddName] = useState('') + const [addUsername, setAddUsername] = useState('') + const [addPassword, setAddPassword] = useState('') + const [addError, setAddError] = useState('') + const [addSaving, setAddSaving] = useState(false) + const [resetId, setResetId] = useState(null) + const [resetPw, setResetPw] = useState('') + const [resetError, setResetError] = useState('') + const [resetSaving, setResetSaving] = useState(false) + + function reload() { + setLoading(true) + fetch('/api/admin/nuggets-upload/hosts') + .then(r => r.json()) + .then(d => setHosts(d.hosts ?? [])) + .catch(() => setError('Failed to load hosts.')) + .finally(() => setLoading(false)) + } + + useEffect(() => { reload() }, []) + + async function addHost(e: React.FormEvent) { + e.preventDefault() + setAddError('') + setAddSaving(true) + try { + const r = await fetch('/api/admin/nuggets-upload/hosts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: addName, username: addUsername, password: addPassword }), + }) + const d = await r.json() + if (!r.ok) { setAddError(d.message ?? 'Failed.'); return } + setAddName(''); setAddUsername(''); setAddPassword('') + reload() + } finally { + setAddSaving(false) + } + } + + async function toggleActive(host: UploadHost) { + await fetch(`/api/admin/nuggets-upload/hosts/${host.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ active: !host.active }), + }) + reload() + } + + async function deleteHost(id: string) { + if (!window.confirm('Remove this host account? This cannot be undone.')) return + await fetch(`/api/admin/nuggets-upload/hosts/${id}`, { method: 'DELETE' }) + reload() + } + + async function resetPassword(e: React.FormEvent) { + e.preventDefault() + if (!resetId) return + setResetError('') + setResetSaving(true) + try { + const r = await fetch(`/api/admin/nuggets-upload/hosts/${resetId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: resetPw }), + }) + const d = await r.json() + if (!r.ok) { setResetError(d.message ?? 'Failed.'); return } + setResetId(null); setResetPw('') + reload() + } finally { + setResetSaving(false) + } + } + + return ( +
+
+

Nuggets — Upload Portal

+

+ Manage host login accounts for the upload portal at{' '} + /host-upload. + Each host gets a username and password to upload episode files to Seafile. +

+

+ Make sure SEAFILE_URL, SEAFILE_TOKEN, and SEAFILE_NUGGETS_REPO_ID are set in your environment. +

+
+ + {error &&

{error}

} + {loading &&

Loading…

} + + {!loading && hosts.length > 0 && ( + + + + + + + + + + + {hosts.map(h => ( + + + + + + + ))} + +
NameUsernameStatus
{h.name}{h.username} + {h.active ? 'Active' : 'Disabled'} + + + + +
+ )} + {!loading && hosts.length === 0 &&

No upload hosts yet. Add one below.

} + + {resetId && ( +
+

Reset password for {hosts.find(h => h.id === resetId)?.username}

+
+
+ + setResetPw(e.target.value)} required minLength={8} /> +
+ + +
+ {resetError &&

{resetError}

} +
+ )} + +
+

Add Host Account

+
+
+ + setAddName(e.target.value)} required /> +
+
+ + setAddUsername(e.target.value.toLowerCase())} required /> +
+
+ + setAddPassword(e.target.value)} required minLength={8} /> +
+ +
+ {addError &&

{addError}

} +
+
+ ) +} + export default function AdminPage({ content, onSave, onLogout }: Props) { const normalizedContent = normalizeSiteContentForAdmin(content) const [form, setForm] = useState(normalizedContent) @@ -4717,6 +4895,9 @@ async function handleAssetUpload(event: ChangeEvent) { ) })()} + {/* NUGGETS UPLOAD PORTAL */} + {adminView === 'nuggets:upload-portal' && } + {/* STUDY COMMENTS */} {adminView === 'study-comments' && (() => { const filtered = studyComments.filter(c => { diff --git a/src/App.tsx b/src/App.tsx index 3cb9fc6..8b8ed23 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { usePageMeta } from './hooks/usePageMeta' import { useGlobalSearch } from './hooks/useGlobalSearch' import { GlobalSearch } from './components/GlobalSearch' import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer' +import { NuggetsHostPortal } from './components/NuggetsHostPortal' import { BIBLE_BOOKS } from '../shared/bible-books.js' import './App.css' import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking } from './analytics' @@ -3139,6 +3140,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> ('upload') + const [dragging, setDragging] = useState(false) + const [uploading, setUploading] = useState(false) + const [progress, setProgress] = useState(0) + const [uploadError, setUploadError] = useState('') + const [uploads, setUploads] = useState([]) + const fileInputRef = useRef(null) + const dropRef = useRef(null) + + useEffect(() => { + fetch('/api/nuggets-upload/me') + .then(r => r.json()) + .then(d => { + setAuthenticated(d.authenticated) + setHostName(d.hostName ?? '') + setSeafileConfigured(d.configured !== false) + }) + .finally(() => setChecking(false)) + }, []) + + async function handleLogin(e: React.FormEvent) { + e.preventDefault() + setLoginError('') + setLoggingIn(true) + try { + const r = await fetch('/api/nuggets-upload/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }) + const d = await r.json() + if (!r.ok) { + setLoginError(d.message ?? 'Login failed.') + return + } + setHostName(d.hostName) + setAuthenticated(true) + } finally { + setLoggingIn(false) + } + } + + async function handleLogout() { + await fetch('/api/nuggets-upload/logout', { method: 'POST' }) + setAuthenticated(false) + setHostName('') + setUsername('') + setPassword('') + setUploads([]) + } + + async function uploadFile(file: File) { + if (uploading) return + setUploadError('') + setUploading(true) + setProgress(0) + + const formData = new FormData() + formData.append('file', file) + formData.append('targetDir', target) + + return new Promise((resolve) => { + const xhr = new XMLHttpRequest() + xhr.open('POST', '/api/nuggets-upload/upload') + + xhr.upload.addEventListener('progress', e => { + if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100)) + }) + + xhr.addEventListener('load', () => { + setUploading(false) + if (xhr.status === 200) { + try { + const d = JSON.parse(xhr.responseText) + setUploads(prev => [{ filename: d.filename, dir: d.dir, at: new Date().toLocaleTimeString() }, ...prev]) + setProgress(100) + } catch { + setUploadError('Upload succeeded but response was unexpected.') + } + } else { + try { + const d = JSON.parse(xhr.responseText) + setUploadError(d.message ?? 'Upload failed.') + } catch { + setUploadError('Upload failed.') + } + } + resolve() + }) + + xhr.addEventListener('error', () => { + setUploading(false) + setUploadError('Network error during upload.') + resolve() + }) + + xhr.send(formData) + }) + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault() + setDragging(false) + const file = e.dataTransfer.files[0] + if (file) uploadFile(file) + } + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (file) uploadFile(file) + e.target.value = '' + } + + if (checking) { + return ( +
+
+

Loading…

+
+
+ ) + } + + if (!authenticated) { + return ( +
+
+
+ 🎙️ + Nuggets +
+

Host Upload Portal

+

Sign in to upload your episode files to Seafile.

+ +
+
+ + setUsername(e.target.value)} + disabled={loggingIn} + required + /> +
+
+ + setPassword(e.target.value)} + disabled={loggingIn} + required + /> +
+ {loginError &&

{loginError}

} + +
+
+
+ ) + } + + const targetLabel = target === 'upload' ? 'Episode Upload' : 'Episode Info' + const acceptTypes = target === 'upload' + ? '.mp3,.wav,.m4a,.aac,.flac,.ogg,.mp4,.mov' + : '*' + + return ( +
+
+
+
+ 🎙️ + Nuggets Upload Portal +
+
+ Hi, {hostName} + +
+
+ + {!seafileConfigured && ( +
+ Seafile is not configured yet. Contact the admin to set up the connection. +
+ )} + +
+ + +
+ +

+ {target === 'upload' + ? 'Upload your finished audio file (MP3, WAV, M4A, etc.).' + : 'Upload show notes, outlines, scripts, or any supporting document.'} +

+ +
{ e.preventDefault(); setDragging(true) }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + onClick={() => !uploading && fileInputRef.current?.click()} + > + + {uploading ? ( +
+

Uploading to {targetLabel}…

+
+
+
+

{progress}%

+
+ ) : ( + <> + {dragging ? '📂' : '⬆️'} +

{dragging ? 'Drop to upload' : 'Drop a file here, or click to browse'}

+

→ {targetLabel}

+ + )} +
+ + {uploadError &&

{uploadError}

} + + {uploads.length > 0 && ( +
+

Uploaded this session

+ {uploads.map((u, i) => ( +
+ ✓ + {u.filename} + {u.dir} · {u.at} +
+ ))} +
+ )} +
+
+ ) +} + +// ── Styles ──────────────────────────────────────────────────────────────── +const styles: Record = { + page: { + minHeight: '100vh', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '2rem 1rem', + background: 'var(--color-bg, #f7f5f0)', + }, + card: { + background: 'var(--color-surface, #fff)', + border: '1px solid var(--color-border, #e5e0d5)', + borderRadius: 16, + padding: '2.5rem 2rem', + width: '100%', + maxWidth: 420, + boxShadow: '0 4px 24px rgba(0,0,0,0.06)', + }, + logo: { + display: 'flex', + alignItems: 'center', + gap: 8, + marginBottom: 16, + }, + logoEmoji: { fontSize: '1.6rem' }, + logoTitle: { fontWeight: 700, fontSize: '1.05rem', letterSpacing: '-0.01em' }, + heading: { fontSize: '1.45rem', fontWeight: 700, margin: '0 0 6px' }, + subheading: { color: 'var(--color-muted, #888)', fontSize: '0.9rem', margin: '0 0 1.5rem' }, + form: { display: 'flex', flexDirection: 'column', gap: 16 }, + field: { display: 'flex', flexDirection: 'column', gap: 5 }, + label: { fontSize: '0.85rem', fontWeight: 600 }, + input: { + padding: '0.55rem 0.75rem', + border: '1px solid var(--color-border, #e5e0d5)', + borderRadius: 8, + fontSize: '0.95rem', + background: 'var(--color-input-bg, #faf9f7)', + outline: 'none', + }, + errorMsg: { color: '#c0392b', fontSize: '0.85rem', margin: '2px 0' }, + btnPrimary: { + background: '#c8a96e', + color: '#fff', + border: 'none', + borderRadius: 8, + padding: '0.65rem 1rem', + fontWeight: 700, + fontSize: '0.95rem', + cursor: 'pointer', + marginTop: 4, + }, + headerRow: { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + marginBottom: 20, + gap: 12, + flexWrap: 'wrap', + }, + headerRight: { display: 'flex', alignItems: 'center', gap: 10 }, + welcomeText: { fontSize: '0.85rem', color: 'var(--color-muted, #888)' }, + btnGhost: { + background: 'none', + border: '1px solid var(--color-border, #e5e0d5)', + borderRadius: 6, + padding: '0.3rem 0.6rem', + fontSize: '0.8rem', + cursor: 'pointer', + }, + warningBanner: { + background: '#fff8e1', + border: '1px solid #ffe082', + borderRadius: 8, + padding: '0.65rem 0.9rem', + fontSize: '0.85rem', + marginBottom: 16, + color: '#7a5f00', + }, + targetRow: { display: 'flex', gap: 8, marginBottom: 10 }, + tab: { + flex: 1, + background: 'var(--color-surface-alt, #f7f5f0)', + border: '1px solid var(--color-border, #e5e0d5)', + borderRadius: 8, + padding: '0.5rem', + fontSize: '0.85rem', + cursor: 'pointer', + fontWeight: 500, + }, + tabActive: { + flex: 1, + background: '#c8a96e', + border: '1px solid #c8a96e', + borderRadius: 8, + padding: '0.5rem', + fontSize: '0.85rem', + cursor: 'pointer', + fontWeight: 700, + color: '#fff', + }, + targetHint: { fontSize: '0.82rem', color: 'var(--color-muted, #888)', margin: '0 0 14px' }, + dropZone: { + border: '2px dashed var(--color-border, #e5e0d5)', + borderRadius: 12, + padding: '2.5rem 1.5rem', + textAlign: 'center', + cursor: 'pointer', + transition: 'border-color 0.15s, background 0.15s', + userSelect: 'none', + }, + dropZoneActive: { + borderColor: '#c8a96e', + background: 'rgba(200,169,110,0.06)', + }, + dropZoneDisabled: { cursor: 'default', opacity: 0.7 }, + dropIcon: { fontSize: '2rem', display: 'block', marginBottom: 8 }, + dropLabel: { fontWeight: 600, margin: '0 0 4px', fontSize: '0.95rem' }, + dropSub: { color: 'var(--color-muted, #888)', fontSize: '0.82rem', margin: 0 }, + progressWrap: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }, + progressBar: { + width: '100%', + height: 8, + background: '#e5e0d5', + borderRadius: 4, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + background: '#c8a96e', + borderRadius: 4, + transition: 'width 0.2s', + }, + progressPct: { fontSize: '0.85rem', color: 'var(--color-muted, #888)', margin: 0 }, + uploadList: { + marginTop: 20, + border: '1px solid var(--color-border, #e5e0d5)', + borderRadius: 10, + padding: '0.75rem 1rem', + }, + uploadListTitle: { fontWeight: 600, fontSize: '0.82rem', color: 'var(--color-muted, #888)', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.04em' }, + uploadRow: { display: 'flex', alignItems: 'baseline', gap: 8, padding: '4px 0', flexWrap: 'wrap' }, + uploadCheck: { color: '#27ae60', fontWeight: 700 }, + uploadName: { fontSize: '0.9rem', fontWeight: 500, wordBreak: 'break-all' }, + uploadMeta: { fontSize: '0.78rem', color: 'var(--color-muted, #888)', whiteSpace: 'nowrap' }, +}