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 });
    }
  });
}