v_1.7
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const ROOT = path.resolve(__dirname, '..');
|
||||
export const PUBLIC_DIR = path.join(ROOT, 'public');
|
||||
|
||||
// All mutable state lives under DATA_DIR so it can be a mounted volume and
|
||||
// survive an "unzip the new version over the old one" upgrade untouched.
|
||||
export const DATA_DIR = process.env.DATA_DIR || path.join(ROOT, 'data');
|
||||
export const UPLOAD_DIR = path.join(DATA_DIR, 'uploads');
|
||||
export const SESSION_DIR = path.join(DATA_DIR, 'sessions');
|
||||
export const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||
export const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
||||
export const LOG_FILE = path.join(DATA_DIR, 'app.log');
|
||||
|
||||
export const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
export const HOST = process.env.HOST || '0.0.0.0';
|
||||
|
||||
// Absolute URL the audience's phones can reach. If empty we derive it from the
|
||||
// incoming request, which is fine on a LAN but should be set behind a proxy.
|
||||
export const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
||||
|
||||
// The first time the app runs with no users file, it seeds one admin account
|
||||
// from these values. After that they're ignored — manage users in the app.
|
||||
export const SEED_ADMIN_USER = process.env.ADMIN_USERNAME || 'admin';
|
||||
export const SEED_ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin';
|
||||
export const SEED_ADMIN_EMAIL = process.env.ADMIN_EMAIL || '';
|
||||
|
||||
export const MAX_UPLOAD_MB = parseInt(process.env.MAX_UPLOAD_MB || '500', 10);
|
||||
export const CONVERT_TIMEOUT_MS = parseInt(process.env.CONVERT_TIMEOUT_MS || '240000', 10);
|
||||
|
||||
export const SOFFICE_BIN = process.env.SOFFICE_BIN || 'libreoffice';
|
||||
export const FFMPEG_BIN = process.env.FFMPEG_BIN || 'ffmpeg';
|
||||
|
||||
export const APP_VERSION = '1.7';
|
||||
@@ -0,0 +1,121 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { SOFFICE_BIN, FFMPEG_BIN, CONVERT_TIMEOUT_MS } from './config.js';
|
||||
|
||||
// Anything LibreOffice can turn into a PDF becomes a paged "deck".
|
||||
const DOC_EXT = new Set([
|
||||
'ppt', 'pptx', 'odp', 'ppsx', 'pps',
|
||||
'doc', 'docx', 'odt', 'rtf', 'txt', 'md',
|
||||
'xls', 'xlsx', 'ods', 'csv', 'tsv'
|
||||
]);
|
||||
const VIDEO_EXT = new Set([
|
||||
'mp4', 'webm', 'ogg', 'ogv', 'mov', 'm4v',
|
||||
'avi', 'mkv', 'wmv', 'flv', '3gp', 'mpeg', 'mpg', 'ts'
|
||||
]);
|
||||
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'avif']);
|
||||
// Formats browsers can generally stream without a re-encode.
|
||||
const WEB_VIDEO = new Set(['mp4', 'webm', 'ogg', 'ogv']);
|
||||
|
||||
function run(cmd, args, { timeout = CONVERT_TIMEOUT_MS, cwd, env } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { cwd, env: { ...process.env, ...env } });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error(`${cmd} timed out after ${timeout}ms`));
|
||||
}, timeout);
|
||||
child.stdout.on('data', (d) => (stdout += d.toString()));
|
||||
child.stderr.on('data', (d) => (stderr += d.toString()));
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`Could not start ${cmd}: ${err.message}`));
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolve({ stdout, stderr });
|
||||
else reject(new Error(`${cmd} exited with code ${code}: ${(stderr || stdout).slice(-500)}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function convertToPdf(inputPath, outDir) {
|
||||
// A throwaway per-job profile avoids the shared-lock problems LibreOffice
|
||||
// hits when several conversions run at once.
|
||||
const profileDir = path.join(outDir, '.lo-profile');
|
||||
await run(SOFFICE_BIN, [
|
||||
'--headless', '--norestore', '--nologo', '--nolockcheck', '--nodefault',
|
||||
`-env:UserInstallation=file://${profileDir}`,
|
||||
'--convert-to', 'pdf',
|
||||
'--outdir', outDir,
|
||||
inputPath
|
||||
]);
|
||||
const base = path.basename(inputPath, path.extname(inputPath));
|
||||
const produced = path.join(outDir, `${base}.pdf`);
|
||||
await fs.access(produced); // throws if LibreOffice silently failed
|
||||
await fs.rm(profileDir, { recursive: true, force: true });
|
||||
return produced;
|
||||
}
|
||||
|
||||
async function toMp4(inputPath, outDir) {
|
||||
const out = path.join(outDir, 'video.mp4');
|
||||
try {
|
||||
// Fast path: just repackage the existing streams into an mp4 container.
|
||||
await run(FFMPEG_BIN, ['-y', '-i', inputPath, '-c', 'copy', '-movflags', '+faststart', out]);
|
||||
return out;
|
||||
} catch {
|
||||
// Fall back to a real transcode for exotic codecs.
|
||||
await run(FFMPEG_BIN, [
|
||||
'-y', '-i', inputPath,
|
||||
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac', '-b:a', '160k',
|
||||
'-movflags', '+faststart',
|
||||
out
|
||||
]);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn an uploaded file into something a browser can display.
|
||||
* Returns { kind: 'pdf'|'video'|'image', viewerFile }.
|
||||
*/
|
||||
export async function processUpload({ inputPath, originalName, outDir }) {
|
||||
const ext = path.extname(originalName).toLowerCase().replace('.', '');
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
|
||||
if (ext === 'pdf') {
|
||||
const dest = path.join(outDir, 'document.pdf');
|
||||
await fs.copyFile(inputPath, dest);
|
||||
return { kind: 'pdf', viewerFile: 'document.pdf' };
|
||||
}
|
||||
|
||||
if (DOC_EXT.has(ext)) {
|
||||
const pdf = await convertToPdf(inputPath, outDir);
|
||||
const dest = path.join(outDir, 'document.pdf');
|
||||
if (pdf !== dest) await fs.rename(pdf, dest);
|
||||
return { kind: 'pdf', viewerFile: 'document.pdf' };
|
||||
}
|
||||
|
||||
if (VIDEO_EXT.has(ext)) {
|
||||
if (WEB_VIDEO.has(ext)) {
|
||||
const outExt = ext === 'ogv' ? 'ogg' : ext;
|
||||
const dest = path.join(outDir, `video.${outExt}`);
|
||||
await fs.copyFile(inputPath, dest);
|
||||
return { kind: 'video', viewerFile: path.basename(dest) };
|
||||
}
|
||||
const mp4 = await toMp4(inputPath, outDir);
|
||||
return { kind: 'video', viewerFile: path.basename(mp4) };
|
||||
}
|
||||
|
||||
if (IMAGE_EXT.has(ext)) {
|
||||
const dest = path.join(outDir, `image.${ext}`);
|
||||
await fs.copyFile(inputPath, dest);
|
||||
return { kind: 'image', viewerFile: path.basename(dest) };
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported file type: .${ext}. Upload a video, PDF, image, or an Office/OpenDocument file.`);
|
||||
}
|
||||
|
||||
export const SUPPORTED_HINT = 'PowerPoint, Word, Excel, PDF, images, and common video formats';
|
||||
+478
@@ -0,0 +1,478 @@
|
||||
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 = `
|
||||
<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('');
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { LOG_FILE } from './config.js';
|
||||
|
||||
// Simple JSON-lines log stored on the data volume. Newest entries are returned
|
||||
// first for the management page.
|
||||
export async function logEvent({ level = 'info', actor = 'system', action = 'event', message = '', meta } = {}) {
|
||||
const entry = { ts: Date.now(), level, actor, action, message, ...(meta ? { meta } : {}) };
|
||||
try {
|
||||
await fs.mkdir(path.dirname(LOG_FILE), { recursive: true });
|
||||
await fs.appendFile(LOG_FILE, JSON.stringify(entry) + '\n');
|
||||
} catch {
|
||||
// Logging must never take the app down.
|
||||
}
|
||||
const tag = level === 'error' ? 'ERROR' : level === 'warn' ? 'warn' : 'log';
|
||||
console.log(`[${tag}] ${action}: ${message}`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function readLogs(limit = 300) {
|
||||
try {
|
||||
const raw = await fs.readFile(LOG_FILE, 'utf8');
|
||||
const lines = raw.split('\n').filter(Boolean);
|
||||
return lines.slice(-limit).reverse().map((l) => {
|
||||
try { return JSON.parse(l); } catch { return { ts: 0, level: 'info', message: l }; }
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearLogs() {
|
||||
try { await fs.writeFile(LOG_FILE, ''); } catch {}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { CONFIG_FILE } from './config.js';
|
||||
import { readJson, writeJson } from './store.js';
|
||||
import { logEvent } from './logger.js';
|
||||
|
||||
// Prefilled for PurelyMail. The admin only needs to add the account address and
|
||||
// password in User management → Mail.
|
||||
const DEFAULTS = {
|
||||
enabled: false,
|
||||
fromName: 'QR Presentation',
|
||||
username: '', // full PurelyMail email address, used as SMTP/IMAP login
|
||||
password: '',
|
||||
smtpHost: 'smtp.purelymail.com',
|
||||
smtpPort: 465,
|
||||
smtpSecure: true, // SSL/TLS on 465
|
||||
imapHost: 'imap.purelymail.com',
|
||||
imapPort: 993,
|
||||
imapSecure: true, // SSL/TLS on 993
|
||||
saveToSent: true,
|
||||
sentFolder: 'Sent'
|
||||
};
|
||||
|
||||
let cfg = { ...DEFAULTS };
|
||||
|
||||
export async function initMail() {
|
||||
const saved = await readJson(CONFIG_FILE, null);
|
||||
cfg = { ...DEFAULTS, ...(saved && saved.mail ? saved.mail : {}) };
|
||||
}
|
||||
|
||||
export function isAvailable() {
|
||||
return Boolean(cfg.enabled && cfg.username && cfg.password);
|
||||
}
|
||||
|
||||
// Never leak the stored password.
|
||||
export function getMailConfig() {
|
||||
const { password, ...safe } = cfg;
|
||||
return { ...safe, hasPassword: Boolean(password), available: isAvailable() };
|
||||
}
|
||||
|
||||
export async function saveMailConfig(patch = {}) {
|
||||
const next = { ...cfg, ...patch };
|
||||
// Blank password field means "keep what's stored".
|
||||
if (patch.password === undefined || patch.password === '') next.password = cfg.password;
|
||||
next.smtpPort = Number(next.smtpPort) || DEFAULTS.smtpPort;
|
||||
next.imapPort = Number(next.imapPort) || DEFAULTS.imapPort;
|
||||
next.enabled = Boolean(next.enabled);
|
||||
next.smtpSecure = Boolean(next.smtpSecure);
|
||||
next.imapSecure = Boolean(next.imapSecure);
|
||||
next.saveToSent = Boolean(next.saveToSent);
|
||||
cfg = next;
|
||||
await writeJson(CONFIG_FILE, { mail: cfg });
|
||||
return getMailConfig();
|
||||
}
|
||||
|
||||
function requireConfigured() {
|
||||
if (!cfg.username || !cfg.password) {
|
||||
throw new Error('Mail account is not set up. Add it under User management → Mail.');
|
||||
}
|
||||
}
|
||||
|
||||
// Compile a MIME message to a Buffer using nodemailer's stream transport, so we
|
||||
// can both send it over SMTP and APPEND the exact same bytes to the Sent folder.
|
||||
async function buildRaw({ to, subject, text, html, attachments }) {
|
||||
const composer = nodemailer.createTransport({ streamTransport: true, buffer: true, newline: 'crlf' });
|
||||
const built = await composer.sendMail({
|
||||
from: `"${cfg.fromName}" <${cfg.username}>`,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
attachments
|
||||
});
|
||||
return built.message; // Buffer
|
||||
}
|
||||
|
||||
export async function sendMail({ to, subject, text, html, attachments, actor = 'system' }) {
|
||||
requireConfigured();
|
||||
const recipients = (Array.isArray(to) ? to : [to]).map((s) => String(s).trim()).filter(Boolean);
|
||||
if (!recipients.length) throw new Error('No recipient address provided.');
|
||||
|
||||
const raw = await buildRaw({ to: recipients.join(', '), subject, text, html, attachments });
|
||||
|
||||
const smtp = nodemailer.createTransport({
|
||||
host: cfg.smtpHost,
|
||||
port: cfg.smtpPort,
|
||||
secure: cfg.smtpSecure,
|
||||
auth: { user: cfg.username, pass: cfg.password }
|
||||
});
|
||||
|
||||
try {
|
||||
await smtp.sendMail({ envelope: { from: cfg.username, to: recipients }, raw });
|
||||
} catch (err) {
|
||||
await logEvent({ level: 'error', actor, action: 'mail_send',
|
||||
message: `Failed sending to ${recipients.join(', ')}: ${err.message}` });
|
||||
throw new Error(`Could not send the email: ${err.message}`);
|
||||
}
|
||||
|
||||
// Keep a copy in the account's Sent folder via IMAP APPEND.
|
||||
if (cfg.saveToSent) {
|
||||
try {
|
||||
const client = new ImapFlow({
|
||||
host: cfg.imapHost, port: cfg.imapPort, secure: cfg.imapSecure,
|
||||
auth: { user: cfg.username, pass: cfg.password }, logger: false
|
||||
});
|
||||
await client.connect();
|
||||
await client.append(cfg.sentFolder || 'Sent', raw, ['\\Seen']);
|
||||
await client.logout();
|
||||
} catch (err) {
|
||||
await logEvent({ level: 'warn', actor, action: 'mail_sent_copy',
|
||||
message: `Sent to ${recipients.join(', ')} but could not save to "${cfg.sentFolder}": ${err.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
await logEvent({ actor, action: 'mail_send',
|
||||
message: `Email sent to ${recipients.join(', ')} — "${subject}"` });
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { SESSION_DIR } from './config.js';
|
||||
|
||||
// id -> meta. Backed by data/sessions/<id>/meta.json so restarts keep decks.
|
||||
const sessions = new Map();
|
||||
|
||||
export async function initSessions() {
|
||||
await fs.mkdir(SESSION_DIR, { recursive: true });
|
||||
const entries = await fs.readdir(SESSION_DIR, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(SESSION_DIR, entry.name, 'meta.json'), 'utf8');
|
||||
const meta = JSON.parse(raw);
|
||||
// A deck still "processing" when the server died can never finish; mark it.
|
||||
if (meta.status === 'processing') {
|
||||
meta.status = 'error';
|
||||
meta.error = 'Processing was interrupted by a restart. Please re-upload.';
|
||||
}
|
||||
sessions.set(meta.id, meta);
|
||||
} catch {
|
||||
// Skip unreadable session folders rather than crash on boot.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getSession(id) {
|
||||
return sessions.get(id);
|
||||
}
|
||||
|
||||
export function listSessions() {
|
||||
return [...sessions.values()].sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
export function sessionDir(id) {
|
||||
return path.join(SESSION_DIR, id);
|
||||
}
|
||||
|
||||
export function outDir(id) {
|
||||
return path.join(SESSION_DIR, id, 'out');
|
||||
}
|
||||
|
||||
export async function saveSession(meta) {
|
||||
sessions.set(meta.id, meta);
|
||||
const dir = sessionDir(meta.id);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta, null, 2));
|
||||
return meta;
|
||||
}
|
||||
|
||||
export async function deleteSession(id) {
|
||||
sessions.delete(id);
|
||||
await fs.rm(sessionDir(id), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Fields safe to expose to an audience member (no presenter token).
|
||||
export function publicMeta(meta) {
|
||||
if (!meta) return null;
|
||||
return {
|
||||
id: meta.id,
|
||||
title: meta.title,
|
||||
kind: meta.kind,
|
||||
status: meta.status,
|
||||
error: meta.error || null,
|
||||
viewerFile: meta.viewerFile || null,
|
||||
createdAt: meta.createdAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export async function readJson(file, fallback = null) {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(file, 'utf8'));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJson(file, data) {
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
const tmp = `${file}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(data, null, 2));
|
||||
await fs.rename(tmp, file); // atomic replace
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { USERS_FILE, SEED_ADMIN_USER, SEED_ADMIN_PASSWORD, SEED_ADMIN_EMAIL } from './config.js';
|
||||
import { readJson, writeJson } from './store.js';
|
||||
import { logEvent } from './logger.js';
|
||||
|
||||
let users = []; // { id, username, email, role, salt, hash, createdAt }
|
||||
const authSessions = new Map(); // token -> { userId, expires }
|
||||
const WEEK = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function hashPassword(password, salt) {
|
||||
return crypto.scryptSync(String(password), salt, 64).toString('hex');
|
||||
}
|
||||
|
||||
function buildUser(username, email, password, role) {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
return {
|
||||
id: nanoid(10),
|
||||
username: username.trim(),
|
||||
email: (email || '').trim(),
|
||||
role: role === 'admin' ? 'admin' : 'user',
|
||||
salt,
|
||||
hash: hashPassword(password, salt),
|
||||
createdAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
export async function initUsers() {
|
||||
const saved = await readJson(USERS_FILE, null);
|
||||
users = Array.isArray(saved) ? saved : [];
|
||||
// No auto-seed: on first run the operator creates the admin in the browser.
|
||||
// For unattended deploys, ADMIN_USERNAME + ADMIN_PASSWORD may pre-seed one.
|
||||
if (users.length === 0 && process.env.ADMIN_PASSWORD) {
|
||||
users = [buildUser(SEED_ADMIN_USER, SEED_ADMIN_EMAIL, SEED_ADMIN_PASSWORD, 'admin')];
|
||||
await persist();
|
||||
await logEvent({ action: 'seed', message: `Pre-seeded admin "${SEED_ADMIN_USER}" from environment` });
|
||||
}
|
||||
}
|
||||
|
||||
export function hasUsers() {
|
||||
return users.length > 0;
|
||||
}
|
||||
|
||||
// Create the very first admin account (first-run setup). Refuses once any user exists.
|
||||
export async function createFirstAdmin({ username, email, password }) {
|
||||
if (users.length > 0) throw new Error('Setup has already been completed.');
|
||||
if (!username || !password) throw new Error('Username and password are required.');
|
||||
const u = buildUser(username, email, password, 'admin');
|
||||
users.push(u);
|
||||
await persist();
|
||||
return publicUser(u);
|
||||
}
|
||||
|
||||
async function persist() {
|
||||
await writeJson(USERS_FILE, users);
|
||||
}
|
||||
|
||||
export function publicUser(u) {
|
||||
return u ? { id: u.id, username: u.username, email: u.email, role: u.role, createdAt: u.createdAt } : null;
|
||||
}
|
||||
export function listUsers() { return users.map(publicUser); }
|
||||
export function getUserById(id) { return users.find((u) => u.id === id); }
|
||||
export function getUserByName(name) {
|
||||
const n = String(name || '').toLowerCase();
|
||||
return users.find((u) => u.username.toLowerCase() === n);
|
||||
}
|
||||
export function adminCount() { return users.filter((u) => u.role === 'admin').length; }
|
||||
|
||||
export async function createUser({ username, email, password, role }) {
|
||||
if (!username || !password) throw new Error('Username and password are required.');
|
||||
if (getUserByName(username)) throw new Error('That username is already taken.');
|
||||
const u = buildUser(username, email, password, role);
|
||||
users.push(u);
|
||||
await persist();
|
||||
return publicUser(u);
|
||||
}
|
||||
|
||||
export async function deleteUser(id, actingUserId) {
|
||||
const u = getUserById(id);
|
||||
if (!u) throw new Error('User not found.');
|
||||
if (id === actingUserId) throw new Error('You cannot delete your own account.');
|
||||
if (u.role === 'admin' && adminCount() <= 1) throw new Error('Cannot delete the last remaining admin.');
|
||||
users = users.filter((x) => x.id !== id);
|
||||
await persist();
|
||||
for (const [token, s] of authSessions) if (s.userId === id) authSessions.delete(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function setPassword(id, password) {
|
||||
const u = getUserById(id);
|
||||
if (!u) throw new Error('User not found.');
|
||||
if (!password) throw new Error('Password cannot be empty.');
|
||||
u.salt = crypto.randomBytes(16).toString('hex');
|
||||
u.hash = hashPassword(password, u.salt);
|
||||
await persist();
|
||||
}
|
||||
|
||||
export async function setRole(id, role, actingUserId) {
|
||||
const u = getUserById(id);
|
||||
if (!u) throw new Error('User not found.');
|
||||
const next = role === 'admin' ? 'admin' : 'user';
|
||||
if (u.role === 'admin' && next === 'user' && adminCount() <= 1) {
|
||||
throw new Error('Cannot demote the last remaining admin.');
|
||||
}
|
||||
if (id === actingUserId && next === 'user') {
|
||||
throw new Error('You cannot remove your own admin rights.');
|
||||
}
|
||||
u.role = next;
|
||||
await persist();
|
||||
}
|
||||
|
||||
export function verifyCredentials(username, password) {
|
||||
const u = getUserByName(username);
|
||||
if (!u) return null;
|
||||
const attempt = Buffer.from(hashPassword(password, u.salt));
|
||||
const known = Buffer.from(u.hash);
|
||||
if (attempt.length !== known.length || !crypto.timingSafeEqual(attempt, known)) return null;
|
||||
return u;
|
||||
}
|
||||
|
||||
// --- auth sessions (cookie tokens, in-memory) ------------------------------
|
||||
export function createAuthSession(userId) {
|
||||
const token = nanoid(32);
|
||||
authSessions.set(token, { userId, expires: Date.now() + WEEK });
|
||||
return token;
|
||||
}
|
||||
export function getAuthUser(token) {
|
||||
const s = token && authSessions.get(token);
|
||||
if (!s) return null;
|
||||
if (s.expires < Date.now()) { authSessions.delete(token); return null; }
|
||||
return getUserById(s.userId) || null;
|
||||
}
|
||||
export function destroyAuthSession(token) {
|
||||
if (token) authSessions.delete(token);
|
||||
}
|
||||
Reference in New Issue
Block a user