diff --git a/src/logger.ts b/src/logger.ts index 3de53fc..e4b672f 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -7,27 +7,42 @@ const logPath = './logs/'; const tsFormat = () => (new Date().toISOString()); const logFormat = format.combine(format.timestamp(), format.json()); -function loggerTransports(logName: string, logLevel: string) { - return [ - new transports.File({ +function loggerTransports(logLevel: string, logName = "") { + let transport:any; + if ("" === logName) { + transport = new transports.Console({ + format: format.simple(), + level: logLevel, + timestamp: tsFormat + }); + } else { + transport = new transports.File({ filename: path.join(logPath, `${logName}.log`), timestamp: tsFormat, level: logLevel - }) - ]; + }); + } + + return [ transport ]; } +const debugLog = createLogger({ + format: format.simple(), + transports: loggerTransports('debug') +}); + const errorLog = createLogger({ format: logFormat, - transports: loggerTransports('errors', 'debug') + transports: loggerTransports('debug', 'errors') }); const accessLog = createLogger({ format: logFormat, - transports: loggerTransports('access', 'info') + transports: loggerTransports('info', 'access') }); module.exports = { errorLog, - accessLog + accessLog, + debugLog }; diff --git a/src/server.ts b/src/server.ts index 43923b1..0622a43 100755 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,7 @@ const logger = require('./logger'); import ClientManager from './clientManager'; import ChannelManager from './channelManager'; +import XhrListener from './xhrListener'; import PublicClient from './clients/types/publicClient'; import PrivateClient from './clients/types/privateClient'; import CustomClient from './clients/types/customClient'; @@ -16,6 +17,7 @@ import CustomClient from './clients/types/customClient'; const wss = new WebSocket.Server({ maxPayload: 250000, port: app.port }); const clientManager = new ClientManager(); const channelManager = new ChannelManager(); +const xhrListener = new XhrListener(channelManager); function connectionManager() { wss.on('connection', (ws: WebSocket, request: any, args: string) => { @@ -63,7 +65,7 @@ function connectionManager() { channelManager.purgeEmptyChannels(); - ws.send(`Hi there, welcome to braid, Measures Web Socket server. Connecting all our services!\nYou are currently in channel: ${data.channel}`); + ws.send(`Hi there! welcome to braid, Measures Web Socket server. Connecting all our services!\nYou are currently in channel: ${data.channel}`); } }); } @@ -88,5 +90,6 @@ startServer(); module.exports = { clientManager, channelManager, - connectionManager + connectionManager, + xhrListener }; diff --git a/src/xhrListener.ts b/src/xhrListener.ts new file mode 100644 index 0000000..0210060 --- /dev/null +++ b/src/xhrListener.ts @@ -0,0 +1,120 @@ +import ChannelManager from './channelManager' + +const logger = require('./logger'); +const url = require('url'); +const http = require('http'); +const crypto = require('crypto'); +const AUTH_TOKEN = process.env.ARCHIMEDES_INTEGRATION_TOKEN || 'sha1=baddbeef' +const HTTP_PORT = process.env.HTTP_PORT || 80 + +import { Server, IncomingMessage, ServerResponse } from 'http' + +interface ArchimedesMessage { + examId: 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, channel: string):boolean { + logger.debugLog.info(`XhrListener:relayEvent(event: ${event}, channel: ${channel})`); + for (let c of this.#cm.channels) { + if (channel === 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}})); + 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"}' + 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.examId)) { + 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.examId)) { + 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;