diff --git a/kubernetes/backendv2-deployment.yaml b/kubernetes/backendv2-deployment.yaml index 11b2b5e3..a98682dd 100644 --- a/kubernetes/backendv2-deployment.yaml +++ b/kubernetes/backendv2-deployment.yaml @@ -96,6 +96,16 @@ spec: secretKeyRef: name: backend-database-secret key: SENTRY_DSN + - name: CSD_URL + valueFrom: + secretKeyRef: + name: backend-database-secret + key: CSD_URL + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: backend-database-secret + key: JWT_SECRET initContainers: - name: quizzes-backendv2-run-migrations image: ${BACKENDV2_IMAGE} diff --git a/packages/backendv2/database/migrations/20210930184939_add_column_check_plagiarism_to_quiz.ts b/packages/backendv2/database/migrations/20210930184939_add_column_check_plagiarism_to_quiz.ts new file mode 100644 index 00000000..ee299f39 --- /dev/null +++ b/packages/backendv2/database/migrations/20210930184939_add_column_check_plagiarism_to_quiz.ts @@ -0,0 +1,11 @@ +import * as Knex from "knex" + +export async function up(knex: Knex): Promise { + await knex.raw( + `alter table quiz add column check_plagiarism boolean default false;`, + ) +} + +export async function down(knex: Knex): Promise { + await knex.raw(`alter table quiz drop column check_plagiarism;`) +} diff --git a/packages/backendv2/package-lock.json b/packages/backendv2/package-lock.json index 3302f5eb..3df96785 100644 --- a/packages/backendv2/package-lock.json +++ b/packages/backendv2/package-lock.json @@ -1202,6 +1202,14 @@ "@types/node": "*" } }, + "@types/jsonwebtoken": { + "version": "8.5.4", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.4.tgz", + "integrity": "sha512-4L8msWK31oXwdtC81RmRBAULd0ShnAHjBuKT9MRQpjP0piNrZdXyTRcKY9/UIfhGeKIT4PvF5amOOUbbT/9Wpg==", + "requires": { + "@types/node": "*" + } + }, "@types/keygrip": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz", @@ -5502,6 +5510,11 @@ "minimist": "^1.2.5" } }, + "jsonparse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.0.0.tgz", + "integrity": "sha1-JiL05mwI4arH7b63YFPJt+EhH3Y=" + }, "jsonstream": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/jsonstream/-/jsonstream-1.0.3.tgz", @@ -5509,12 +5522,43 @@ "requires": { "jsonparse": "~1.0.0", "through": ">=2.2.7 <3" + } + }, + "jsonwebtoken": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", + "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "requires": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^5.6.0" }, "dependencies": { - "jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=" + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } } } }, @@ -5791,12 +5835,47 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=" }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", "dev": true }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", diff --git a/packages/backendv2/package.json b/packages/backendv2/package.json index 153abe9f..975e1993 100644 --- a/packages/backendv2/package.json +++ b/packages/backendv2/package.json @@ -11,7 +11,7 @@ "dev": "concurrently npm:ts-watch npm:watch", "test": "cross-env NODE_ENV=test && jest --runInBand", "test-open-conn": "cross-env NODE_ENV=test && jest --runInBand --detectOpenHandles ", - "reset-test-db": "cross-env NODE_ENV=test && (dropdb quizzes_test || true) && createdb quizzes_test && knex migrate:latest", + "reset-test-db": "cross-env NODE_ENV=test && (dropdb quizzes_test || true) && createdb quizzes_test && cross-env NODE_ENV=test && knex migrate:latest", "migrate": "knex migrate:latest", "seed": "knex seed:run --specific a.ts", "update-expired-courses": "node ./dist/bin/update-expired-courses.js", @@ -46,6 +46,7 @@ "@types/csv-parse": "^1.2.2", "@types/csv-stringify": "^3.1.0", "@types/jsonstream": "^0.8.30", + "@types/jsonwebtoken": "^8.5.4", "@types/lodash": "^4.14.161", "JSONStream": "^1.3.5", "axios": "^0.21.1", @@ -54,6 +55,7 @@ "dotenv": "^8.2.0", "ioredis": "^4.19.4", "jsonstream": "^1.0.3", + "jsonwebtoken": "^8.5.1", "knex": "^0.21.1", "koa": "^2.12.0", "koa-bodyparser": "^4.3.0", diff --git a/packages/backendv2/src/models/language.ts b/packages/backendv2/src/models/language.ts index 7a60b4be..a2cdc9b9 100644 --- a/packages/backendv2/src/models/language.ts +++ b/packages/backendv2/src/models/language.ts @@ -1,6 +1,8 @@ import BaseModel from "./base_model" class Language extends BaseModel { + name!: string + static get tableName() { return "language" } diff --git a/packages/backendv2/src/models/quiz.ts b/packages/backendv2/src/models/quiz.ts index df8c0746..638cd481 100644 --- a/packages/backendv2/src/models/quiz.ts +++ b/packages/backendv2/src/models/quiz.ts @@ -35,6 +35,7 @@ export class Quiz extends BaseModel { title!: string body!: string submitMessage!: string + checkPlagiarism!: boolean static get tableName() { return "quiz" diff --git a/packages/backendv2/src/models/quiz_answer.ts b/packages/backendv2/src/models/quiz_answer.ts index 2a2d5d77..5320d3f1 100644 --- a/packages/backendv2/src/models/quiz_answer.ts +++ b/packages/backendv2/src/models/quiz_answer.ts @@ -21,6 +21,8 @@ import softDelete from "objection-soft-delete" import { mixin } from "objection" import QuizAnswerStatusModification from "./quiz_answer_status_modification" import { TStatusModificationOperation } from "./../types/index" +import { relayNewAnswer } from "../services/plagiarism" +import Language from "./language" type QuizAnswerStatus = | "draft" @@ -581,6 +583,9 @@ class QuizAnswer extends mixin(BaseModel, [ } await trx.commit() quiz.course = course + if (quiz.checkPlagiarism) { + await this.relayPlagiarismData(savedQuizAnswer, quiz) + } return { quiz, quizAnswer: savedQuizAnswer, @@ -592,6 +597,26 @@ class QuizAnswer extends mixin(BaseModel, [ } } + private static async relayPlagiarismData(quizAnswer: QuizAnswer, quiz: Quiz) { + const language = await Language.query().findById(quiz.course.languageId) + for (const quizItem of quiz.items) { + if (quizItem.type === "essay") { + const itemAnswer = quizAnswer.itemAnswers.find( + itemAnswer => itemAnswer.quizItemId === quizItem.id, + ) + if (itemAnswer) { + relayNewAnswer({ + quizId: quiz.id, + quizAnswerId: quizAnswer.id, + quizItemAnswerId: itemAnswer.id, + language: language.name.split(" ")[0].toLowerCase(), + data: itemAnswer.textData, + }) + } + } + } + } + public static async update( quizAnswer: QuizAnswer, userQuizState: UserQuizState, diff --git a/packages/backendv2/src/services/plagiarism.ts b/packages/backendv2/src/services/plagiarism.ts new file mode 100644 index 00000000..af988ee9 --- /dev/null +++ b/packages/backendv2/src/services/plagiarism.ts @@ -0,0 +1,27 @@ +import axios from "axios" +import jwt from "jsonwebtoken" +import { GlobalLogger } from "../middleware/logger" + +const CancelToken = axios.CancelToken +const source = CancelToken.source() + +export const relayNewAnswer = async (data: any) => { + try { + const csd_url = process.env.CSD_URL || "http://localhost/5150" + await axios.post(csd_url + "/new", data, { + headers: { + authorization: jwt.sign( + { source: "quizzes" }, + process.env.JWT_SECRET || "", + ), + }, + cancelToken: source.token, + }) + setTimeout(() => { + GlobalLogger.warn("plagiarism detection: request timed out") + source.cancel() + }, 10000) + } catch (error) { + GlobalLogger.error("plagiarism detection: backend responded with error") + } +} diff --git a/packages/backendv2/tests/data.ts b/packages/backendv2/tests/data.ts index 6559da76..50909baa 100644 --- a/packages/backendv2/tests/data.ts +++ b/packages/backendv2/tests/data.ts @@ -15,6 +15,7 @@ export const input = { awardPointsEvenIfWrong: false, grantPointsPolicy: "grant_whenever_possible", autoReject: true, + checkPlagiarism: false, title: "quiz", body: "body", submitMessage: "nice one!", @@ -86,6 +87,7 @@ export const input = { awardPointsEvenIfWrong: false, grantPointsPolicy: "grant_whenever_possible", autoReject: true, + checkPlagiarism: false, title: "quiz", body: "body", submitMessage: "nice one!", @@ -238,6 +240,8 @@ interface QuizValidator { awardPointsEvenIfWrong: false grantPointsPolicy: "grant_whenever_possible" autoReject: true + + checkPlagiarism: false title: "quiz" body: "body" submitMessage: "nice one!" @@ -261,6 +265,7 @@ export const validation = { awardPointsEvenIfWrong: false, grantPointsPolicy: "grant_whenever_possible", autoReject: true, + checkPlagiarism: false, title: "quiz", body: "body", submitMessage: "nice one!", @@ -375,6 +380,7 @@ export const validation = { awardPointsEvenIfWrong: false, grantPointsPolicy: "grant_whenever_possible", autoReject: true, + checkPlagiarism: false, title: "quiz", body: "body", submitMessage: "nice one!", @@ -475,6 +481,7 @@ export const validation = { awardPointsEvenIfWrong: false, grantPointsPolicy: "grant_whenever_possible", autoReject: true, + checkPlagiarism: false, title: "quiz 1", body: "body", submitMessage: "nice one!", @@ -589,6 +596,7 @@ export const validation = { awardPointsEvenIfWrong: false, grantPointsPolicy: "grant_whenever_possible", autoReject: true, + checkPlagiarism: false, title: "quiz 2", body: "body", submitMessage: "nice one!", @@ -663,6 +671,7 @@ export const validation = { awardPointsEvenIfWrong: false, grantPointsPolicy: "grant_whenever_possible", autoReject: true, + checkPlagiarism: false, title: "quiz", body: "body", submitMessage: "nice one!", @@ -737,6 +746,7 @@ export const validation = { awardPointsEvenIfWrong: false, grantPointsPolicy: "grant_whenever_possible", autoReject: true, + checkPlagiarism: false, title: "quiz", body: "body", submitMessage: "nice one!", @@ -990,6 +1000,7 @@ export const validation = { quiz: { autoConfirm: true, autoReject: true, + checkPlagiarism: false, awardPointsEvenIfWrong: false, courseId: "46d7ceca-e1ed-508b-91b5-3cc8385fa44b", createdAt: expect.stringMatching(dateTime), @@ -1271,6 +1282,7 @@ export const validation = { excludedFromScore: false, autoConfirm: true, autoReject: true, + checkPlagiarism: false, triesLimited: true, tries: 1, grantPointsPolicy: "grant_whenever_possible", @@ -1460,6 +1472,7 @@ export const validation = { excludedFromScore: false, autoConfirm: true, autoReject: true, + checkPlagiarism: false, triesLimited: true, tries: 1, grantPointsPolicy: "grant_whenever_possible",