diff --git a/backend/package.json b/backend/package.json index c0b4d9d..92ffd1c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "lateinicusserver", - "version": "1.2.0", + "version": "1.3.0", "description": "The backend server for Lateinicus", "main": "index.js", "scripts": { @@ -22,7 +22,8 @@ "body-parser": "1.18.3", "cors": "^2.8.4", "express": "4.16.3", - "mongodb": "^3.1.6" + "mongodb": "^3.1.6", + "profanity-util": "^0.2.0" }, "devDependencies": { "@types/cors": "^2.8.4", diff --git a/backend/src/#main.ts# b/backend/src/#main.ts# new file mode 100644 index 0000000..a5ab3d4 --- /dev/null +++ b/backend/src/#main.ts# @@ -0,0 +1,276 @@ +import { env, exit } from "process"; +// import * as fs from "fs"; +import { randomBytes, pbkdf2Sync } from "crypto"; +import * as assert from "assert"; + +import * as express from "express"; +import * as cors from "cors"; + +import * as bodyparser from "body-parser"; + +//@ts-ignore +import * as profanity from "profanity-util"; + +import { isAuthenticated, performLogin } from "./security/auth"; + +import { LRequest } from "./types/express"; + +import UserRouter from "./api/user"; +import ClassRouter from "./api/class"; +import LevelRouter from "./api/level"; + +import { ITrackerDBModel } from "./models/tracker"; + +const baseRouter = express.Router(); +const authRouter = express.Router(); + +import { MongoClient } from "mongodb"; + +const user = encodeURIComponent("backend"); +const password = encodeURIComponent(env["LATEINICUS_USER_PW"]); + +(async function() { + // Load the profanity list + // const list = JSON.parse(fs.readFileSync("/etc/profanity", { encoding: "utf-8" })); + // const profanityFilter = new Filter({ + // list, + // }); + + // Database Name + const dbName = 'lateinicus'; + // Connection URL + const url = `mongodb://${user}:${password}@128.1.0.2:27017/?authMechanism=SCRAM-SHA-1&authSource=${dbName}`; + let client: MongoClient; + + try { + // Use connect method to connect to the Server + client = await MongoClient.connect(url); + console.log("Connected to MongoDB"); + } catch (err) { + console.log(err.stack); + assert(false); + } + + const db = client.db(dbName); + console.log("Connected to the database"); + + + const app = express(); + app.use(bodyparser.json()); + app.options("*", cors()); + + app.use((req: LRequest, res, next) => { + // Every route should have access to the database so that + // we can easily make calls to it + req.db = db; + next(); + }); + app.use("/api/level", LevelRouter); + app.use("/api/class", ClassRouter); + app.use("/api/user", UserRouter); + app.post("/api/tracker", async (req, res) => { + // Did we get any data + if (!req.body) { + res.send({ + error: "403", + data: { + msg: "No request body provided", + }, + }); + return; + } + + // Did we get all arguments? + if (!("session" in req.body) || !("event" in req.body)) { + res.send({ + error: "403", + data: { + msg: "Invalid request", + }, + }); + return; + } + + // Insert it into the database + const tracker: ITrackerDBModel = Object.assign({}, req.body, { + timestamp: Date.now(), + }); + await db.collection("tracker").insertOne(tracker); + + res.send({ + error: "200", + data: {}, + }); + }); + app.get("/api/levels", async (req, res) => { + // TODO: if (levels) + const levels = (await db.collection("levels").find({}, { + // The order in which we send the levels is important, so better + // sort them + sort: { + level: 1, + }, + }) + .toArray()) + .map((el) => { + let tmp = Object.assign({}, el); + delete tmp.vocab; + delete tmp._id; + + return tmp; + }); + + res.send({ + error: "200", + data: { + levels, + }, + }); + }); + app.post("/api/register", async (req, res) => { + // Check if any data was sent + if (!req.body) { + res.send({ + error: "403", + data: { + msg: `No data sent`, + }, + }); + return; + } + + // Check if we have all we need + const params = ["username", "password", "classId"]; + for (let param of params) { + if (!(param in req.body)) { + res.send({ + error: "403", + data: { + msg: `${param} not specified!`, + }, + }); + return; + } + } + + const { username, password, classId } = req.body; + + // Check if the registration is open for the class Id + // NOTE: This to prevent people from spamming the database + const classes = env["LATEINICUS_CLASSES"].split(","); + if (classes.indexOf(classId) === -1) { + res.send({ + error: "403", + data: { + msg: "Class does not exist", + }, + }); + return; + } + + // TODO: Check if the username is profane + // if (profanityFilter.isProfane(username)) { + // res.send({ + // error: "451", + // data: { + // msg: "Profane username", + // }, + // }); + // return; + // } + const matches = profanity.check(username, { substring: true }); + if (matches.length > 0) { + res.send({ + error: "451", + data: { + msg: "Profane username", + }, + }); + return; + } + + // Check if the user already exists + const checkUser = await db.collection("users").findOne({ + username, + }); + if (checkUser) { + res.send({ + error: "403", + data: { + msg: "User already exists", + }, + }); + return; + } + + const salt = randomBytes(30).toString("hex"); + const hash = pbkdf2Sync(password, salt, 50000, 512, "sha512").toString("hex"); + const user = { + username, + salt, + hash, + classId, + score: 0, + showWelcome: true, + + lastReview: { + correct: 0, + wrong: 0, + }, + + lastLevel: 0, + levels: [] as number[], + vocabMetadata: {}, + }; + await db.collection("users").insertOne(user); + + res.send({ + error: "200", + data: {}, + }); + }); + app.get("/api/health", (req, res) => { + res.send({ + error: "200", + data: { + msg: "lol", + }, + }); + }); + app.post("/api/login", async (req, res) => { + // Check if all arguments were sent + const { body } = req; + if (!body || !("username" in body) || !("password" in body)) { + res.send({ + error: "400", + data: { + msg: "Username or password not specified", + }, + }); + + return; + } + + // Try to log the user in + try { + const userData = await performLogin(body.username, body.password, db); + res.send({ + error: "200", + data: userData, + }); + } catch (err) { + console.log("Could not resolve login promise!", err); + + // If anything was wrong, just tell the client + res.send({ + error: "1", + data: { + msg: "Username or password is wrong", + }, + }); + } + }); + const server = app.listen(8080, () => { + console.log("Starting on port 8080"); + }); +})(); diff --git a/backend/src/.#main.ts b/backend/src/.#main.ts new file mode 120000 index 0000000..b833355 --- /dev/null +++ b/backend/src/.#main.ts @@ -0,0 +1 @@ +alexander@nishimiya.6541:1539788829 \ No newline at end of file diff --git a/backend/src/algorithms/sm2/index.ts b/backend/src/algorithms/sm2/index.ts index 673936b..5aa8050 100644 --- a/backend/src/algorithms/sm2/index.ts +++ b/backend/src/algorithms/sm2/index.ts @@ -1,11 +1,15 @@ -import { dayInNDays } from "../../utils/date"; - export interface ISchedulingData { easiness: number; consecutiveCorrectAnswers: number; nextDueDate: number; }; +function dateInNDays(n: number): number { + let today = new Date(); + today.setDate(today.getDate() + n); + return Date.parse(today.toString()); +} + export enum AnswerType { CORRECT, WRONG, @@ -28,8 +32,8 @@ export function updateSchedulingData(data: ISchedulingData, answer: AnswerType): data.consecutiveCorrectAnswers + 1 ) : 0; data.nextDueDate = answer === AnswerType.CORRECT ? ( - dayInNDays(6 * Math.pow(data.easiness, data.consecutiveCorrectAnswers - 1)) - ) : dayInNDays(1); + dateInNDays(6 * Math.pow(data.easiness, data.consecutiveCorrectAnswers - 1)) + ) : dateInNDays(1); return data; } diff --git a/backend/src/api/level.ts b/backend/src/api/level.ts index dff47f1..a91215c 100644 --- a/backend/src/api/level.ts +++ b/backend/src/api/level.ts @@ -49,7 +49,7 @@ levelRouter.get("/:id/vocab", async (req: LRequest, res: Response) => { // Fetch all the vocabulary const vocab = await db.collection("vocabulary").find({ id: { $in: level.vocab } }).toArray(); res.send({ - error: "0", + error: "200", data: { vocab, } diff --git a/backend/src/api/user.ts b/backend/src/api/user.ts index 2670117..2a032f6 100644 --- a/backend/src/api/user.ts +++ b/backend/src/api/user.ts @@ -37,7 +37,7 @@ userRouter.get("/me", async (req: LRequest, res) => { delete copy.vocabMetadata; res.send({ - error: "0", + error: "200", data: copy, }); } else { @@ -55,7 +55,7 @@ userRouter.get("/logout", async (req: LRequest, res) => { await db.collection("sessions").findOneAndDelete({ token, }); res.send({ - error: "0", + error: "200", data: {}, }); }); @@ -143,9 +143,9 @@ userRouter.get("/queue", async (req: LRequest, res) => { }); res.send({ - error: "0", + error: "200", data: { - queue: vocab, + vocab, }, }); }); @@ -157,17 +157,11 @@ userRouter.get("/lastReview", async (req: LRequest, res) => { const user = await userFromSession(token, db); res.send({ - error: "0", + error: "200", data: user.lastReview, }); }); -function dateInNDays(n: number): number { - let today = new Date(); - today.setDate(today.getDate() + n); - return Date.parse(today.toString()); -} - userRouter.post("/lastReview", async (req: LRequest, res) => { // Check if we get the needed data if (!req.body || !("meta" in req.body) || !("sm2" in req.body) || !("delta" in req.body)) { @@ -228,7 +222,7 @@ userRouter.post("/lastReview", async (req: LRequest, res) => { }); res.send({ - error: "0", + error: "200", data: {}, }); }); @@ -259,7 +253,7 @@ async function getNextLevel(token: string, db: Db): Promise { userRouter.get("/nextLevel", async (req: LRequest, res) => { const level = await getNextLevel(req.token, req.db); res.send({ - error: "0", + error: "200", data: level, }); }); @@ -307,7 +301,7 @@ userRouter.post("/level/:id", async (req: LRequest, res) => { }); // Convert the level's vocabulary to SM2 metadata - let sm2: { [id: number]: ISM2Metadata } = {}; + let sm2: { [id: number]: ISchedulingData } = {}; level.vocab.forEach((id: number) => { sm2[id] = { easiness: 1.3, diff --git a/backend/src/main.ts b/backend/src/main.ts index e4e2573..2bdfe1f 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -9,7 +9,7 @@ import * as cors from "cors"; import * as bodyparser from "body-parser"; //@ts-ignore -//import * as Filter from "bad-words"; +import * as profanity from "profanity-util"; import { isAuthenticated, performLogin } from "./security/auth"; @@ -121,7 +121,7 @@ const password = encodeURIComponent(env["LATEINICUS_USER_PW"]); }); res.send({ - error: "0", + error: "200", data: { levels, }, @@ -178,6 +178,16 @@ const password = encodeURIComponent(env["LATEINICUS_USER_PW"]); // }); // return; // } + const matches = profanity.check(username, { substring: true }); + if (matches.length > 0) { + res.send({ + error: "451", + data: { + msg: "Profane username", + }, + }); + return; + } // Check if the user already exists const checkUser = await db.collection("users").findOne({ @@ -221,7 +231,7 @@ const password = encodeURIComponent(env["LATEINICUS_USER_PW"]); }); app.get("/api/health", (req, res) => { res.send({ - error: "0", + error: "200", data: { msg: "lol", }, @@ -245,7 +255,7 @@ const password = encodeURIComponent(env["LATEINICUS_USER_PW"]); try { const userData = await performLogin(body.username, body.password, db); res.send({ - error: "0", + error: "200", data: userData, }); diff --git a/compose.yml b/compose.yml index c555ecb..b6cfa75 100644 --- a/compose.yml +++ b/compose.yml @@ -16,7 +16,7 @@ services: ipv4_address: 128.1.0.2 backend: - image: lateinicus/server:1.2.0 + image: lateinicus/server:1.3.0 environment: - LATEINICUS_USER_PW=abc123 - LATEINICUS_CLASSES=test diff --git a/frontend/package.json b/frontend/package.json index 19478d0..4774fdb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "seminarfach", - "version": "1.2.0", + "version": "1.3.0", "description": "", "main": "index.js", "scripts": { diff --git a/frontend/src/api/call.ts b/frontend/src/api/call.ts new file mode 100644 index 0000000..f96ab40 --- /dev/null +++ b/frontend/src/api/call.ts @@ -0,0 +1,33 @@ +import { BACKEND_URL } from "../config.in"; + +interface IAPIOptions { + token?: string; + body?: any; + method: "post" | "get"; +}; +export function makeAPICall(endpoint: string, options: IAPIOptions): Promise { + const { token, body, method } = options; + const headers = token !== "" ? ({ + "Content-Type": "application/json", + "Token": token, + }) : ({ + "Content-Type": "application/json", + }); + + return new Promise((res, rej) => { + fetch(`${BACKEND_URL}${endpoint}`, { + // WHUT + headers: new Headers(headers), + body: body !== {} ? JSON.stringify(body) : "", + method, + }) + .then(resp => resp.json(), err => rej(err)) + .then(data => { + if (data.error === "200") { + res(data.data); + } else { + rej(data); + } + }); + }); +} diff --git a/frontend/src/components/app.tsx b/frontend/src/components/app.tsx index 891a61e..7102fc8 100644 --- a/frontend/src/components/app.tsx +++ b/frontend/src/components/app.tsx @@ -17,6 +17,7 @@ import VocabPage from "../containers/VocabPage"; import Drawer from "../containers/Drawer"; import { trackAction } from "../api/tracker"; +import { makeAPICall } from "../api/call"; import { BACKEND_URL } from "../config.in"; @@ -60,76 +61,26 @@ export default class Application extends React.Component { // Track the end of a review trackAction(TrackerEvent.LOG_IN); - return new Promise((res, rej) => { - fetch(`${BACKEND_URL}/api/user/me`, { - headers: new Headers({ - "Content-Type": "application/json", - "Token": token, - }), - }).then(resp => resp.json(), err => rej(err)) - .then(data => { - if (data.error === "0") { - res(data.data); - } else { - rej(data); - } - }); - }); + return makeAPICall("/api/user/me", { + token: this.props.user.sessionToken, + method: "get", + }) } - getVocab = (): Promise => { - return new Promise((res, rej) => { - fetch(`${BACKEND_URL}/api/user/vocab`, { - headers: new Headers({ - "Content-Type": "application/json", - "Token": this.props.user.sessionToken, - }), - }).then(resp => resp.json(), err => rej(err)) - .then(data => { - if (data.error === "200") { - res(data.data); - } else { - rej(data); - } - }); - }); - } + getVocab = () => makeAPICall("/api/user/vocab", { + token: this.props.user.sessionToken, + method: "get", + }) - getLevels = (): Promise => { - return new Promise((res, rej) => { - fetch(`${BACKEND_URL}/api/levels`, { - headers: new Headers({ - "Content-Type": "application/json", - "Token": this.props.user.sessionToken, - }), - }).then(resp => resp.json(), err => rej(err)) - .then(data => { - if (data.error === "0") { - res(data.data.levels); - } else { - rej(data); - } - }); - }); - } + getLevels = () => makeAPICall("/api/levels", { + token: this.props.user.sessionToken, + method: "get", + }); - getLastReview = (): Promise => { - return new Promise((res, rej) => { - fetch(`${BACKEND_URL}/api/user/lastReview`, { - headers: new Headers({ - "Content-Type": "application/json", - "Token": this.props.user.sessionToken, - }), - }).then(resp => resp.json(), err => rej(err)) - .then(data => { - if (data.error === "0") { - res(data.data); - } else { - rej(data); - } - }); - }); - } + getLastReview = () => makeAPICall("/api/user/lastReview", { + token: this.props.user.sessionToken, + method: "get", + }); // TODO: Type? setLastReview = (meta: IReviewMetadata, sm2: any, delta: number) => { @@ -138,149 +89,55 @@ export default class Application extends React.Component { this.props.setUserScoreDelta(delta); // Tell the server about the last review - fetch(`${BACKEND_URL}/api/user/lastReview`, { - headers: new Headers({ - "Content-Type": "application/json", - "Token": this.props.user.sessionToken, - }), - method: "POST", - body: JSON.stringify({ + makeAPICall("/api/user/lastReview", { + token: this.props.user.sessionToken, + body: { meta, sm2, delta, - }), - }).then(resp => resp.json(), err => { - console.log("Application::setLastReview: POSTing last results failed"); + }, + method: "post", }); // Track the end of a review trackAction(TrackerEvent.FINISH_LEARNING); } - getReviewQueue = (): Promise => { - return new Promise((res, rej) => { - fetch(`${BACKEND_URL}/api/user/queue`, { - headers: new Headers({ - "Content-Type": "application/json", - "Token": this.props.user.sessionToken, - }), - }).then(resp => resp.json(), err => rej(err)) - .then(data => { - if (data.error === "0") { - res(data.data.queue); - } else { - rej(data); - } - }); - }); - } + getReviewQueue = () => makeAPICall("/api/user/queue", { + token: this.props.user.sessionToken, + method: "get", + }); - getTopTenLearners = (): Promise => { - // TODO: Deprecate? - const id = this.props.user.classId; - return new Promise((res, rej) => { - fetch(`${BACKEND_URL}/api/class/${id}/topTen`, { - headers: new Headers({ - "Content-Type": "application/json", - "Token": this.props.user.sessionToken, - }), - }).then(resp => resp.json(), - err => rej(err)) - .then(data => { - if (data.error === "0") { - res(data.data.topTen); - } else { - rej(data); - } - }); - }); - } + getNextLevel = () => makeAPICall("/api/user/nextLevel", { + token: this.props.user.sessionToken, + method: "get", + }); - getNextLevel = (): Promise => { - return new Promise((res, rej) => { - fetch(`${BACKEND_URL}/api/user/nextLevel`, { - headers: new Headers({ - "Content-Type": "application/json", - "Token": this.props.user.sessionToken, - }), - }).then(resp => resp.json(), - err => rej(err)) - .then(data => { - if (data.error === "0") { - res(data.data); - } else { - rej(data); - } - }); - }); - } + getLevelVocab = (id: number) => makeAPICall(`/api/level/${id}/vocab`, { + token: this.props.user.sessionToken, + method: "get", + }); - getLevelVocab = (id: number): Promise => { - return new Promise((res, rej) => { - fetch(`${BACKEND_URL}/api/level/${id}/vocab`, { - method: "GET", - headers: new Headers({ - "Content-Type": "application/json", - "Token": this.props.user.sessionToken, - }), - }).then(data => data.json(), err => { - rej(err); - }).then((resp: IResponse) => { - if (resp.error === "0") { - res(resp.data.vocab); - } else { - rej(resp); - } - }); - }); - } + // NOTE: This is not a promise, as we do not care about any response + // being sent, since we don't need to update any client-side + // state. + introDontShowAgain = () => makeAPICall("/api/user/showWelcome", { + token: this.props.user.sessionToken, + body: { + state: false, + }, + method: "post", + }); - introDontShowAgain = (): void => { - // NOTE: This is not a promise, as we do not care about any response - // being sent, since we don't need to update any client-side - // state. - fetch(`${BACKEND_URL}/api/user/showWelcome`, { - headers: new Headers({ - "Content-Type": "application/json", - "Token": this.props.user.sessionToken, - }), - method: "POST", - body: JSON.stringify({ - state: false, - }), - }); - } + getDashboard = () => makeAPICall("/api/user/dashboard", { + token: this.props.user.sessionToken, + method: "get", + }); - // TODO: Type? - getDashboard = (): Promise => { - return new Promise((res, rej) => { - fetch(`${BACKEND_URL}/api/user/dashboard`, { - headers: new Headers({ - "Content-Type": "application/json", - "Token": this.props.user.sessionToken, - }), - }) - .then(resp => resp.json(), err => rej(err)) - .then(data => { - if (data.error === "200") { - res(data.data); - } else { - console.log("Application::getDashboard: Failed to get dashboard"); - rej(data); - } - }); - }); - } - - updateDoneLevels = (id: string): void => { - fetch(`${BACKEND_URL}/api/user/level/${id}`, { - headers: new Headers({ - "Content-Type": "application/json", - "Token": this.props.user.sessionToken, - }), - method: "POST", - }); - } + updateDoneLevels = (id: string) => makeAPICall(`/api/user/level/${id}`, { + token: this.props.user.sessionToken, + method: "post", + }); login = (username: string, password: string): Promise => { // Track the login @@ -302,7 +159,7 @@ export default class Application extends React.Component { // The fetch failed rej(err); }).then((resp: IResponse) => { - if (resp.error === "0") { + if (resp.error === "200") { // Successful login this.props.setUser(resp.data); this.props.setDidLogin(true); diff --git a/frontend/src/pages/level.tsx b/frontend/src/pages/level.tsx index e1c3872..ac3b4c8 100644 --- a/frontend/src/pages/level.tsx +++ b/frontend/src/pages/level.tsx @@ -28,7 +28,7 @@ import { IVocab, VocabType } from "../models/vocab"; interface IProps { id: string; - levelVocab: (id: string) => Promise; + levelVocab: (id: string) => Promise; history: any; @@ -64,7 +64,8 @@ const LevelPageWithRouter = withRouter( this.props.setLoading(true); // TODO: Error handling - this.props.levelVocab(this.props.id).then(vocab => { + this.props.levelVocab(this.props.id).then(data => { + const { vocab } = data; this.props.setVocab(vocab); this.props.setCurrentVocab(vocab[0]); this.props.setLookedAt([vocab[0].id]); diff --git a/frontend/src/pages/levelList.tsx b/frontend/src/pages/levelList.tsx index 65018ce..4a5b771 100644 --- a/frontend/src/pages/levelList.tsx +++ b/frontend/src/pages/levelList.tsx @@ -6,7 +6,6 @@ import Button from "@material-ui/core/Button"; import Card from '@material-ui/core/Card'; import CardActions from '@material-ui/core/CardActions'; import CardContent from '@material-ui/core/CardContent'; -import Paper from "@material-ui/core/Paper"; import Snackbar from "@material-ui/core/Snackbar"; import Loader from "../components/loading"; @@ -17,7 +16,7 @@ import { ILevel } from "../models/level"; import { IUser } from "../models/user"; interface IProps { - getLevels: () => Promise; + getLevels: () => Promise; history: any; @@ -37,7 +36,7 @@ const LevelListWithRouter = withRouter( // Fetch the levels this.props.getLevels().then(res => { - this.props.setLevels(res); + this.props.setLevels(res.levels); this.props.setLoading(false); }); } diff --git a/frontend/src/pages/review.tsx b/frontend/src/pages/review.tsx index 56725f5..25dbb14 100644 --- a/frontend/src/pages/review.tsx +++ b/frontend/src/pages/review.tsx @@ -40,8 +40,8 @@ import { Queue } from "../utils/queue"; interface IProps { levelId?: number; - vocabByLevel?: (level: number) => Promise; - vocabByQueue?: () => Promise; + vocabByLevel?: (level: number) => Promise; + vocabByQueue?: () => Promise; updateDoneLevels?: (id: string) => void; setLastReview: (meta: IReviewMetadata, sm2: any, delta: number) => void; reviewType: ReviewType; @@ -110,19 +110,19 @@ const ReviewPageWithRouter = withRouter( // Track the start of a session trackAction(TrackerEvent.START_LEARNING); - getVocab().then((res: IVocab[]) => { + getVocab().then((res: any) => { // Check if we received any vocabulary - if (res.length === 0) { + if (res.vocab.length === 0) { this.openModal(); return; } // Stop the loading this.props.setLoading(false); - this.vocab = res; + this.vocab = res.vocab; // Convert the vocab items into review queue cards - res.forEach(vocab => { + res.vocab.forEach((vocab: IVocab) => { // Set the error data for the group this.error_data[vocab.id] = 0;