import ChannelManager from './channelManager' const logger = require('./logger'); const url = require('url'); const http = require('http'); const crypto = require('crypto'); const app = require('./config/app'); const AUTH_TOKEN = app.archimedesToken; const HTTP_PORT = app.httpPort; import { Server, IncomingMessage, ServerResponse } from 'http' interface ArchimedesMessage { examId: string, reservation_no: string } class XhrListener { #cm: ChannelManager; constructor(cm: ChannelManager) { // I'm not entirely happy with bind(this) but it does let me break the // callback and event handlers out into class methods. It will be // problematic if the actual `this` needs to be accessed. let xhrlisten = http.createServer(this.onRequest.bind(this)); xhrlisten.listen(HTTP_PORT); this.#cm = cm; logger.debugLog.info("XhrListener running"); } public relayEvent(event: string, archMsg: ArchimedesMessage): boolean { logger.debugLog.info(`XhrListener:relayEvent(event: ${event}, channel: ${archMsg.examId})`); for (let c of this.#cm.channels) { if (archMsg.examId === c.id) { let dmCount: number = 0; for (let client of c.clients) { let nonce: string = crypto.randomBytes(4).toString("hex"); // TODO: verify the nonce against the received reply, which we currently ignore client.directMessage(JSON.stringify({ message_type: "broadcast", message: { event_type: event, seq_id: nonce, reservation_no: archMsg.reservation_no } })); dmCount++; } // dmCount of 1 would be normal. more than 1 is odd, but not necessarily bad. 0 means Exam UI has gone away somehow. return dmCount > 0; } } // did not match a channel; examId isn't valid for some reason return false; } public onRequestEnded(body: Uint8Array[], request: IncomingMessage, response: ServerResponse): void { logger.debugLog.info("XhrListener:onRequestEnded()"); if (request.method === 'OPTIONS') { logger.debugLog.info("handling CORS preflight"); // CORS: implement permissive policy for XHR clients response.setHeader('Access-Control-Allow-Origin', request.headers['origin'] || "*"); response.setHeader('Access-Control-Allow-Methods', request.headers['access-control-request-methods'] || "OPTIONS, GET, POST"); response.setHeader('Access-Control-Allow-Headers', request.headers['access-control-request-headers'] || "Content-Type"); response.setHeader('Access-Control-Allow-Credentials', "true"); response.end(); } else { logger.debugLog.info("parsing Archimedes event: " + body); let endpoint = url.parse(request.url).pathname let returnVal: [number, string] = [-1, ""]; let authorized: boolean = request.headers['x-proctoru-signature'] === AUTH_TOKEN; // body of POST is JSON: '{"examId":"proctor_u_id", "reservation_no":"primary_key_from_archimedes"}' switch (true) { // match /event/pause* case /^\/event\/pause([/]+.*)*$/.test(endpoint): if (authorized) { let amsg: ArchimedesMessage = JSON.parse(Buffer.concat(body).toString()); if (this.relayEvent("pauseExam", amsg)) { returnVal = [200, "pause event was successfully relayed"]; } else { logger.errorLog.info(`XhrListener: could not relay pause event "${amsg}" to Exam UI; body: "${body}"`); returnVal = [400, "error while relaying pause event"]; } } break; // match /event/resume* case /^\/event\/resume([/]+.*)*$/.test(endpoint): if (authorized) { let amsg: ArchimedesMessage = JSON.parse(Buffer.concat(body).toString()); if (this.relayEvent("resumeExam", amsg)) { returnVal = [200, "resume event was successfully relayed"]; } else { logger.errorLog.info(`XhrListener: could not relay resume event "${amsg}" to Exam UI; body: "${body}"`); returnVal = [400, "error while relaying resume event"]; } } break; default: logger.errorLog.info(`XhrListener: bad event or other request; body: "${body}"`); returnVal = [400, "Bad request"]; break; }; if (-1 == returnVal[0]) { response.writeHead(authorized ? 400 : 401); response.end(); } else { response.writeHead(returnVal[0], { 'Content-Type': 'application/json' }); response.end(JSON.stringify(returnVal[1])); } } } private onRequest(request: IncomingMessage, response: ServerResponse): void { logger.debugLog.info("XhrListener:onRequest()"); let body: Uint8Array[] = []; request.on('data', post_block => body.push(post_block)); request.on('end', () => { this.onRequestEnded(body, request, response); }); } } export default XhrListener;