diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4d0f1e0..f92eb6d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,5 +21,5 @@ jobs: - name: Start server run: | npm start & - sleep 5 # Give server some time to start + sleep 10 # Give server some time to start - run: npm test diff --git a/.gitignore b/.gitignore index c6bba59..c53815f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +data/ + # Logs logs *.log diff --git a/.vscode/launch.json b/.vscode/launch.json index c56ec22..8425a2b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,21 +1,15 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Debug server.ts", - "skipFiles": [ - "/**" - ], - "program": "${workspaceFolder}\\src\\app.ts", - "preLaunchTask": "tsc: build - tsconfig.json", - "outFiles": [ - "${workspaceFolder}/dist/**/*.js" - ] - } - ] + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug TypeScript", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}\\src\\app.ts", + "preLaunchTask": "build" + } + ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..864577f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,18 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "shell", + "command": "npx tsc -p tsconfig.json", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "silent" + }, + "problemMatcher": "$tsc" + } + ] +} diff --git a/httpdocs/favicon.ico b/httpdocs/favicon.ico new file mode 100644 index 0000000..040a17f Binary files /dev/null and b/httpdocs/favicon.ico differ diff --git a/nodemon.json b/nodemon.json index 92b25d2..ad7eb01 100644 --- a/nodemon.json +++ b/nodemon.json @@ -2,5 +2,8 @@ "watch": ["src"], "ext": ".ts", "ignore": [], - "exec": "tsc && node dist/app.js" + "exec": "tsc && node dist/app.js", + "events": { + "start": "clear" + } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 808a918..4c0b355 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,10 @@ "name": "lorex", "version": "0.0.1", "dependencies": { + "chalk": "^4.1.2", "express": "^4.18.2", "express-validator": "^7.0.1", + "helmet": "^7.1.0", "hpp": "^0.2.3", "module-alias": "^2.2.3" }, @@ -17,6 +19,7 @@ "@jest/globals": "^29.7.0", "@tsconfig/node20": "^20.1.2", "@types/bcrypt": "^5.0.2", + "@types/dotenv": "^8.2.0", "@types/express": "^4.17.21", "@types/hpp": "^0.2.5", "@types/jest": "^29.5.11", @@ -1519,6 +1522,16 @@ "@types/node": "*" } }, + "node_modules/@types/dotenv": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-ylSC9GhfRH7m1EUXBXofhgx4lUWmFeQDINW5oLuS+gxWdfUeW4zJdeVTYVkexEW+e2VUvlZR2kGnGGipAWR7kw==", + "deprecated": "This is a stub types definition. dotenv provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "dotenv": "*" + } + }, "node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -2098,7 +2111,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2438,7 +2450,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2454,7 +2465,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -2463,7 +2473,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -2562,7 +2571,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2573,8 +2581,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/combined-stream": { "version": "1.0.8", @@ -3756,6 +3763,14 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.1.0.tgz", + "integrity": "sha512-g+HZqgfbpXdCkme/Cd/mZkV0aV3BZZZSugecH03kl38m/Kmdx8jKjBikpDj2cr+Iynv4KpYEviojNdTJActJAg==", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/hpp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/hpp/-/hpp-0.2.3.tgz", diff --git a/package.json b/package.json index 43db4ea..86d5cdc 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "start": "node dist/app.js", "dev": "rm -rf dist/* && cp -R httpdocs/ dist/ && nodemon src/app.ts", "lint": "eslint . --fix", - "test": "jest" + "test": "jest --runInBand" }, "keywords": [], "author": "Type-Style", @@ -18,6 +18,7 @@ "@jest/globals": "^29.7.0", "@tsconfig/node20": "^20.1.2", "@types/bcrypt": "^5.0.2", + "@types/dotenv": "^8.2.0", "@types/express": "^4.17.21", "@types/hpp": "^0.2.5", "@types/jest": "^29.5.11", @@ -35,8 +36,10 @@ "typescript": "^5.3.3" }, "dependencies": { + "chalk": "^4.1.2", "express": "^4.18.2", "express-validator": "^7.0.1", + "helmet": "^7.1.0", "hpp": "^0.2.3", "module-alias": "^2.2.3" }, diff --git a/src/controller/write.test.ts b/src/controller/write.test.ts deleted file mode 100644 index f447d22..0000000 --- a/src/controller/write.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import axios, { AxiosError } from 'axios'; - -describe('HEAD /write', () => { - it('with all parameters correctly set it should succeed', async () => { - const timestamp = new Date().getTime(); - const response = await axios.head(`http://localhost/write?user=xx&lat=45.000&lon=90.000×tamp=${timestamp}&hdop=50.0&altitude=5000.000&speed=150.000&heading=180.0&key=test`); - expect(response.status).toBe(200); - }); - - it('without key it sends 403', async () => { - try { - const timestamp = new Date().getTime(); - await axios.head(`http://localhost/write?user=xx&lat=45.000&lon=90.000×tamp=${timestamp}&hdop=50.0&altitude=5000.000&speed=150.000&heading=180.0`); - } catch (error) { - const axiosError = error as AxiosError; - expect(axiosError.response!.status).toBe(403); - } - }); - - it('with user length not equal to 2 it sends 422', async () => { - try { - const timestamp = new Date().getTime(); - await axios.head(`http://localhost/write?user=x&lat=45.000&lon=90.000×tamp=${timestamp}&hdop=50.0&altitude=5000.000&speed=150.000&heading=180.0&key=test`); - } catch (error) { - const axiosError = error as AxiosError; - expect(axiosError.response!.status).toBe(422); - } - }); - - - it('with lat not between -90 and 90 it sends 422', async () => { - try { - const timestamp = new Date().getTime(); - await axios.head(`http://localhost/write?user=xx&lat=91.000&lon=90.000×tamp=${timestamp}&hdop=50.0&altitude=5000.000&speed=150.000&heading=180.0&key=test`); - } catch (error) { - const axiosError = error as AxiosError; - expect(axiosError.response!.status).toBe(422); - } - }); - - it('with lon not between -180 and 180 it sends 422', async () => { - try { - const timestamp = new Date().getTime(); - await axios.head(`http://localhost/write?user=xx&lat=45.000&lon=181.000×tamp=${timestamp}&hdop=50.0&altitude=5000.000&speed=150.000&heading=180.0&key=test`); - } catch (error) { - const axiosError = error as AxiosError; - expect(axiosError.response!.status).toBe(422); - } - }); - - it('with timestamp to old sends 422', async () => { - try { - const timestamp = new Date().getTime() - 24 * 60 * 60 * 1000 * 2; // two days ago - await axios.head(`http://localhost/write?user=xx&lat=45.000&lon=90.000×tamp=${timestamp}&hdop=101.0&altitude=5000.000&speed=150.000&heading=180.0&key=test`); - } catch (error) { - const axiosError = error as AxiosError; - expect(axiosError.response!.status).toBe(422); - } - }) - - it('with hdop not between 0 and 100 it sends 422', async () => { - try { - const timestamp = new Date().getTime(); - await axios.head(`http://localhost/write?user=xx&lat=45.000&lon=90.000×tamp=${timestamp}&hdop=101.0&altitude=5000.000&speed=150.000&heading=180.0&key=test`); - } catch (error) { - const axiosError = error as AxiosError; - expect(axiosError.response!.status).toBe(422); - } - }); - - it('with altitude not between 0 and 10000 it sends 422', async () => { - try { - const timestamp = new Date().getTime(); - await axios.head(`http://localhost/write?user=xx&lat=45.000&lon=90.000×tamp=${timestamp}&hdop=50.0&altitude=10001.000&speed=150.000&heading=180.0&key=test`); - } catch (error) { - const axiosError = error as AxiosError; - expect(axiosError.response!.status).toBe(422); - } - }); - - it('with speed not between 0 and 300 it sends 422', async () => { - try { - const timestamp = new Date().getTime(); - await axios.head(`http://localhost/write?user=xx&lat=45.000&lon=90.000×tamp=${timestamp}&hdop=50.0&altitude=5000.000&speed=301.000&heading=180.0&key=test`); - } catch (error) { - const axiosError = error as AxiosError; - expect(axiosError.response!.status).toBe(422); - } - }); - - it('with heading not between 0 and 360 it sends 422', async () => { - try { - const timestamp = new Date().getTime(); - await axios.head(`http://localhost/write?user=xx&lat=45.000&lon=90.000×tamp=${timestamp}&hdop=50.0&altitude=5000.000&speed=150.000&heading=361.0&key=test`); - } catch (error) { - const axiosError = error as AxiosError; - expect(axiosError.response!.status).toBe(422); - } - }); -}); diff --git a/src/controller/write.ts b/src/controller/write.ts index b62fd57..dc0658d 100644 --- a/src/controller/write.ts +++ b/src/controller/write.ts @@ -1,17 +1,19 @@ import express, { Request, Response, NextFunction } from 'express'; import { entry } from '@src/models/entry'; import { validationResult } from 'express-validator'; +import { create as createError } from '@src/error'; + // example call: /write?user=xx&lat=00.000&lon=00.000×tamp=1704063600000&hdop=0.0&altitude=0.000&speed=0.000&heading=000.0 -function errorChecking (req:Request, res:Response, next:NextFunction) { +async function errorChecking (req:Request, res:Response, next:NextFunction) { const errors = validationResult(req); if (!errors.isEmpty()) { const errorAsJson = { errors: errors.array()}; - const errorAsString = new Error(JSON.stringify(errorAsJson)); + const errorAsString = JSON.stringify(errorAsJson); const hasKeyErrors = errors.array().some(error => error.msg.includes("Key")); - res.status(hasKeyErrors ? 403 : 422); // send forbidden or unprocessable content - return next(errorAsString); + // send forbidden or unprocessable content + return createError(res, hasKeyErrors ? 403 : 422, errorAsString, next) } if (req.method == "HEAD") { @@ -19,19 +21,22 @@ function errorChecking (req:Request, res:Response, next:NextFunction) { return; } - // Regular Save logic from here - - //entry.create(req, res); - //const test = process.env.TEST; - // res.send(req.query); - + // Regular Save logic from here + await entry.create(req, res, next); + + if (!res.locals.error) { + res.send(req.query); + } else { + /* at this point error handling already happend, + * or the request has already been send + * therefor there is no need for it again (only middleware to follow at this point) */ + next(); + } } const router = express.Router(); -router.use(entry.validate); - -router.get('/', errorChecking); -router.head('/', errorChecking); +router.get('/', entry.validate, errorChecking); +router.head('/', entry.validate, errorChecking); export default router; \ No newline at end of file diff --git a/src/error.ts b/src/error.ts index 0b0bc04..7b8d624 100644 --- a/src/error.ts +++ b/src/error.ts @@ -1,4 +1,15 @@ import { Request, Response, NextFunction } from "express"; +import logger from '@src/scripts/logger'; + +export function create(res:Response, status:number = 500, message:string, next:NextFunction) { + /** + * takes httpStatusCode and Message and forwards to error Handling + */ + const error = new Error(message); + res.status(status); + res.locals.error = true; // to let other middleware know that an error was called + next(error) +} export function notFound(req: Request, res: Response, next: NextFunction) { res.status(404); @@ -25,8 +36,7 @@ export function handler(err: Error, req: Request, res: Response stack: process.env.NODE_ENV === "development" ? err.stack : "---" }; - //logger.error(responseBody); + logger.error(responseBody); res.json(responseBody); - next(); } diff --git a/src/models/entry.ts b/src/models/entry.ts index 73b7d29..147256e 100644 --- a/src/models/entry.ts +++ b/src/models/entry.ts @@ -1,47 +1,95 @@ -import { Request, Response} from 'express'; +import { NextFunction, Request, Response } from 'express'; import { checkExact, query } from 'express-validator'; import { crypt } from '@src/scripts/crypt'; +import { create as createError } from '@src/error'; +import * as file from '@src/scripts/file'; +import { getTime } from '@src/scripts/time'; +import { getSpeed } from '@src/scripts/speed'; +import { getDistance } from '@src/scripts/distance'; +import { getAngle } from '@src/scripts/angle'; +import logger from '@src/scripts/logger'; + export const entry = { - create: (req:Request, res:Response) => { - console.log(req.query); - console.log(res); - }, - validate: [ - query('user').isLength({ min: 2, max: 2 }), - query('lat').custom(checkNumber(-90, 90)), - query('lon').custom(checkNumber(-180, 180)), - query('timestamp').custom(checkTime), - query('hdop').custom(checkNumber(0, 100)), - query('altitude').custom(checkNumber(0, 10000)), - query('speed').custom(checkNumber(0, 300)), - query('heading').custom(checkNumber(0, 360)), + create: async (req: Request, res: Response, next: NextFunction) => { + const fileObj: File.Obj = file.getFile(res, next); + fileObj.content = await file.readAsJson(res, fileObj.path, next); + + if (!fileObj.content?.entries) { + return createError(res, 500, "File Content unavailable: " + fileObj.path, next); + } + const entries = fileObj.content.entries; + const lastEntry = fileObj.content.entries.at(-1); + const entry = {} as Models.IEntry; + + entry.altitude = Number(req.query.altitude); + entry.hdop = Number(req.query.hdop); + entry.heading = Number(req.query.heading); + entry.index = entries.length; + entry.lat = Number(req.query.lat); + entry.lon = Number(req.query.lon); + entry.user = req.query.user as string; + entry.ignore = false; + + if (lastEntry) { // so there is a previous entry + entry.time = getTime(Number(req.query.timestamp), lastEntry); + lastEntry.ignore = checkIgnore(lastEntry, entry); + entry.angle = getAngle(lastEntry, entry); + entry.distance = getDistance(entry, lastEntry) + entry.speed = getSpeed(Number(req.query.speed), entry); + } else { + entry.angle = undefined; + entry.time = getTime(Number(req.query.timestamp)); + entry.speed = getSpeed(Number(req.query.speed)) + } + + if (entries.length >= 1000) { + logger.log(`File over 1000 lines: ${fileObj.path}`); + if (entry.hdop < 12 || (lastEntry && entry.hdop < lastEntry.hdop)) { + entries[entries.length - 1] = entry; // replace last entry + } + } else { + entries.push(entry); + } + + file.write(res, fileObj, next); + + }, + validate: [ + query('user').isLength({ min: 2, max: 2 }), + query('lat').custom(checkNumber(-90, 90)), + query('lon').custom(checkNumber(-180, 180)), + query('timestamp').custom(checkTime), + query('hdop').custom(checkNumber(0, 100)), + query('altitude').custom(checkNumber(0, 10000)), + query('speed').custom(checkNumber(0, 300)), + query('heading').custom(checkNumber(0, 360, "integer")), query("key").custom(checkKey), - checkExact() + checkExact() // INFO: if message or any string gets added remember to escape - ] + ] } -export function checkNumber(min:number, max:number) { - return (value:string) => { +export function checkNumber(min: number, max: number, type: string = "float") { + return (value: string) => { if (!value) { throw new Error('is required'); } - if (value.length > 12) { - throw new Error('Should have a maximum of 11 digits'); - } - - const number = parseFloat(value); + if (value.length > 12) { + throw new Error('Should have a maximum of 11 digits'); + } + + const number = type == "float" ? parseFloat(value) : parseInt(value); if (isNaN(number) || number < min || number > max) { throw new Error(`Value should be between ${min} and ${max}`); } return true; - }; + }; } -export function checkTime(value:string) { +export function checkTime(value: string) { const timestamp = parseFloat(value); - + // Check if it's a number if (isNaN(timestamp)) { throw new Error('Timestamp should be a number'); @@ -56,20 +104,35 @@ export function checkTime(value:string) { if (process.env.NODE_ENV == "development") { return true; // dev testing convenience } - + const now = new Date(); const difference = now.getTime() - date.getTime(); const oneDayInMilliseconds = 24 * 60 * 60 * 1000; if (Math.abs(difference) >= oneDayInMilliseconds) { throw new Error('Timestamp should represent a date not further from server time than 1 day'); } - + return true } -function checkKey(value:string) { +function checkIgnore(lastEntry: Models.IEntry, entry: Models.IEntry): boolean { + let threshold = 6; // hdop not allowed to be higher + const maxThreshold = 25; + + const timing = Math.max(lastEntry.time.diff, entry.time.diff) + + // Threshold increases with older previous entries or farther future entries. + if (timing > 32) { + threshold += Math.min(lastEntry.time.diff / 60, maxThreshold); + } + + return lastEntry.hdop > threshold; +} + + +function checkKey(value: string) { if (process.env.NODE_ENV != "production" && value == "test") { - return true; // dev testing convenience + return true; // dev testing convenience } if (!value) { @@ -77,15 +140,15 @@ function checkKey(value:string) { } value = decodeURIComponent(value); - + const hash = crypt(value); if (process.env.KEYB != hash) { if (process.env.NODE_ENV == "development") { - console.log(hash); - } + console.log(hash); + } throw new Error('Key does not match'); } return true; -} \ No newline at end of file +} diff --git a/src/scripts/angle.ts b/src/scripts/angle.ts new file mode 100644 index 0000000..e6ea972 --- /dev/null +++ b/src/scripts/angle.ts @@ -0,0 +1,9 @@ +export function getAngle(lastEntry: Models.IEntry, entry: Models.IEntry): number { + const dLon = (entry.lon - lastEntry.lon) * Math.PI / 180; + const lat1 = lastEntry.lat * Math.PI / 180; + const lat2 = entry.lat * Math.PI / 180; + const y = Math.sin(dLon) * Math.cos(lat2); + const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon); + const angle = (Math.atan2(y, x) * 180 / Math.PI + 360) % 360; + return angle; +} \ No newline at end of file diff --git a/src/scripts/distance.ts b/src/scripts/distance.ts new file mode 100644 index 0000000..36c4637 --- /dev/null +++ b/src/scripts/distance.ts @@ -0,0 +1,30 @@ +export function getDistance(entry: Models.IEntry, lastEntry: Models.IEntry): Models.IDistance { + const horizontal = calculateDistance({ lat: entry.lat, lon: entry.lon }, { lat: lastEntry.lat, lon: lastEntry.lon }); + const vertical = entry.altitude - lastEntry.altitude; + const total = Math.sqrt(horizontal * horizontal + vertical * vertical); + + return { + horizontal: horizontal, + vertical: vertical, + total: total + } +} + +function toRad(x: number): number { + return x * Math.PI / 180; +} + +function calculateDistance(coord1: { lat: number, lon: number }, coord2: { lat: number, lon: number }): number { + const R = 6371000; // radius of the Earth in meters + const dLat = toRad(coord2.lat - coord1.lat); + const dLon = toRad(coord2.lon - coord1.lon); + + const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRad(coord1.lat)) * Math.cos(toRad(coord2.lat)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + const distance = R * c; + + return distance; +} \ No newline at end of file diff --git a/src/scripts/file.ts b/src/scripts/file.ts new file mode 100644 index 0000000..5fdec3f --- /dev/null +++ b/src/scripts/file.ts @@ -0,0 +1,62 @@ +import fs from 'fs'; +import path from 'path'; +import { promisify } from 'util'; +import { create as createError } from '@src/error'; +import { NextFunction, Response } from 'express'; +import logger from '@src/scripts/logger'; + +export const getFile = (res: Response, next: NextFunction): File.Obj => { + const date = new Date(); + const formattedDate = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; + const dirPath = path.resolve(__dirname, '../data'); + const filePath = path.resolve(dirPath, `data-${formattedDate}.json`); + + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + logger.log("data folder did not exist, but created now"); + } + + let fileExisted = true; + if (!fs.existsSync(filePath)) { // check if file exist + fileExisted = false; + try { + fs.writeFileSync(filePath, '{"entries": []}'); + logger.log(`file: ${filePath} did not exist, but created now`); + } catch (err) { + createError(res, 500, "File cannot be written to", next); + } + } + + return { path: filePath, content: fileExisted ? undefined : JSON.parse('{"entries": []}') }; // if the file did not exist before, the content is emptyString +}; + + +const readFileAsync = promisify(fs.readFile); + +export async function readAsJson(res: Response, filePath: string, next: NextFunction): Promise { + const data = await readFileAsync(filePath, 'utf-8'); + + try { + return JSON.parse(data); + } catch (err) { + createError(res, 500, "File contains wrong content: " + filePath, next); + } +} + + +export const write = (res:Response, fileObj:File.Obj, next: NextFunction) => { + + if (!fs.existsSync(fileObj.path)) { // check if file exist + createError(res, 500, "Can not write to file that does not exist: " + fileObj.path, next); + } + try { + const content = JSON.stringify(fileObj.content, undefined, 2); + fs.writeFileSync(fileObj.path, content); + fileObj.content = JSON.parse(content); + logger.log(`written to file: ${fileObj.path} ${fileObj.content ? fileObj.content?.entries.length - 1 : ''}`); + } catch (err) { + createError(res, 500, `File (${fileObj.path}) cannot be written to`, next); + } + + return fileObj; // if the file did not exist before, the content is emptyString +}; diff --git a/src/scripts/logger.ts b/src/scripts/logger.ts index b58f366..0beeaea 100644 --- a/src/scripts/logger.ts +++ b/src/scripts/logger.ts @@ -1,17 +1,24 @@ // primitive text logger -import fs from 'fs'; -import path from 'path'; +import fs from 'fs'; // typescript will compile to require +import path from 'path'; // typescript will compile to require +import chalk from "chalk"; // keep import syntax after compile const logPath = path.resolve(__dirname, '../httpdocs', 'log.txt'); const date = new Date().toLocaleString('de-DE', { hour12: false }); export default { - log: (message:string|JSON) => { + log: (message:string|JSON, showDateInConsole:boolean=false, showLogInTest=false) => { + message = JSON.stringify(message); fs.appendFileSync(logPath, `${date} \t|\t ${message} \n`); - console.log(message); + if (showDateInConsole) { + message = `${chalk.dim(date + ":")} ${message}`; + } + if (process.env.NODE_ENV == "development" || showLogInTest && process.env.NODE_ENV == "test") { + console.log(message); + } }, error: (message:string|JSON|Response.Error) => { fs.appendFileSync(logPath, `${date} \t|\t ERROR: ${message} \n`); console.error(message); - }, -} \ No newline at end of file + } +} diff --git a/src/scripts/speed.ts b/src/scripts/speed.ts new file mode 100644 index 0000000..b0591c0 --- /dev/null +++ b/src/scripts/speed.ts @@ -0,0 +1,19 @@ +export function getSpeed(speed: number, entry?: Models.IEntry): Models.ISpeed { + const gps = speed; + let horizontal; + let vertical; + let total; + + if (entry) { + horizontal = entry.distance.horizontal / entry.time.diff; + vertical = entry.distance.vertical / entry.time.diff; + total = entry.distance.total / entry.time.diff; + } + + return { + gps: gps, + horizontal: horizontal, + vertical: vertical, + total: total + } +} \ No newline at end of file diff --git a/src/scripts/time.ts b/src/scripts/time.ts index e69de29..f6adbc7 100644 --- a/src/scripts/time.ts +++ b/src/scripts/time.ts @@ -0,0 +1,34 @@ +import logger from '@src/scripts/logger'; + +export function getTime(time: number, entry?: Models.IEntry): Models.ITime { + const now = new Date(); + const created = Number(time); + const recieved = now.getTime(); + const uploadDuration = (recieved - created) / 1000; + const createdString = now.toLocaleString("de-DE", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: '2-digit', + hour12: false, + minute: '2-digit', + second: '2-digit' + }); + const diff = entry ? (created - entry.time.created) / 1000 : undefined; + + if (uploadDuration < 0) { + logger.error(`upload Duration is negative: ${createdString}, index: ${entry ? entry.index + 1 : 0}`); + } + if (entry && entry.time.created > created) { // maybe this could happend due to the async nature, but due to uncertainty logging is enabled + logger.error(`previous timestamp is more recent: ${createdString}, index: ${entry?.index + 1}`); + } + + return { + created: created, + recieved: recieved, + uploadDuration: uploadDuration, + diff: diff, + createdString: createdString + } +} \ No newline at end of file diff --git a/src/app.test.ts b/src/tests/app.test.ts similarity index 81% rename from src/app.test.ts rename to src/tests/app.test.ts index 0682f2d..b7c01c2 100644 --- a/src/app.test.ts +++ b/src/tests/app.test.ts @@ -4,7 +4,7 @@ describe('Server Status', () => { it('The server is running', async () => { let serverStatus; try { - const response = await axios.get('http://localhost'); + const response = await axios.get('http://localhost:80/'); serverStatus = response.status; } catch (error) { console.error(error); diff --git a/src/models/entry.test.ts b/src/tests/entry.test.ts similarity index 97% rename from src/models/entry.test.ts rename to src/tests/entry.test.ts index bd2c0b4..48058e7 100644 --- a/src/models/entry.test.ts +++ b/src/tests/entry.test.ts @@ -1,4 +1,4 @@ -import { checkNumber, checkTime } from "./entry"; +import { checkNumber, checkTime } from "../models/entry"; describe("checkNumber", () => { diff --git a/src/tests/write.test.ts b/src/tests/write.test.ts new file mode 100644 index 0000000..e5b4a29 --- /dev/null +++ b/src/tests/write.test.ts @@ -0,0 +1,199 @@ +import axios, { AxiosError } from 'axios'; +import fs from "fs"; +// import path from "path"; + +async function callServer(timestamp = new Date().getTime(), query: string, expectStatus: number = 200, method: string = "HEAD") { + const url = new URL("http://localhost:80/write?"); + url.search = "?" + query; + const params = new URLSearchParams(url.search); + params.set("timestamp", timestamp.toString()); + url.search = params.toString(); + + let response; + if (expectStatus == 200) { + if (method == "GET") { + response = await axios.get(url.toString()); + } else { + response = await axios.head(url.toString()); + } + expect(response.status).toBe(expectStatus); + } else { + try { + await axios.head(url.toString()); + } catch (error) { + const axiosError = error as AxiosError; + expect(axiosError.response!.status).toBe(expectStatus); + } + } +} + +/* function getData(filePath: string) { + const data = fs.readFileSync(filePath); + return JSON.parse(data.toString()); +} + +function isInRange(actual: string | number, expected: number, range: number) { + return Math.abs(Number(actual) - expected) <= range; +} */ + +describe('HEAD /write', () => { + it('with all parameters correctly set it should succeed', async () => { + await callServer(undefined, "user=xx&lat=45.000&lon=90.000×tamp=R3Pl4C3&hdop=50.0&altitude=5000.000&speed=150.000&heading=180.0&key=test", 200); + }); + + it('without key it sends 403', async () => { + await callServer(undefined, "user=xx&lat=45.000&lon=90.000×tamp=R3Pl4C3&hdop=50.0&altitude=5000.000&speed=150.000&heading=180.0", 403); + }); + + it('with user length not equal to 2 it sends 422', async () => { + await callServer(undefined, "user=x&lat=45.000&lon=90.000×tamp=R3Pl4C3&hdop=50.0&altitude=5000.000&speed=150.000&heading=180.0&key=test", 422); + }); + + it('with lat not between -90 and 90 it sends 422', async () => { + await callServer(undefined, "user=xx&lat=91.000&lon=90.000×tamp=R3Pl4C3&hdop=50.0&altitude=5000.000&speed=150.000&heading=180.0&key=test", 422); + }); + + it('with lon not between -180 and 180 it sends 422', async () => { + await callServer(undefined, "user=xx&lat=45.000&lon=181.000×tamp=R3Pl4C3&hdop=50.0&altitude=5000.000&speed=150.000&heading=180.0&key=test", 422); + }); + + it('with timestamp to old sends 422', async () => { + const timestamp = new Date().getTime() - 24 * 60 * 60 * 1000 * 2; // two days ago + await callServer(timestamp, "user=xx&lat=45.000&lon=90.000×tamp=R3Pl4C3&hdop=50.0&altitude=5000.000&speed=150.000&heading=180.0&key=test", 422); + }) + + it('with hdop not between 0 and 100 it sends 422', async () => { + await callServer(undefined, "user=xx&lat=45.000&lon=90.000×tamp=R3Pl4C3&hdop=101.0&altitude=5000.000&speed=150.000&heading=180.0&key=test", 422); + }); + + it('with altitude not between 0 and 10000 it sends 422', async () => { + await callServer(undefined, "user=xx&lat=45.000&lon=90.000×tamp=R3Pl4C3&hdop=50.0&altitude=10001.000&speed=150.000&heading=180.0&key=test", 422); + }); + + it('with speed not between 0 and 300 it sends 422', async () => { + await callServer(undefined, "user=xx&lat=45.000&lon=90.000×tamp=R3Pl4C3&hdop=50.0&altitude=5000.000&speed=301.000&heading=180.0&key=test", 422); + }); + + it('with heading not between 0 and 360 it sends 422', async () => { + await callServer(undefined, "user=xx&lat=45.000&lon=90.000×tamp=R3Pl4C3&hdop=50.0&altitude=5000.000&speed=150.000&heading=361.0&key=test", 422); + }); +}); + +/* +describe("GET /write", () => { + const date = new Date(); + const formattedDate = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; + const dirPath = path.resolve(__dirname, '../../dist/data/'); + const filePath = path.resolve(dirPath, `data-${formattedDate}.json`); + + it('there should a file of the current date', async () => { + await await callServer(undefined, "user=xx&lat=52.51451&lon=13.35105×tamp=R3Pl4C3&hdop=20.0&altitude=5000.000&speed=150.000&heading=180.0&key=test", 200, "GET"); + + fs.access(filePath, fs.constants.F_OK, (err) => { + expect(err).toBeFalsy(); + }); + }); + + it('the file contains valid JSON', async () => { + fs.readFile(filePath, 'utf8', (err, data) => { + expect(err).toBeFalsy(); + try { + JSON.parse(data); + } catch (e) { + expect(e).toBeFalsy(); + } + }); + }); + + it('after second call and the JSON entries length is 2', () => { + return new Promise(done => { + // Increase the timeout for this test + setTimeout(async () => { + await await callServer(undefined, "user=xx&lat=52.51627&lon=13.37770×tamp=R3Pl4C3&hdop=50&altitude=4000.000&speed=150.000&heading=180.0&key=test", 200, "GET"); + const jsonData = getData(filePath); + + expect(jsonData.entries.length).toBe(2); + + done(); + }, 2000); + }) + }); + + it('the time is correct', () => { + const jsonData = getData(filePath); + const entry = jsonData.entries.at(-1) + + expect(entry.time.created).toBeGreaterThan(date.getTime()); + expect(entry.time.diff).toBeGreaterThan(2); + expect(entry.time.diff).toBeLessThan(3); + + + const germanDayPattern = "(Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag|Sonntag)"; + const dayOfMonthPattern = "(0?[1-9]|[12][0-9]|3[01])"; + const germanMonthPattern = "(Januar|Februar|März|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember)"; + const yearPattern = "(\\d{4})"; + const timePattern = "([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]"; + const pattern = new RegExp(`^${germanDayPattern}, ${dayOfMonthPattern}. ${germanMonthPattern} ${yearPattern} um ${timePattern}$`); + const string = entry.time.createdString; + expect(pattern.test(string)).toBeTruthy(); + + }); + + it('the distance is correct', () => { + const jsonData = getData(filePath); + const entry = jsonData.entries.at(-1) + + expect(entry.distance.horizontal).toBeCloseTo(1813.926); + expect(entry.distance.vertical).toBe(-1000); + expect(entry.distance.total).toBeCloseTo(2071.311); + }); + + it('the angle is correct', () => { + const jsonData = getData(filePath); + const entry = jsonData.entries.at(-1) + + expect(entry.angle).toBeCloseTo(83.795775); + }); + + it('the speed is correct', () => { + const jsonData = getData(filePath); + const entry = jsonData.entries.at(-1) + + expect(isInRange(entry.speed.horizontal, 870, 10)).toBe(true); + expect(isInRange(entry.speed.vertical, -478, 10)).toBe(true); + expect(isInRange(entry.speed.total, 992, 15)).toBe(true); + }); + + it('check ignore', async () => { + let jsonData = getData(filePath); + let entry = jsonData.entries[1]; + const lastEntry = jsonData.entries[0]; + + expect(entry.ignore).toBe(false); // current one to be false allways + expect(lastEntry.ignore).toBe(true); // last one to high hdop to be true + + await await callServer(undefined, "user=xx&lat=52.51627&lon=13.37770×tamp=R3Pl4C3&hdop=50&altitude=4000.000&speed=150.000&heading=180.0&key=test", 200, "GET"); + jsonData = getData(filePath); + entry = jsonData.entries[1]; // same data point, but not last now therefore ignore true + expect(entry.ignore).toBe(true); + }); +}); */ + +/* describe('API calls', () => { + test(`1000 api calls`, async () => { + for (let i = 0; i < 1000; i++) { + const url = `http://localhost:80/write?user=xx&lat=${(52 + Math.random()).toFixed(3)}&lon=${(13 + Math.random()).toFixed(3)}×tamp=${new Date().getTime()}&hdop=${(25 * Math.random()).toFixed(3)}&altitude=${i}&speed=88.888&heading=${(360 * Math.random()).toFixed(3)}&key=test`; + const response = await axios.get(url); + expect(response.status).toBe(200); + } + }, 20000); // adjust this to to fit your setup + + test(`length of json should not exceed 1000`, async () => { + const date = new Date(); + const formattedDate = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; + const dirPath = path.resolve(__dirname, '../../dist/data/'); + const filePath = path.resolve(dirPath, `data-${formattedDate}.json`); + const jsonData = getData(filePath); + expect(jsonData.entries.length).toBeLessThanOrEqual(1000); + }); +}); */ \ No newline at end of file diff --git a/types.d.ts b/types.d.ts index b3d3397..7cb4c68 100644 --- a/types.d.ts +++ b/types.d.ts @@ -1,14 +1,10 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -type NumericRange = - ARR['length'] extends END ? ACC | START | END : - NumericRange; - namespace Response { interface Message { message: string; - data?: string|JSON; + data?: string | JSON; } interface Error extends Response.Message { @@ -17,7 +13,18 @@ namespace Response { status?: number } } +namespace File { + interface Obj { + path: string, + content?: Models.IEntries; + } +} + namespace Models { + interface IEntries { + entries: Models.IEntry[] + } + interface IEntry { /** * height above ground in meters, as received by gps @@ -27,25 +34,17 @@ namespace Models { /** * Direction in degrees between two coordinate pairs: 0°-360° */ - angle: NumericRange<0, 360>, + angle?: number, /** * object containing horizontal vertical and total distance, in meters */ - distance: { - horizontal: number, - vertical: number, - total: number - }, + distance: Models.IDistance, /** * object containing horizontal vertical and total speed, in km/h */ - speeed: { - horizontal: number, - vertical: number, - total: number - }, + speed: Models.ISpeed, /** * index, position of the entry point in the chain @@ -55,13 +54,13 @@ namespace Models { /** * Heading or Bearing as recieved from gps */ - heading: NumericRange<0, 360>, + heading: number, /** * lat */ lat: number, - + /** * lon @@ -82,17 +81,31 @@ namespace Models { /** * time object containing UNIX timestamps with milliseconds, gps creation time (as recieved via gps), server time (when the server recieved and computed it), differce to last entry (time between waypoints), upload time differnce */ - time: { - created: number, - recieved: number, - uploadDuration: number, - diff: number - createdString: string - }, + time: Models.time, /** * user as recieved */ user: string } + + interface ITime { + created: number, + recieved: number, + uploadDuration: number, + diff?: number + createdString: string + } + + interface ISpeed { + gps: number; + horizontal?: number, + vertical?: number, + total?: number + } + interface IDistance { + horizontal: number, + vertical: number, + total: number + } }