From cc7c0f5e225138cd1ba29e872d4e36fa79a67a59 Mon Sep 17 00:00:00 2001
From: Benjamin Auder <benjamin.auder@somewhere>
Date: Sun, 28 Jan 2018 13:05:01 +0100
Subject: [PATCH] Simplify cheating detection

---
 public/javascripts/assessment.js  | 195 ++++++++++++------------------
 public/stylesheets/assessment.css |   5 -
 routes/pages.js                   |  14 ++-
 views/assessment.pug              |  14 +--
 views/enable-js.pug               |   4 -
 views/enablejs.pug                |   6 +
 views/fullscreen.pug              |   7 ++
 views/no-devtools.pug             |   5 -
 views/noblur.pug                  |   7 ++
 9 files changed, 113 insertions(+), 144 deletions(-)
 delete mode 100644 views/enable-js.pug
 create mode 100644 views/enablejs.pug
 create mode 100644 views/fullscreen.pug
 delete mode 100644 views/no-devtools.pug
 create mode 100644 views/noblur.pug

diff --git a/public/javascripts/assessment.js b/public/javascripts/assessment.js
index 05b4d58..c574f1f 100644
--- a/public/javascripts/assessment.js
+++ b/public/javascripts/assessment.js
@@ -1,35 +1,20 @@
+// TODO: if display == "all", les envois devraient être non définitifs (possibilité de corriger)
+// Et, blur sur une (sous-)question devrait envoyer la version courante de la sous-question
+
 let socket = null; //monitor answers in real time
 
-function checkWindowSize()
-{
-	if (assessment.mode == "secure")
-	{
-		// NOTE: temporarily accept smartphone (security hole: pretend being a smartphone on desktop browser...)
-		if (navigator.userAgent.match(/(iPhone|iPod|iPad|Android|BlackBerry)/))
-			return true;
-		let test = () => {
-			return window.innerWidth < screen.width || window.innerHeight < screen.height;
-		};
-		const returnVal = test;
-		while (!test)
-			alert("Please enter fullscreen mode (F11)");
-		return returnVal;
-	}
-	return true;
-};
+if (assessment.mode == "secure" && !checkWindowSize())
+	document.location.href= "/fullscreen";
 
-function libsRefresh()
+function checkWindowSize()
 {
-	$("#statements").find("code[class^=language-]").each( (i,elem) => {
-		Prism.highlightElement(elem);
-	});
-	MathJax.Hub.Queue(["Typeset",MathJax.Hub,"statements"]);
+	// NOTE: temporarily accept smartphone (security hole: pretend being a smartphone on desktop browser...)
+	if (navigator.userAgent.match(/(iPhone|iPod|iPad|Android|BlackBerry)/))
+		return true;
+	return window.innerWidth == screen.width && window.innerHeight == screen.height;
 };
 
-// TODO: if display == "all", les envois devraient être non définitifs (possibilité de corriger)
-// Et, blur sur une (sous-)question devrait envoyer la version courante de la sous-question
-
-let V = new Vue({
+new Vue({
 	el: "#assessment",
 	data: {
 		assessment: assessment,
@@ -51,28 +36,6 @@ let V = new Vue({
 					index: 0, //current question index in assessment.indices
 				};
 			},
-			mounted: function() {
-				if (assessment.mode != "secure")
-					return;
-				$("#warning").modal({
-					complete: () => {
-						this.stage = 2;
-						this.resumeAssessment();
-					},
-				});
-				window.addEventListener("blur", () => {
-					if (this.stage == 2)
-						this.showWarning();
-				}, false);
-				window.addEventListener("resize", e => {
-					if (this.stage == 2 && !checkWindowSize())
-						this.showWarning();
-				}, false);
-				//socket.on("disconnect", () => { }); //TODO: notify monitor (highlight red)
-			},
-			updated: function() {
-				libsRefresh();
-			},
 			// TODO: general render function for nested exercises
 			// TODO: with answer if stage==4 : class "wrong" if ticked AND stage==4 AND received answers
 			// class "right" if stage == 4 AND received answers (background-color: red / green)
@@ -87,7 +50,7 @@ let V = new Vue({
 							"div",
 							{
 								"class": {
-									"wording": true,
+									wording: true,
 								},
 								domProps: {
 									innerHTML: q.wording,
@@ -179,6 +142,11 @@ let V = new Vue({
 									"waves-light": true,
 									"btn": true,
 								},
+								style: {
+									"display": "block",
+									"margin-left": "auto",
+									"margin-right": "auto",
+								},
 								on: {
 									click: () => this.sendAnswer(assessment.indices[this.index]),
 								},
@@ -197,15 +165,35 @@ let V = new Vue({
 					questions
 				);
 			},
+			mounted: function() {
+				if (assessment.mode != "secure")
+					return;
+				window.addEventListener("keydown", e => {
+					// (Try to) Ignore F11 + F12 (avoid accidental window resize)
+					// NOTE: in Chromium at least, exiting fullscreen mode with F11 cannot be prevented.
+					// Workaround: disable key at higher level. Possible xbindkey config:
+					// "false"
+					//   m:0x10 + c:95
+					//   Mod2 + F11
+					if ([122,123].includes(e.keyCode))
+						e.preventDefault();
+				}, false);
+				window.addEventListener("blur", () => {
+					this.trySendCurrentAnswer();
+					document.location.href= "/noblur";
+				}, false);
+				window.addEventListener("resize", e => {
+					this.trySendCurrentAnswer();
+					document.location.href= "/fullscreen";
+				}, false);
+			},
 			methods: {
-				// HELPERS:
 				inputId: function(i,j) {
 					return "q" + i + "_" + "input" + j;
 				},
-				showWarning: function(action) {
-					this.sendAnswer(assessment.indices[this.index]);
-					this.stage = 32; //fictive stage to hide all elements
-					$("#warning").modal('open');
+				trySendCurrentAnswer: function() {
+					if (this.stage == 2)
+						this.sendAnswer(assessment.indices[this.index]);
 				},
 				// stage 2
 				sendAnswer: function(realIndex) {
@@ -238,42 +226,9 @@ let V = new Vue({
 						},
 					});
 				},
-				// stage 2 after blur or resize
-				resumeAssessment: function() {
-					checkWindowSize();
-				},
 			},
 		},
 	},
-	mounted: function() {
-		if (assessment.mode == "open")
-			return; //no security needed in open mode
-		window.addEventListener("keydown", e => {
-			// If F12 or ctrl+shift (ways to access devtools)
-			if (e.keyCode == 123 || (e.ctrlKey && e.shiftKey))
-				e.preventDefault();
-		}, false);
-		// Devtools detect based on https://jsfiddle.net/ebhjxfwv/4/
-		let div = document.createElement('div');
-		let devtoolsLoop = setInterval(
-			() => {
-				if (assessment.mode != "open")
-				{
-					console.log(div);
-					console.clear();
-				}
-			},
-			1000
-		);
-		Object.defineProperty(div, "id", {
-			get: () => {
-				clearInterval(devtoolsLoop);
-				if (this.stage == 2)
-					this.endAssessment();
-				document.location.href = "/nodevtools";
-			}
-		});
-	},
 	computed: {
 		countdown: function() {
 			let seconds = this.remainingTime % 60;
@@ -282,7 +237,6 @@ let V = new Vue({
 		},
 	},
 	methods: {
-		// HELPERS:
 		padWithZero: function(x) {
 			if (x < 10)
 				return "0" + x;
@@ -314,7 +268,6 @@ let V = new Vue({
 		},
 		// stage 1 --> 2 (get all questions, set password)
 		startAssessment: function() {
-			checkWindowSize();
 			let initializeStage2 = questions => {
 				$("#leftButton, #rightButton").hide();
 				if (assessment.time > 0)
@@ -331,10 +284,18 @@ let V = new Vue({
 					? _.range(assessment.questions.length)
 					: _.shuffle( _.range(assessment.questions.length) );
 				this.stage = 2;
-				Vue.nextTick( () => { libsRefresh(); });
+				Vue.nextTick( () => {
+					// Run Prism + MathJax on questions text
+					$("#statements").find("code[class^=language-]").each( (i,elem) => {
+						Prism.highlightElement(elem);
+					});
+					MathJax.Hub.Queue(["Typeset",MathJax.Hub,"statements"]);
+				});
 			};
 			if (assessment.mode == "open")
 				return initializeStage2();
+			// TODO: if existing password cookie: get stored answers (papers[number cookie]), inject (inputs), set index+indices
+			//       (instead of following ajax call)
 			$.ajax("/start/assessment", {
 				method: "GET",
 				data: {
@@ -348,11 +309,13 @@ let V = new Vue({
 					this.student.password = s.password;
 					// Got password: students answers locked to this page until potential teacher
 					// action (power failure, computer down, ...)
+					// TODO: set password cookie
 					// TODO: password also exchanged by sockets to check identity
 					//socket = io.connect("/" + assessment.name, {
 					//	query: "number=" + this.student.number + "&password=" + this.password
 					//});
 					//socket.on(message.allAnswers, this.setAnswers);
+					//socket.on("disconnect", () => { }); //TODO: notify monitor (highlight red), redirect
 					initializeStage2(s.questions);
 				},
 			});
@@ -369,40 +332,34 @@ let V = new Vue({
 					clearInterval(this);
 			}, 1000);
 		},
-		// stage 2 after disconnect (socket)
-		resumeAssessment: function() {
-			// UNIMPLEMENTED
-			// TODO: get stored answers (papers[number cookie]), inject (inputs), set index+indices
-		},
 		// stage 2 --> 3 (or 4)
-		// from a message by statements component
+		// from a message by statements component, or time over
 		endAssessment: function() {
-			// If time over or cheating: set endTime, destroy password
+			// Set endTime, destroy password
 			$("#leftButton, #rightButton").show();
-			//this.sendAnswer(...); //TODO: for each non-answered (and non-empty!) index (yet)
-			if (assessment.mode != "open")
+			if (assessment.mode == "open")
 			{
-				$.ajax("/end/assessment", {
-					method: "GET",
-					data: {
-						aid: assessment._id,
-						number: this.student.number,
-						password: this.student.password,
-					},
-					dataType: "json",
-					success: ret => {
-						if (!!ret.errmsg)
-							return alert(ret.errmsg);
-						assessment.conclusion = ret.conclusion;
-						this.stage = 3;
-						delete this.student["password"]; //unable to send new answers now
-						//socket.disconnect();
-						//socket = null;
-					},
-				});
-			}
-			else
 				this.stage = 4;
+				return;
+			}
+			$.ajax("/end/assessment", {
+				method: "GET",
+				data: {
+					aid: assessment._id,
+					number: this.student.number,
+					password: this.student.password,
+				},
+				dataType: "json",
+				success: ret => {
+					if (!!ret.errmsg)
+						return alert(ret.errmsg);
+					assessment.conclusion = ret.conclusion;
+					this.stage = 3;
+					delete this.student["password"]; //unable to send new answers now
+					//socket.disconnect();
+					//socket = null;
+				},
+			});
 		},
 		// stage 3 --> 4 (on socket message "feedback")
 		setAnswers: function(answers) {
diff --git a/public/stylesheets/assessment.css b/public/stylesheets/assessment.css
index 1511857..4805a53 100644
--- a/public/stylesheets/assessment.css
+++ b/public/stylesheets/assessment.css
@@ -9,11 +9,6 @@ a#rightButton {
 	padding: 15px 0;
 }
 
-#assessment button {
-	display: block;
-	margin: 0 auto 15px auto;
-}
-
 .question label {
 	color: black;
 }
diff --git a/routes/pages.js b/routes/pages.js
index 37b84cf..15cf1fd 100644
--- a/routes/pages.js
+++ b/routes/pages.js
@@ -28,14 +28,20 @@ router.get("/login", access.unlogged, (req,res) => {
 
 // Redirection screens when possible cheating attempt detected in exam
 router.get("/enablejs", (req,res) => {
-	res.render("enable-js", {
+	res.render("enablejs", {
 		title: "JS disabled",
 	});
 });
 
-router.get("/nodevtools", (req,res) => {
-	res.render("no-devtools", {
-		title: "Devtools enabled",
+router.get("/fullscreen", (req,res) => {
+	res.render("fullscreen", {
+		title: "Not in fullscreen",
+	});
+});
+
+router.get("/noblur", (req,res) => {
+	res.render("noblur", {
+		title: "Lost focus",
 	});
 });
 
diff --git a/views/assessment.pug b/views/assessment.pug
index 9caa03d..b4ba8c8 100644
--- a/views/assessment.pug
+++ b/views/assessment.pug
@@ -25,13 +25,13 @@ block content
 		.row
 			.col.s12.m10.offset-m1.l8.offset-l2.xl6.offset-xl3
 				h4= assessment.name
-				#stage0(v-if="stage==0")
+				#stage0(v-show="stage==0")
 					.card
 						.input-field.inline.on-left
 							label(for="number") Number
 							input#number(type="text" v-model="student.number" @keyup.enter="getStudent()")
 						button.waves-effect.waves-light.btn(@click="getStudent()") Send
-				#stage1(v-if="stage==1")
+				#stage1(v-show="stage==1")
 					.card
 						if assessment.mode != "open"
 							.input-field.inline.on-left
@@ -40,23 +40,23 @@ block content
 							.input-field.inline
 								label(for="name") Name
 								input#name(type="text" v-model="student.name" disabled)
-						p
+						p.center-align
 							if assessment.mode != "open"
 								button.waves-effect.waves-light.btn.on-left(@click="cancelStudent") Cancel
 							button.waves-effect.waves-light.btn(@click="startAssessment") Start!
-				#stage1_2_4(v-if="stage==1 || stage==2 || stage == 4")
+				#stage1_2_4(v-show="stage==1 || stage==2 || stage == 4")
 					.card
 						.introduction(v-html="assessment.introduction")
-				#stage2_4(v-if="stage==2 || stage==4")
+				#stage2_4(v-show="stage==2 || stage==4")
 					if assessment.time > 0
 						.card
 							.timer.center(v-if="stage==2") {{ countdown }}
 					.card
 						statements(:assessment="assessment" :student="student" :stage="stage" :inputs="inputs" @gameover="endAssessment")
-				#stage3(v-if="stage==3")
+				#stage3(v-show="stage==3")
 					.card
 						.finish Exam completed &#9786; ...don't close the window!
-				#stage3_4(v-if="stage==3 || stage==4")
+				#stage3_4(v-show="stage==3 || stage==4")
 					.card
 						.conclusion(v-html="assessment.conclusion")
 
diff --git a/views/enable-js.pug b/views/enable-js.pug
deleted file mode 100644
index f05a445..0000000
--- a/views/enable-js.pug
+++ /dev/null
@@ -1,4 +0,0 @@
-extends layout
-
-block content
-	p.warn Javascript must be enabled. Change settings, and reload exam page.
diff --git a/views/enablejs.pug b/views/enablejs.pug
new file mode 100644
index 0000000..20c8c74
--- /dev/null
+++ b/views/enablejs.pug
@@ -0,0 +1,6 @@
+extends layout
+
+block content
+	p.red-text.text-darken-4.center-align.
+		Javascript must be enabled. #[br]
+		Change settings, and reload exam page.
diff --git a/views/fullscreen.pug b/views/fullscreen.pug
new file mode 100644
index 0000000..c69096e
--- /dev/null
+++ b/views/fullscreen.pug
@@ -0,0 +1,7 @@
+extends layout
+
+block content
+	p.red-text.text-darken-4.center-align.
+		Full-screen mode required, window resizing forbidden. #[br]
+		Your current answer (if any) was sent to the server. #[br]
+		Please go back to the exam page ASAP.
diff --git a/views/no-devtools.pug b/views/no-devtools.pug
deleted file mode 100644
index bdc6021..0000000
--- a/views/no-devtools.pug
+++ /dev/null
@@ -1,5 +0,0 @@
-extends layout
-
-block content
-	p.warn Devtools is forbidden during an exam. Exit devtools and go back to exam page.
-	p.warn NOTE: if the exam was started already, then you cannot resume it :/
diff --git a/views/noblur.pug b/views/noblur.pug
new file mode 100644
index 0000000..b8d22ff
--- /dev/null
+++ b/views/noblur.pug
@@ -0,0 +1,7 @@
+extends layout
+
+block content
+	p.red-text.text-darken-4.center-align.
+		Losing focus is forbidden. #[br]
+		Your current answer (if any) was sent to the server. #[br]
+		Please go back to the exam page ASAP, in full screen mode.
-- 
2.44.0