Files

479 lines
20 KiB
JavaScript
Raw Permalink Normal View History

2026-09-13 20:23:05 +01:00
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const html = `
<div style="font-family:Arial,Helvetica,sans-serif;max-width:520px;margin:auto;color:#111">
<h2 style="margin:0 0 6px">${esc(meta.title)}</h2>
<p style="color:#555;margin:0 0 16px">You're invited to watch this presentation.</p>
${note ? `<p>${esc(note)}</p>` : ''}
<p><a href="${esc(url)}" style="display:inline-block;background:#ffb020;color:#2a1c00;padding:11px 18px;border-radius:8px;text-decoration:none;font-weight:bold">Open the presentation</a></p>
<p style="color:#555;font-size:13px">Or scan this QR code:</p>
<img src="cid:qrcode" alt="QR code" width="220" height="220" style="border:1px solid #eee;border-radius:8px"/>
<p style="color:#888;font-size:12px;margin-top:16px">${esc(url)}</p>
</div>`;
try {
await sendMail({
to, actor: req.user.username,
subject: `Join the presentation: ${meta.title}`,
text: `You're invited to watch "${meta.title}".\n\nOpen: ${url}\n${note ? '\n' + note + '\n' : ''}`,
html,
attachments: [{ filename: 'join-qr.png', content: qrPng, cid: 'qrcode' }]
});
res.json({ ok: true, sentTo: to });
} catch (err) {
res.status(502).json({ error: err.message });
}
});
// ---------------------------------------------------------------------------
// User management (admin)
// ---------------------------------------------------------------------------
app.get('/api/users', requireAdmin, (req, res) => res.json(listUsers()));
app.post('/api/users', requireAdmin, async (req, res) => {
try {
const u = await createUser(req.body || {});
await logEvent({ actor: req.user.username, action: 'user_create', message: `Created user "${u.username}" (${u.role})` });
res.status(201).json(u);
} catch (e) { res.status(400).json({ error: e.message }); }
});
app.delete('/api/users/:id', requireAdmin, async (req, res) => {
try {
const target = getUserById(req.params.id);
await deleteUser(req.params.id, req.user.id);
await logEvent({ actor: req.user.username, action: 'user_delete', message: `Deleted user "${target?.username || req.params.id}"` });
res.json({ ok: true });
} catch (e) { res.status(400).json({ error: e.message }); }
});
app.post('/api/users/:id/password', requireAdmin, async (req, res) => {
try {
await setPassword(req.params.id, req.body?.password);
await logEvent({ actor: req.user.username, action: 'user_password', message: `Reset password for "${getUserById(req.params.id)?.username}"` });
res.json({ ok: true });
} catch (e) { res.status(400).json({ error: e.message }); }
});
app.post('/api/users/:id/role', requireAdmin, async (req, res) => {
try {
await setRole(req.params.id, req.body?.role, req.user.id);
await logEvent({ actor: req.user.username, action: 'user_role', message: `Set "${getUserById(req.params.id)?.username}" to ${req.body?.role}` });
res.json({ ok: true });
} catch (e) { res.status(400).json({ error: e.message }); }
});
// ---------------------------------------------------------------------------
// Mail config + logs (admin)
// ---------------------------------------------------------------------------
app.get('/api/mail', requireAdmin, (req, res) => res.json(getMailConfig()));
app.put('/api/mail', requireAdmin, async (req, res) => {
const saved = await saveMailConfig(req.body || {});
await logEvent({ actor: req.user.username, action: 'mail_config', message: 'Updated mail settings' });
res.json(saved);
});
app.post('/api/mail/test', requireAdmin, async (req, res) => {
const to = req.body?.to || req.user.email;
if (!to) return res.status(400).json({ error: 'Provide a test recipient address.' });
try {
await sendMail({
to, actor: req.user.username,
subject: 'QR Presentation — test email',
text: 'This is a test message from your QR Presentation server. Mail is working.',
html: '<p>This is a test message from your <b>QR Presentation</b> server. Mail is working.</p>'
});
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('');
});