APP-Backend / src /api /iserv-notes.js
Luca448's picture
Claude Sonnet 5.5
feat(iserv): /api/notes/iserv/tasks and /file for the notes app
b8fbd8b
Raw History Blame Contribute Delete
3.43 kB
// 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 });
}
});
}