Spaces:
Paused
Paused
File size: 3,425 Bytes
b8fbd8b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | // 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 });
}
});
}
|