// GET /api/notes/iserv/tasks and GET /api/notes/iserv/file?path=: the notes app's own view of IServ (tasks with // status, description and provided files). IServ credentials come from the Space secrets (ISERV_BASE_URL, // ISERV_USERNAME, ISERV_PASSWORD), never from the request. Unlike the other /api/notes routes these FAIL CLOSED: // without a NOTES_ACCESS_TOKEN of at least 16 characters nothing is served. Errors carry a code only (the messages // of Puppeteer and IServ quote the school domain and task text). Nothing is logged. import { timingSafeEqual } from 'node:crypto'; import { downloadIServFile, fetchIServOverview } from './iserv.js'; const CACHE_MS = 3 * 60_000; // several opens of the app must not log in to IServ each time /** A path on the school server: /iserv/…, no scheme or host, no dot segments, no double slash. */ export function isSafeIServPath(path) { return typeof path === 'string' && path.startsWith('/iserv/') && path.length <= 2000 && !/[\\\s]|\.\.|\/\//.test(path); } function keyOk(req) { const expected = Buffer.from(process.env.NOTES_ACCESS_TOKEN || ''); const offered = [req.headers['x-app-key'], (req.headers.authorization || '').replace(/^Bearer\s+/i, '')]; return offered.some((o) => o && Buffer.byteLength(o) === expected.length && timingSafeEqual(Buffer.from(o), expected)); } /** true if the request may continue; otherwise the answer is already sent. */ function allowed(req, res) { res.set('Cache-Control', 'no-store'); if ((process.env.NOTES_ACCESS_TOKEN || '').length < 16) return void res.status(503).json({ error: 'config' }); if (!keyOk(req)) return void res.status(401).json({ error: 'unauthorized' }); const { ISERV_BASE_URL: url, ISERV_USERNAME: user, ISERV_PASSWORD: pass } = process.env; if (!url || !user || !pass || !url.startsWith('https://')) return void res.status(503).json({ error: 'config' }); return true; } export function registerIServNotes(app, { overview = fetchIServOverview, file = downloadIServFile, now = Date.now } = {}) { let running = Promise.resolve(); // one IServ session at a time let cached = null; // { at, tasks }, memory only const credentials = () => [process.env.ISERV_BASE_URL, process.env.ISERV_USERNAME, process.env.ISERV_PASSWORD]; const queued = (run) => { const job = running.then(run, run); running = job.catch(() => {}); return job; }; app.get('/api/notes/iserv/tasks', async (req, res) => { if (!allowed(req, res)) return; try { const { at, tasks } = await queued(async () => { if (cached && now() - cached.at < CACHE_MS) return cached; cached = { at: now(), tasks: await overview(...credentials()) }; return cached; }); res.json({ tasks, fetchedAt: new Date(at).toISOString() }); } catch { res.status(502).json({ error: 'unreachable' }); } }); app.get('/api/notes/iserv/file', async (req, res) => { if (!allowed(req, res)) return; const path = req.query.path; if (!isSafeIServPath(path)) return res.status(400).json({ error: 'bad_path' }); try { const { contentType, buffer } = await queued(() => file(...credentials(), path)); res.set('Content-Type', contentType).send(buffer); } catch (e) { const code = e?.code === 'too_large' || e?.code === 'refused' ? e.code : 'unreachable'; res.status(code === 'too_large' ? 413 : 502).json({ error: code }); } }); }