diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..2fcbea3 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,50 @@ +{ + "extends": "eslint:recommended", + "env": { + "node": true, + "es6": true + }, + "parserOptions": { + "ecmaVersion": 2021, + "sourceType": "module" + }, + "rules": { + "arrow-spacing": ["warn", { "before": true, "after": true }], + "brace-style": ["error", "stroustrup", { "allowSingleLine": true }], + "comma-dangle": ["error", "always-multiline"], + "comma-spacing": "error", + "comma-style": "error", + "curly": ["error", "multi-line", "consistent"], + "dot-location": ["error", "property"], + "handle-callback-err": "off", + "indent": ["error", "tab"], + "keyword-spacing": "error", + "max-nested-callbacks": ["error", { "max": 4 }], + "max-statements-per-line": ["error", { "max": 2 }], + "no-console": "off", + "no-empty-function": "error", + "no-floating-decimal": "error", + "no-inline-comments": "error", + "no-lonely-if": "error", + "no-multi-spaces": "error", + "no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1, "maxBOF": 0 }], + "no-shadow": ["error", { "allow": ["err", "resolve", "reject"] }], + "no-trailing-spaces": ["error"], + "no-var": "error", + "object-curly-spacing": ["error", "always"], + "prefer-const": "error", + "quotes": ["error", "single"], + "semi": ["error", "always"], + "space-before-blocks": "error", + "space-before-function-paren": ["error", { + "anonymous": "never", + "named": "never", + "asyncArrow": "always" + }], + "space-in-parens": "error", + "space-infix-ops": "error", + "space-unary-ops": "error", + "spaced-comment": "error", + "yoda": "error" + } +} \ No newline at end of file diff --git a/api/.env.sample b/api/.env.sample new file mode 100644 index 0000000..a57d2c1 --- /dev/null +++ b/api/.env.sample @@ -0,0 +1,6 @@ +DATABASE_HOST="127.0.0.1" +DATABASE_NAME=nuitdelinfo2023 +DATABASE_USER=nuitdelinfo2023 +DATABASE_PASSWORD="" +JWT_SECRET="" +PORT=3000 \ No newline at end of file diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 0000000..d9e2431 --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,173 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..276a965 --- /dev/null +++ b/api/README.md @@ -0,0 +1,15 @@ +# nuitdelinfo2023-api + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.js +``` + +This project was created using `bun init` in bun v1.0.13. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/api/bun.lockb b/api/bun.lockb new file mode 100755 index 0000000..48fddcc Binary files /dev/null and b/api/bun.lockb differ diff --git a/api/database.sql b/api/database.sql new file mode 100644 index 0000000..6394258 --- /dev/null +++ b/api/database.sql @@ -0,0 +1,25 @@ +SET default_storage_engine = InnoDB; +DROP DATABASE IF EXISTS `nuitdelinfo2023`; +CREATE DATABASE IF NOT EXISTS `nuitdelinfo2023` + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +DROP USER IF EXISTS 'nuitdelinfo2023'; +CREATE USER 'nuitdelinfo2023'@'%' IDENTIFIED BY 'PASSWORD'; +GRANT ALL PRIVILEGES ON airjet.* TO 'nuitdelinfo2023'@'%'; + +USE `nuitdelinfo2023`; + +CREATE TABLE users ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + username VARCHAR(64) NOT NULL, + password VARCHAR(255) NOT NULL, + score INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + CONSTRAINT u_user_type_id + FOREIGN KEY (user_type_id) + REFERENCES user_types(id) + ON DELETE RESTRICT + ON UPDATE CASCADE, + INDEX ur_user_type_idx (user_type_id) +) ENGINE=InnoDB; \ No newline at end of file diff --git a/api/index.js b/api/index.js new file mode 100644 index 0000000..b495a98 --- /dev/null +++ b/api/index.js @@ -0,0 +1,32 @@ +import fs from 'fs'; +import path from 'path'; +import cors from 'cors'; +import logger from 'morgan'; +import express from 'express'; + +import { log } from './modules/log'; +import { speedLimiter, checkSystemLoad } from './modules/requestHandler'; + +import testRouter from './routes/test'; + +const app = express(); +app.set('trust proxy', 1); + +app.use(express.json()); +app.use(logger('dev')); +app.use(speedLimiter); +app.use(checkSystemLoad); +app.use(logger('combined', { stream: fs.createWriteStream(path.join(__dirname, 'logs/access.log'), { flags: 'a' }) })); +app.use(cors({ + origin: '*', +})); + +app.use(express.static('public')); + +// routes +app.use('/api/test', testRouter); + +// run the API +app.listen(process.env.PORT, async () => { + log(`running at port ${process.env.PORT}`); +}); \ No newline at end of file diff --git a/api/jsconfig.json b/api/jsconfig.json new file mode 100644 index 0000000..7556e1d --- /dev/null +++ b/api/jsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "noEmit": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "types": [ + "bun-types" // add Bun global + ] + } +} diff --git a/api/logs/access.log b/api/logs/access.log new file mode 100644 index 0000000..e69de29 diff --git a/api/modules/database.js b/api/modules/database.js new file mode 100644 index 0000000..d29e367 --- /dev/null +++ b/api/modules/database.js @@ -0,0 +1,26 @@ +import mysql from 'mysql2'; + +const connection = mysql.createConnection({ + host: process.env.DATABASE_HOST, + user: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + database: process.env.DATABASE_NAME, +}); +const pool = mysql.createPool({ + host: process.env.DATABASE_HOST, + user: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + database: process.env.DATABASE_NAME, +}).promise(); + +function createPool(host, user, password, db) { + const newPool = mysql.createPool({ + host: host, + user: user, + password: password, + database: db, + }).promise(); + return newPool; +} + +export { connection, pool, createPool }; diff --git a/api/modules/log.js b/api/modules/log.js new file mode 100644 index 0000000..e1d1baa --- /dev/null +++ b/api/modules/log.js @@ -0,0 +1,19 @@ +import pino from 'pino'; + +const logger = pino(); + +export function log(x) { + logger.info(x); +} + +export function debug(x) { + logger.debug(x); +} + +export function warn(x) { + logger.warn(x); +} + +export function error(x) { + logger.error(x); +} \ No newline at end of file diff --git a/api/modules/random.js b/api/modules/random.js new file mode 100644 index 0000000..8063195 --- /dev/null +++ b/api/modules/random.js @@ -0,0 +1,20 @@ +export function random(x, y) { + return crypto.randomInt(x, y + 1); +} + +export function random2(min, max) { + const range = max - min + 1; + const byteLength = Math.ceil(Math.log2(range) / 8); + let randomValue; + + do { + const randomBytes = crypto.randomBytes(byteLength); + randomValue = parseInt(randomBytes.toString('hex'), 16); + } while (randomValue >= range); + + return randomValue + min; +} + +export function randomHEX(x) { + return crypto.randomBytes(x).toString('hex'); +} diff --git a/api/modules/requestHandler.js b/api/modules/requestHandler.js new file mode 100644 index 0000000..2c6e0ea --- /dev/null +++ b/api/modules/requestHandler.js @@ -0,0 +1,54 @@ +import rateLimit from 'express-rate-limit'; +import slowDown from 'express-slow-down'; +import http from 'http'; +import os from 'os'; + +const requestLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 5, + standardHeaders: true, + legacyHeaders: false, + message: 'Too many requests from this IP, please try again later', +}); + +const speedLimiter = slowDown({ + windowMs: 60 * 1000, + delayAfter: 5, + delayMs: (hits) => hits * 100, +}); + +function checkSystemLoad(req, res, next) { + const load = os.loadavg()[0]; + const cores = os.cpus().length; + const threshold = cores * 0.7; + + if (load > threshold) { + return respondWithStatus(res, 503, 'API Unavailable - System Overloaded!'); + } + + return next(); +} + +function respondWithStatus(res, statusCode, message) { + const response = { status: statusCode, message: message }; + if (statusCode >= 400 && statusCode <= 599) { + response.error = http.STATUS_CODES[statusCode]; + } + return res.status(statusCode).json(response); +} + +function respondWithStatusJSON(res, statusCode, JSON) { + const response = { status: statusCode, JSON }; + if (statusCode >= 400 && statusCode <= 599) { + response.error = http.STATUS_CODES[statusCode]; + } + return res.status(statusCode).json(response); +} + +export { + requestLimiter, + speedLimiter, + checkSystemLoad, + respondWithStatus, + respondWithStatusJSON, +}; \ No newline at end of file diff --git a/api/modules/token.js b/api/modules/token.js new file mode 100644 index 0000000..57ef111 --- /dev/null +++ b/api/modules/token.js @@ -0,0 +1,52 @@ +/* eslint-disable no-undef */ +import jwt from 'jsonwebtoken'; +import { Level } from 'level'; +import { respondWithStatus } from './requestHandler'; +import { pool } from './database'; + + +// Set up LevelDB instance +const db = new Level('./tokensDB'); + +// Generate a new JWT +const generateToken = async (userId, password) => { + const token = jwt.sign({ userId: userId, password: password }, process.env.JWT_SECRET, { expiresIn: '7d' }); + await db.put(token); + return token; +}; + +// Middleware to verify the JWT and set req.userId +const verifyToken = async (req, res, next) => { + const token = req.headers.authorization; + if (!token) return await respondWithStatus(res, 401, 'No token provided'); + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + req.userId = decoded.userId; + + const [rows] = await pool.execute( + 'SELECT * FROM users WHERE id = ? LIMIT 1', [req.userId], + ); + if (!rows.length) return await respondWithStatus(res, 404, 'User not found!'); + const passwordMatch = await Bun.password.verify(decoded.password, rows[0].password); + if (!passwordMatch) return await respondWithStatus(res, 401, 'Token is invalid'); + + const now = Date.now().valueOf() / 1000; + if (decoded.exp - now < 36000) { + const newToken = generateToken(req.userId, decoded.password); + res.cookie('token', newToken, { + expires: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), + httpOnly: true, + secure: true, + sameSite: 'strict', + }); + res.set('Authorization', newToken); + } + next(); + } + catch (error) { + return await respondWithStatus(res, 401, 'Invalid user'); + } +}; + +export { generateToken, verifyToken }; \ No newline at end of file diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..cd113a9 --- /dev/null +++ b/api/package.json @@ -0,0 +1,20 @@ +{ + "name": "nuitdelinfo2023-api", + "module": "index.js", + "type": "module", + "devDependencies": { + "bun-types": "latest", + "eslint": "^8.55.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "level": "^8.0.0", + "morgan": "^1.10.0", + "mysql2": "^3.6.5" + } +} \ No newline at end of file diff --git a/api/routes/leaderboard.js b/api/routes/leaderboard.js new file mode 100644 index 0000000..7cb8c43 --- /dev/null +++ b/api/routes/leaderboard.js @@ -0,0 +1,40 @@ +/* eslint-disable no-undef */ +import express from 'express'; +import { pool } from '../modules/database.js'; +import { requestLimiter, respondWithStatus, respondWithStatusJSON } from '../modules/requestHandler.js'; + +const router = express.Router(); + +router.get('/', requestLimiter, async (req, res) => { + try { + const [rows] = await pool.execute('SELECT * FROM users'); + if (!rows.length) return await respondWithStatus(res, 404, 'There are no users'); + + return await respondWithStatusJSON(res, 200, { + message: 'Successfully retrieved users', + users: rows, + }); + } + catch (error) { + console.error(error); + return await respondWithStatus(res, 500, 'An error has occured'); + } +}); + +router.get('/:username', requestLimiter, async (req, res) => { + try { + const [rows] = await pool.execute('SELECT u.*, (SELECT COUNT(*) + 1 FROM users AS uu WHERE uu.score > u.score) AS rank FROM users AS u WHERE username = ? LIMIT 1', [req.params.username]); + if (!rows.length) return await respondWithStatus(res, 404, 'There are no users'); + + return await respondWithStatusJSON(res, 200, { + message: 'Successfully retrieved user', + users: rows[0], + }); + } + catch (error) { + console.error(error); + return await respondWithStatus(res, 500, 'An error has occured'); + } +}); + +export default router; \ No newline at end of file diff --git a/api/routes/test.js b/api/routes/test.js new file mode 100644 index 0000000..567566d --- /dev/null +++ b/api/routes/test.js @@ -0,0 +1,35 @@ +import express from 'express'; + +const router = express.Router(); + +router.get('/', (req, res) => { + + res.status(200).json({ code: 200, message:'Received GET request' }); + +}); + +router.post('/', (req, res) => { + + res.status(200).json({ code: 200, message:'Received POST request' }); + +}); + +router.patch('/', (req, res) => { + + res.status(200).json({ code: 200, message:'Received PUT request' }); + +}); + +router.put('/', (req, res) => { + + res.status(200).json({ code: 200, message:'Received PUT request' }); + +}); + +router.delete('/', (req, res) => { + + res.status(200).json({ code: 200, message:'Received DELETE request' }); + +}); + +export default router; \ No newline at end of file diff --git a/api/routes/users.js b/api/routes/users.js new file mode 100644 index 0000000..adc23b6 --- /dev/null +++ b/api/routes/users.js @@ -0,0 +1,79 @@ +/* eslint-disable no-undef */ +import express from 'express'; +import { pool } from '../modules/database.js'; +import { generateToken } from '../modules/token.js'; +import { requestLimiter, respondWithStatus, respondWithStatusJSON } from '../modules/requestHandler.js'; + +const router = express.Router(); + +router.post('/register', requestLimiter, async (req, res) => { + const { username, password } = req.body; + if ([ username, password ].every(Boolean)) { + try { + const [existingUsername] = await pool.execute('SELECT * FROM users WHERE username = ? LIMIT 1', [username]); + if (existingUsername.length) { + return await respondWithStatus(res, 400, 'Username is already taken'); + } + + const hashedPassword = await Bun.password.hash(password); + const [result] = await pool.execute( + 'INSERT INTO users (username, password) VALUES (?, ?)', [ username, hashedPassword ], + ); + if (result.affectedRows === 0) { + return await respondWithStatus(res, 500, 'Error storing user'); + } + return await respondWithStatus(res, 200, 'Successfully registered'); + } + catch (error) { + console.error(error); + return await respondWithStatus(res, 500, 'An error has occured'); + } + } + else { + return await respondWithStatus(res, 400, 'Missing fields'); + } +}); + +router.post('/login', requestLimiter, async (req, res) => { + const { username, password } = req.body; + if ([username, password].every(Boolean)) { + try { + const [rows] = await pool.execute( + 'SELECT * FROM users WHERE username = ? LIMIT 1', [username], + ); + if (!rows.length) { + return await respondWithStatus(res, 404, 'Incorrect username or email'); + } + const user = rows[0]; + const passwordMatch = await Bun.password.verify(password, user.password); + if (!passwordMatch) return await respondWithStatus(res, 401, 'Incorrect password'); + + const token = await generateToken(user.id, password); + res.cookie('token', token, { + expires: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), + httpOnly: true, + secure: true, + sameSite: 'strict', + }); + return await respondWithStatusJSON(res, 200, { + message: 'Login successful', + token: token, + user: { + id: user.id, + username: user.username, + email: user.email, + name: user.name, + }, + }); + } + catch (error) { + console.error(error); + return await respondWithStatus(res, 500, 'An error has occured'); + } + } + else { + return await respondWithStatus(res, 400, 'Missing fields'); + } +}); + +export default router; \ No newline at end of file