First commit

This commit is contained in:
2023-12-07 20:35:55 +01:00
parent 3480f2ab05
commit ea881e2403
18 changed files with 668 additions and 0 deletions

50
.eslintrc.json Normal file
View File

@@ -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"
}
}

6
api/.env.sample Normal file
View File

@@ -0,0 +1,6 @@
DATABASE_HOST="127.0.0.1"
DATABASE_NAME=nuitdelinfo2023
DATABASE_USER=nuitdelinfo2023
DATABASE_PASSWORD=""
JWT_SECRET=""
PORT=3000

173
api/.gitignore vendored Normal file
View File

@@ -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

15
api/README.md Normal file
View File

@@ -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.

BIN
api/bun.lockb Executable file

Binary file not shown.

25
api/database.sql Normal file
View File

@@ -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;

32
api/index.js Normal file
View File

@@ -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}`);
});

22
api/jsconfig.json Normal file
View File

@@ -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
]
}
}

0
api/logs/access.log Normal file
View File

26
api/modules/database.js Normal file
View File

@@ -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 };

19
api/modules/log.js Normal file
View File

@@ -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);
}

20
api/modules/random.js Normal file
View File

@@ -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');
}

View File

@@ -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,
};

52
api/modules/token.js Normal file
View File

@@ -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 };

20
api/package.json Normal file
View File

@@ -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"
}
}

40
api/routes/leaderboard.js Normal file
View File

@@ -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;

35
api/routes/test.js Normal file
View File

@@ -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;

79
api/routes/users.js Normal file
View File

@@ -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;