import { createServer } from 'node:http'; import fs from 'node:fs/promises'; import path from 'node:path'; import express from 'express'; import multer from 'multer'; import mime from 'mime-types'; import QRCode from 'qrcode'; import { Server as SocketServer } from 'socket.io'; import { nanoid } from 'nanoid'; import { PORT, HOST, PUBLIC_DIR, UPLOAD_DIR, PUBLIC_BASE_URL, MAX_UPLOAD_MB, APP_VERSION } from './config.js'; import { processUpload, SUPPORTED_HINT } from './convert.js'; import { initSessions, getSession, listSessions, saveSession, deleteSession, outDir, publicMeta } from './sessions.js'; import { initUsers, hasUsers, createFirstAdmin, verifyCredentials, createAuthSession, getAuthUser, destroyAuthSession, listUsers, createUser, deleteUser, setPassword, setRole, getUserById, publicUser } from './users.js'; import { initMail, isAvailable as mailAvailable, getMailConfig, saveMailConfig, sendMail } from './mailer.js'; import { logEvent, readLogs, clearLogs } from './logger.js'; await fs.mkdir(UPLOAD_DIR, { recursive: true }); await initSessions(); await initUsers(); await initMail(); // Migrate pre-1.4 sessions: ensure every one has an access code. for (const m of listSessions()) { if (!m.accessToken) { m.accessToken = nanoid(12); await saveSession(m); } } const app = express(); app.set('trust proxy', true); app.use(express.json()); // --------------------------------------------------------------------------- // Live (ephemeral) per-session state: what everyone is currently looking at. // --------------------------------------------------------------------------- const live = new Map(); function ensureLive(id) { let s = live.get(id); if (!s) { s = { currentPage: 1, mode: 'synced', video: { playing: false, time: 0, rate: 1, updatedAt: Date.now() }, viewers: new Set(), presenters: new Set() }; live.set(id, s); } return s; } function stateFor(id) { const s = ensureLive(id); return { meta: publicMeta(getSession(id)), mode: s.mode, currentPage: s.currentPage, video: s.video, counts: { viewers: s.viewers.size, presenters: s.presenters.size } }; } // --------------------------------------------------------------------------- // Auth helpers // --------------------------------------------------------------------------- function parseCookies(req) { const out = {}; for (const part of (req.headers.cookie || '').split(';')) { const i = part.indexOf('='); if (i > -1) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); } return out; } function currentUser(req) { return getAuthUser(parseCookies(req).sid); } function requireAuth(req, res, next) { const u = currentUser(req); if (!u) return res.status(401).json({ error: 'Please sign in.' }); req.user = u; next(); } function requireAdmin(req, res, next) { const u = currentUser(req); if (!u) return res.status(401).json({ error: 'Please sign in.' }); if (u.role !== 'admin') return res.status(403).json({ error: 'Admins only.' }); req.user = u; next(); } function setSessionCookie(res, token) { res.setHeader('Set-Cookie', `sid=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 7}`); } // --------------------------------------------------------------------------- // Auth routes // --------------------------------------------------------------------------- // First-run: create the initial admin account. Only works while no users exist. app.post('/api/setup', async (req, res) => { if (hasUsers()) return res.status(403).json({ error: 'Setup has already been completed.' }); try { const user = await createFirstAdmin(req.body || {}); setSessionCookie(res, createAuthSession(user.id)); await logEvent({ actor: user.username, action: 'setup', message: 'Created the first admin account' }); res.status(201).json({ user }); } catch (e) { res.status(400).json({ error: e.message }); } }); app.post('/api/login', async (req, res) => { const { username, password } = req.body || {}; const user = verifyCredentials(username, password); if (!user) { await logEvent({ level: 'warn', actor: String(username || '?'), action: 'login', message: 'Failed login attempt' }); return res.status(401).json({ error: 'Incorrect username or password.' }); } setSessionCookie(res, createAuthSession(user.id)); await logEvent({ actor: user.username, action: 'login', message: 'Signed in' }); res.json({ user: publicUser(user) }); }); app.post('/api/logout', (req, res) => { destroyAuthSession(parseCookies(req).sid); res.setHeader('Set-Cookie', 'sid=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0'); res.json({ ok: true }); }); app.get('/api/me', (req, res) => { const u = currentUser(req); res.json({ authed: Boolean(u), user: publicUser(u), needsSetup: !hasUsers() }); }); // --------------------------------------------------------------------------- // Presentations // --------------------------------------------------------------------------- const upload = multer({ storage: multer.diskStorage({ destination: (req, file, cb) => cb(null, UPLOAD_DIR), filename: (req, file, cb) => cb(null, `${nanoid(16)}${path.extname(file.originalname)}`) }), limits: { fileSize: MAX_UPLOAD_MB * 1024 * 1024 } }); app.post('/api/sessions', requireAuth, upload.single('file'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'No file was uploaded.' }); const id = nanoid(10); const meta = { id, presenterToken: nanoid(24), accessToken: nanoid(12), // embedded in the audience QR/link; rotated to invalidate old codes owner: req.user.id, ownerName: req.user.username, title: (req.body?.title || '').trim() || req.file.originalname, originalName: req.file.originalname, kind: null, viewerFile: null, status: 'processing', error: null, createdAt: Date.now() }; await saveSession(meta); await logEvent({ actor: req.user.username, action: 'deck_create', message: `Uploaded "${meta.title}"` }); res.status(201).json({ id, presenterToken: meta.presenterToken }); processUpload({ inputPath: req.file.path, originalName: req.file.originalname, outDir: outDir(id) }) .then(async (result) => { meta.kind = result.kind; meta.viewerFile = result.viewerFile; meta.status = 'ready'; await saveSession(meta); io.to(id).emit('state', stateFor(id)); }) .catch(async (err) => { meta.status = 'error'; meta.error = String(err.message || err); await saveSession(meta); await logEvent({ level: 'error', actor: req.user.username, action: 'deck_convert', message: `Failed "${meta.title}": ${meta.error}` }); io.to(id).emit('state', stateFor(id)); }) .finally(() => fs.unlink(req.file.path).catch(() => {})); }); app.get('/api/sessions', requireAuth, (req, res) => { const all = listSessions(); const mine = req.user.role === 'admin' ? all : all.filter((m) => m.owner === req.user.id); res.json(mine.map((m) => ({ id: m.id, title: m.title, kind: m.kind, status: m.status, error: m.error || null, createdAt: m.createdAt, presenterToken: m.presenterToken, accessToken: m.accessToken, ownerName: m.ownerName || null, viewers: live.get(m.id)?.viewers.size || 0 }))); }); app.get('/api/sessions/:id', (req, res) => { const meta = getSession(req.params.id); if (!meta) return res.status(404).json({ error: 'Presentation not found.' }); res.json(publicMeta(meta)); }); function canManage(user, meta) { return user.role === 'admin' || meta.owner === user.id || !meta.owner; } app.delete('/api/sessions/:id', requireAuth, async (req, res) => { const meta = getSession(req.params.id); if (!meta) return res.status(404).json({ error: 'Presentation not found.' }); if (!canManage(req.user, meta)) return res.status(403).json({ error: 'This is not your presentation.' }); await deleteSession(req.params.id); live.delete(req.params.id); io.to(req.params.id).emit('ended'); await logEvent({ actor: req.user.username, action: 'deck_delete', message: `Deleted "${meta.title}"` }); res.json({ ok: true }); }); // --------------------------------------------------------------------------- // QR + audience link // --------------------------------------------------------------------------- function baseUrl(req) { return PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`; } function viewUrl(req, meta) { return `${baseUrl(req)}/view/${meta.id}?k=${meta.accessToken}`; } // Rotate the access code so any previously generated QR / link stops working. async function rotateCode(meta) { meta.accessToken = nanoid(12); await saveSession(meta); return meta.accessToken; } // QR generation is owner/admin only, so a stale link can't be used to mint a fresh code. app.get('/api/sessions/:id/qr', requireAuth, async (req, res) => { const meta = getSession(req.params.id); if (!meta) return res.status(404).json({ error: 'Presentation not found.' }); if (!canManage(req.user, meta)) return res.status(403).json({ error: 'This is not your presentation.' }); const url = viewUrl(req, meta); const dataUrl = await QRCode.toDataURL(url, { errorCorrectionLevel: 'M', margin: 1, width: 640 }); res.json({ url, dataUrl }); }); app.get('/api/sessions/:id/qr.svg', requireAuth, async (req, res) => { const meta = getSession(req.params.id); if (!meta) return res.status(404).send('Not found'); if (!canManage(req.user, meta)) return res.status(403).send('Forbidden'); const svg = await QRCode.toString(viewUrl(req, meta), { type: 'svg', errorCorrectionLevel: 'M', margin: 1 }); res.type('image/svg+xml').send(svg); }); // Rotate the codes for every presentation the caller manages (used by dashboard Refresh), // so links from a finished session can no longer be opened. app.post('/api/sessions/rotate-codes', requireAuth, async (req, res) => { const targets = listSessions().filter((m) => canManage(req.user, m)); for (const m of targets) await rotateCode(m); res.json({ ok: true, rotated: targets.length }); }); // End a live presentation: invalidate its QR code and kick everyone watching. app.post('/api/sessions/:id/terminate', requireAuth, async (req, res) => { const meta = getSession(req.params.id); if (!meta) return res.status(404).json({ error: 'Presentation not found.' }); if (!canManage(req.user, meta)) return res.status(403).json({ error: 'This is not your presentation.' }); await rotateCode(meta); io.to(meta.id).emit('ended'); live.delete(meta.id); await logEvent({ actor: req.user.username, action: 'deck_terminate', message: `Ended "${meta.title}" and invalidated its QR code` }); res.json({ ok: true }); }); // Share a presentation link + QR by email. app.post('/api/sessions/:id/email', requireAuth, async (req, res) => { const meta = getSession(req.params.id); if (!meta) return res.status(404).json({ error: 'Presentation not found.' }); if (!canManage(req.user, meta)) return res.status(403).json({ error: 'This is not your presentation.' }); if (!mailAvailable()) return res.status(400).json({ error: 'Email is not set up yet. Ask an admin to configure Mail.' }); const to = String(req.body?.to || '').split(/[,;\s]+/).filter(Boolean); if (!to.length) return res.status(400).json({ error: 'Add at least one recipient email address.' }); const note = String(req.body?.message || '').trim(); const url = viewUrl(req, meta); const qrPng = await QRCode.toBuffer(url, { errorCorrectionLevel: 'M', margin: 1, width: 480 }); const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const html = `
You're invited to watch this presentation.
${note ? `${esc(note)}
` : ''}Or scan this QR code:
${esc(url)}
This is a test message from your QR Presentation server. Mail is working.
' }); res.json({ ok: true }); } catch (e) { res.status(502).json({ error: e.message }); } }); app.get('/api/logs', requireAdmin, async (req, res) => res.json(await readLogs(400))); app.delete('/api/logs', requireAdmin, async (req, res) => { await clearLogs(); await logEvent({ actor: req.user.username, action: 'logs_clear', message: 'Cleared activity log' }); res.json({ ok: true }); }); // --------------------------------------------------------------------------- // Serve converted assets. The audience must present the current access code // (?k=), so a rotated/terminated code stops working. Owners/admins bypass it. // --------------------------------------------------------------------------- app.get('/s/:id/:file', (req, res) => { const meta = getSession(req.params.id); if (!meta) return res.status(404).end(); const u = currentUser(req); const privileged = u && canManage(u, meta); if (!privileged && req.query.k !== meta.accessToken) { return res.status(403).end(); } const safe = path.basename(req.params.file); res.sendFile(path.join(outDir(req.params.id), safe), { headers: { 'Content-Type': mime.lookup(safe) || 'application/octet-stream' } }, (err) => { if (err && !res.headersSent) res.status(404).end(); }); }); // --------------------------------------------------------------------------- // Pages // --------------------------------------------------------------------------- const page = (name) => path.join(PUBLIC_DIR, name); app.use(express.static(PUBLIC_DIR, { index: false })); app.get('/config.json', (req, res) => res.json({ supportedHint: SUPPORTED_HINT, maxUploadMb: MAX_UPLOAD_MB, version: APP_VERSION, mailAvailable: mailAvailable() })); app.get('/login', (req, res) => res.sendFile(page('login.html'))); app.get('/', (req, res) => currentUser(req) ? res.sendFile(page('index.html')) : res.redirect('/login')); app.get('/users', (req, res) => { const u = currentUser(req); if (!u) return res.redirect('/login'); if (u.role !== 'admin') return res.redirect('/'); res.sendFile(page('users.html')); }); app.get('/present/:id', (req, res) => res.sendFile(page('present.html'))); app.get('/view/:id', (req, res) => res.sendFile(page('view.html'))); app.get('/display/:id', (req, res) => res.sendFile(page('display.html'))); app.get('/qr/:id', (req, res) => res.sendFile(page('qr.html'))); app.use((err, req, res, next) => { if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') { return res.status(413).json({ error: `File is larger than the ${MAX_UPLOAD_MB} MB limit.` }); } console.error(err); res.status(500).json({ error: 'Something went wrong on the server.' }); }); // --------------------------------------------------------------------------- // Real-time sync // --------------------------------------------------------------------------- const server = createServer(app); const io = new SocketServer(server, { maxHttpBufferSize: 1e6 }); io.on('connection', (socket) => { socket.on('join', ({ sessionId, role, token, k } = {}) => { const meta = getSession(sessionId); if (!meta) { socket.emit('error:join', 'This presentation is no longer available.'); return; } const s = ensureLive(sessionId); const asPresenter = role === 'presenter' && token && token === meta.presenterToken; // Audience must carry the current access code from the QR/link. if (!asPresenter && k !== meta.accessToken) { socket.emit('error:join', 'This QR code is no longer valid. Ask the presenter for the current one.'); return; } socket.data.sessionId = sessionId; socket.data.isPresenter = asPresenter; socket.join(sessionId); (asPresenter ? s.presenters : s.viewers).add(socket.id); socket.emit('joined', { isPresenter: asPresenter }); io.to(sessionId).emit('state', stateFor(sessionId)); }); socket.on('control', (payload = {}) => { const id = socket.data.sessionId; if (!id || !socket.data.isPresenter) return; const s = ensureLive(id); if (Number.isFinite(payload.currentPage)) s.currentPage = Math.max(1, Math.trunc(payload.currentPage)); if (payload.mode === 'synced' || payload.mode === 'free') s.mode = payload.mode; if (payload.video && typeof payload.video === 'object') { s.video = { playing: Boolean(payload.video.playing), time: Number(payload.video.time) || 0, rate: Number(payload.video.rate) || 1, updatedAt: Date.now() }; } io.to(id).emit('state', stateFor(id)); }); socket.on('disconnect', () => { const id = socket.data.sessionId; if (!id) return; const s = live.get(id); if (!s) return; s.viewers.delete(socket.id); s.presenters.delete(socket.id); io.to(id).emit('state', stateFor(id)); }); }); server.listen(PORT, HOST, () => { console.log(`\n QR Presentation v${APP_VERSION} on http://${HOST}:${PORT}`); if (PUBLIC_BASE_URL) console.log(` Audience links use: ${PUBLIC_BASE_URL}`); else console.log(' Tip: set PUBLIC_BASE_URL so phones off this machine can open the QR link.'); console.log(''); });