diff --git a/.env.sample b/.env.sample index 8d2906f..2feb727 100644 --- a/.env.sample +++ b/.env.sample @@ -1,10 +1,22 @@ -PORT=3000 +PORT=80 DOMAIN="http://localhost:3000" DATABASE_HOST="127.0.0.1" DATABASE_NAME=hsp_gdh DATABASE_USER=hsp_gdh DATABASE_PASSWORD="" JWT_SECRET="" -SMTP= -MAIL= -MAIL_PASS= \ No newline at end of file +MAIL_SERVER= +MAIL_ADDRESS= +MAIL_PASSWORD= +BEHIND_PROXY=false +DISABLE_EMAIL_VERIFICATION=true +DISABLE_2_FACTOR_AUTHENTICATION=true +DISABLE_ANTI_DOS=true +DISABLE_ANTI_SPAM=true +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_LOGIN_ATTEMPTS=5 +RATE_LIMIT_VERIFICATION_REQUESTS=5 +ENABLE_HTTPS=false +HTTPS_PORT=443 +HTTPS_KEY=domain.key +HTTPS_CERT=domain.crt diff --git a/.gitignore b/.gitignore index 6769c0e..123b98f 100644 --- a/.gitignore +++ b/.gitignore @@ -136,4 +136,7 @@ logs/ *.log # token -tokens/ \ No newline at end of file +tokens/ + +# certs +certs/ \ No newline at end of file diff --git a/README.md b/README.md index e69de29..d8b8a90 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,38 @@ +# HSP-GDH (API) + +Short description of your project. + +## Table of Contents + +- [HSP-GDH (API)](#hsp-gdh-api) + - [Table of Contents](#table-of-contents) + - [Installation](#installation) + - [Usage](#usage) + +## Installation + +1. Install [bun](https://bun.sh/). +2. Clone the repository. +3. Install the dependencies by running the following command: + ```bash + bun install + ``` +4. Copy .env.sample as .env + ```bash + cp .env.sample .env + ``` +5. (Optional) Setup SSL/TLS certs + 1. Create Certificate + ```bash + openssl req -newkey rsa:2048 -nodes -keyout certs/domain.key -x509 -days 365 -out certs/domain.crt -subj "/C=FR/L=Paris/O=HSP-GDH/CN=localhost" + ``` + 2. Enable HTTPS in .env + + +## Usage + +1. Start the application by running the following command: + ```bash + bun index.js + ``` +2. Open your browser and navigate to `http(s)://localhost:{port}`. \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 6bed908..b68c395 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/database.json b/database.json new file mode 100644 index 0000000..80da010 --- /dev/null +++ b/database.json @@ -0,0 +1,980 @@ +{ + "tables": [ + { + "name": "roles", + "columns": [ + { + "name": "id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL", + "AUTO_INCREMENT" + ], + "primary_key": true + }, + { + "name": "name", + "type": "VARCHAR(255)", + "constraints": [ + "NOT NULL", + "UNIQUE" + ] + }, + { + "name": "user_bitfield", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "role_bitfield", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "verification_code_bitfield", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "ban_bitfield", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "patient_bitfield", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "doctor_bitfield", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "service_bitfield", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "company_bitfield", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "hospital_bitfield", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "room_bitfield", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "appointment_bitfield", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + } + ], + "data": [ + { + "name": "Admin", + "user_bitfield": 7, + "role_bitfield": 7, + "verification_code_bitfield": 7, + "ban_bitfield": 7, + "patient_bitfield": 7, + "doctor_bitfield": 7, + "service_bitfield": 7, + "company_bitfield": 7, + "hospital_bitfield": 7, + "room_bitfield": 7, + "appointment_bitfield": 7 + }, + { + "name": "Doctor", + "user_bitfield": 0, + "role_bitfield": 0, + "verification_code_bitfield": 0, + "ban_bitfield": 0, + "patient_bitfield": 1, + "doctor_bitfield": 1, + "service_bitfield": 1, + "company_bitfield": 1, + "hospital_bitfield": 1, + "room_bitfield": 1, + "appointment_bitfield": 0 + }, + { + "name": "Patient", + "user_bitfield": 0, + "role_bitfield": 0, + "verification_code_bitfield": 0, + "ban_bitfield": 0, + "patient_bitfield": 0, + "doctor_bitfield": 1, + "service_bitfield": 1, + "company_bitfield": 1, + "hospital_bitfield": 1, + "room_bitfield": 1, + "appointment_bitfield": 0 + } + ] + }, + { + "name": "users", + "columns": [ + { + "name": "id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL", + "AUTO_INCREMENT" + ], + "primary_key": true + }, + { + "name": "first_name", + "type": "VARCHAR(64)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "last_name", + "type": "VARCHAR(64)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "username", + "type": "VARCHAR(64)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "password", + "type": "VARCHAR(255)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "email", + "type": "VARCHAR(128)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "email_verified", + "type": "BOOLEAN", + "constraints": [ + "NOT NULL", + "DEFAULT FALSE" + ] + }, + { + "name": "phone", + "type": "VARCHAR(32)", + "constraints": [ + "DEFAULT 'None'" + ] + }, + { + "name": "phone_verified", + "type": "BOOLEAN", + "constraints": [ + "NOT NULL", + "DEFAULT FALSE" + ] + } + ] + }, + { + "name": "user_roles", + "columns": [ + { + "name": "user_id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "role_id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + } + ], + "constraints": [ + { + "primary_key": true, + "columns": ["user_id", "role_id"] + }, + { + "foreign_key": true, + "name": "user_roles_user_id", + "column": "user_id", + "reference": "users(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "foreign_key": true, + "name": "user_roles_role_id", + "column": "role_id", + "reference": "roles(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "index": true, + "name": "user_roles_user_idx", + "columns": ["user_id"] + }, + { + "index": true, + "name": "user_roles_role_idx", + "columns": ["role_id"] + } + ] + }, + { + "name": "verification_codes", + "columns": [ + { + "name": "id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL", + "AUTO_INCREMENT" + ], + "primary_key": true + }, + { + "name": "user_id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "verification_code", + "type": "VARCHAR(255)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "type", + "type": "VARCHAR(32)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "created_at", + "type": "TIMESTAMP", + "constraints": [ + "NOT NULL", + "DEFAULT CURRENT_TIMESTAMP" + ] + } + ], + "constraints": [ + { + "foreign_key": true, + "name": "verification_codes_user_id", + "column": "user_id", + "reference": "users(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "index": true, + "name": "verification_codes_user_idx", + "columns": ["user_id"] + } + ] + }, + { + "name": "bans", + "columns": [ + { + "name": "id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL", + "AUTO_INCREMENT" + ], + "primary_key": true + }, + { + "name": "user_id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "reason", + "type": "TEXT", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "created_at", + "type": "TIMESTAMP", + "constraints": [ + "NOT NULL", + "DEFAULT CURRENT_TIMESTAMP" + ] + } + ], + "constraints": [ + { + "foreign_key": true, + "name": "bans_user_id", + "column": "user_id", + "reference": "users(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "index": true, + "name": "bans_user_idx", + "columns": ["user_id"] + } + ] + }, + { + "name": "doctors", + "columns": [ + { + "name": "id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL", + "AUTO_INCREMENT" + ], + "primary_key": true + }, + { + "name": "user_id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL", + "UNIQUE" + ] + }, + { + "name": "email", + "type": "VARCHAR(255)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "phone", + "type": "VARCHAR(20)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "specialty", + "type": "VARCHAR(255)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "status", + "type": "ENUM('Available', 'Absent', 'Unavailable')", + "constraints": [ + "NOT NULL", + "DEFAULT 'Available'" + ] + }, + { + "name": "is_verified", + "type": "BOOLEAN", + "constraints": [ + "NOT NULL", + "DEFAULT FALSE" + ] + } + ], + "constraints": [ + { + "foreign_key": true, + "name": "doctors_user_id", + "column": "user_id", + "reference": "users(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "index": true, + "name": "doctors_user_idx", + "columns": ["user_id"] + } + ] + }, + { + "name": "patients", + "columns": [ + { + "name": "id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL", + "AUTO_INCREMENT" + ], + "primary_key": true + }, + { + "name": "user_id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL", + "UNIQUE" + ] + }, + { + "name": "date_of_birth", + "type": "DATE", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "gender", + "type": "ENUM('M', 'F', 'O')", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "address", + "type": "VARCHAR(255)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "social_security_number", + "type": "VARCHAR(128)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "insurance_number", + "type": "VARCHAR(128)", + "constraints": [ + "NOT NULL" + ] + } + ], + "constraints": [ + { + "foreign_key": true, + "name": "patients_user_id", + "column": "user_id", + "reference": "users(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "index": true, + "name": "patients_user_idx", + "columns": ["user_id"] + } + ] + }, + { + "name": "services", + "columns": [ + { + "name": "id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL", + "AUTO_INCREMENT" + ], + "primary_key": true + }, + { + "name": "name", + "type": "VARCHAR(255)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "description", + "type": "TEXT", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "price", + "type": "DECIMAL(10, 2)", + "constraints": [ + "NOT NULL" + ] + } + ] + }, + { + "name": "service_doctors", + "columns": [ + { + "name": "service_id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "doctor_id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + } + ], + "constraints": [ + { + "primary_key": true, + "columns": ["service_id", "doctor_id"] + }, + { + "foreign_key": true, + "name": "service_doctors_service_id", + "column": "service_id", + "reference": "services(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "foreign_key": true, + "name": "service_doctors_doctor_id", + "column": "doctor_id", + "reference": "doctors(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "index": true, + "name": "service_doctors_service_idx", + "columns": ["service_id"] + }, + { + "index": true, + "name": "service_doctors_doctor_idx", + "columns": ["doctor_id"] + } + ] + }, + { + "name": "companies", + "columns": [ + { + "name": "id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL", + "AUTO_INCREMENT" + ], + "primary_key": true + }, + { + "name": "name", + "type": "VARCHAR(255)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "code", + "type": "VARCHAR(2)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "logo", + "type": "VARCHAR(255)" + } + ] + }, + { + "name": "hospitals", + "columns": [ + { + "name": "id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL", + "AUTO_INCREMENT" + ], + "primary_key": true + }, + { + "name": "company_id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "name", + "type": "VARCHAR(255)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "code", + "type": "VARCHAR(3)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "country", + "type": "VARCHAR(255)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "region", + "type": "VARCHAR(255)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "city", + "type": "VARCHAR(255)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "address", + "type": "VARCHAR(255)", + "constraints": [ + "NOT NULL" + ] + } + ], + "constraints": [ + { + "foreign_key": true, + "name": "hospitals_company_id", + "column": "company_id", + "reference": "companies(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "index": true, + "name": "hospitals_company_idx", + "columns": ["company_id"] + } + ] + }, + { + "name": "rooms", + "columns": [ + { + "name": "id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL", + "AUTO_INCREMENT" + ], + "primary_key": true + }, + { + "name": "hospital_id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "name", + "type": "VARCHAR(255)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "code", + "type": "VARCHAR(3)", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "floor", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "room_number", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "room_type", + "type": "ENUM('General Ward', 'Private', 'Intensive Care Unit', 'Labor and Delivery', 'Operating', 'Recovery', 'Isolation', 'Emergency', 'Imaging', 'Procedure', 'Physical Therapy', 'Consultation')", + "constraints": [ + "NOT NULL" + ] + } + ], + "constraints": [ + { + "foreign_key": true, + "name": "rooms_hospital_id", + "column": "hospital_id", + "reference": "hospitals(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "index": true, + "name": "rooms_hospital_idx", + "columns": ["hospital_id"] + } + ] + }, + { + "name": "hospital_doctors", + "columns": [ + { + "name": "hospital_id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "doctor_id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + } + ], + "constraints": [ + { + "primary_key": true, + "columns": ["hospital_id", "doctor_id"] + }, + { + "foreign_key": true, + "name": "hospital_doctors_hospital_id", + "column": "hospital_id", + "reference": "hospitals(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "foreign_key": true, + "name": "hospital_doctors_doctor_id", + "column": "doctor_id", + "reference": "doctors(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "index": true, + "name": "hospital_doctors_hospital_idx", + "columns": ["hospital_id"] + }, + { + "index": true, + "name": "hospital_doctors_doctor_idx", + "columns": ["doctor_id"] + } + ] + }, + { + "name": "appointments", + "columns": [ + { + "name": "id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL", + "AUTO_INCREMENT" + ], + "primary_key": true + }, + { + "name": "patient_id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "doctor_id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "service_id", + "type": "INT UNSIGNED", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "hospital_id", + "type": "INT UNSIGNED", + "constraints": [ + "DEFAULT NULL" + ] + }, + { + "name": "room_id", + "type": "INT UNSIGNED", + "constraints": [ + "DEFAULT NULL" + ] + }, + { + "name": "date", + "type": "DATE", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "time", + "type": "TIME", + "constraints": [ + "NOT NULL" + ] + }, + { + "name": "status", + "type": "ENUM('Confirmed', 'Completed', 'Absent', 'Cancelled by Patient', 'Cancelled by Doctor')", + "constraints": [ + "NOT NULL" + ] + } + ], + "constraints": [ + { + "foreign_key": true, + "name": "appointments_patient_id", + "column": "patient_id", + "reference": "patients(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "foreign_key": true, + "name": "appointments_doctor_id", + "column": "doctor_id", + "reference": "doctors(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "foreign_key": true, + "name": "appointments_service_id", + "column": "service_id", + "reference": "services(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "foreign_key": true, + "name": "appointments_hospital_id", + "column": "hospital_id", + "reference": "hospitals(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "foreign_key": true, + "name": "appointments_room_id", + "column": "room_id", + "reference": "rooms(id)", + "on_delete": "RESTRICT", + "on_update": "CASCADE" + }, + { + "index": true, + "name": "appointments_patient_idx", + "columns": ["patient_id"] + }, + { + "index": true, + "name": "appointments_doctor_idx", + "columns": ["doctor_id"] + }, + { + "index": true, + "name": "appointments_service_idx", + "columns": ["service_id"] + }, + { + "index": true, + "name": "appointments_hospital_idx", + "columns": ["hospital_id"] + }, + { + "index": true, + "name": "appointments_room_idx", + "columns": ["room_id"] + } + ] + } + ] +} \ No newline at end of file diff --git a/database.sql b/database.sql index 117e1e4..e6ea7d2 100644 --- a/database.sql +++ b/database.sql @@ -67,7 +67,7 @@ CREATE TABLE user_roles ( CREATE TABLE verification_codes ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, - verification_code VARCHAR(255), + verification_code VARCHAR(255) NOT NULL, type VARCHAR(32) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), diff --git a/index.js b/index.js index 7aa88fe..b8082bd 100644 --- a/index.js +++ b/index.js @@ -1,17 +1,22 @@ import fs from 'fs'; import path from 'path'; import cors from 'cors'; +import https from 'https'; +import helmet from 'helmet'; import logger from 'morgan'; import express from 'express'; import cookieParser from 'cookie-parser'; -import { log } from './modules/logManager'; -import { speedLimiter, checkSystemLoad } from './modules/requestHandler'; +import { fileExist } from './modules/fileManager'; +import { log, error } from './modules/logManager'; +import { speedLimiter, requestLimiter, checkSystemLoad, respondWithStatus } from './modules/requestHandler'; import testRouter from './routes/test'; import usersRouter from './routes/users'; +import rolesRouter from './routes/roles'; import doctorsRouter from './routes/doctors'; import patientsRouter from './routes/patients'; +import servicesRouter from './routes/services'; import companiesRouter from './routes/companies'; import hospitalsRouter from './routes/hospitals'; @@ -20,33 +25,73 @@ const logsDir = path.join(import.meta.dir, 'logs'); if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir); const app = express(); -app.set('trust proxy', 1); - +if (process.env.BEHIND_PROXY == 'true') app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']); +app.use(cors({ origin: '*' })); app.use(express.json()); app.use(cookieParser()); -app.use(speedLimiter); + +// Security +app.use(helmet()); +app.disable('x-powered-by'); + +// Rate limiting and anti DoS +if (!process.env.DISABLE_ANTI_SPAM == 'true') app.use(speedLimiter); +if (!process.env.DISABLE_ANTI_DOS == 'true') app.use(requestLimiter); app.use(checkSystemLoad); + +// Logging app.use(logger('dev')); app.use(logger('combined', { stream: fs.createWriteStream(path.join(import.meta.dir, 'logs/access.log'), { flags: 'a' }) })); -app.use(cors({ - origin: '*', -})); app.use(express.static('public')); // routes app.use('/api/test', testRouter); app.use('/api/users', usersRouter); +app.use('/api/roles', rolesRouter); app.use('/api/doctors', doctorsRouter); app.use('/api/patients', patientsRouter); +app.use('/api/services', servicesRouter); app.use('/api/companies', companiesRouter); app.use('/api/hospitals', hospitalsRouter); -// run the API -app.listen(process.env.PORT, async () => { - log(`running at port ${process.env.PORT}`); + +app.use((req, res) => { + return respondWithStatus(res, 404, 'Nothing\'s here!'); }); -// test -// import { post } from './modules/fetcher'; -// post('http://127.0.0.1:1109/users/login', { 'usernameOrEmail':'foo', 'password':'bar' }).then(res => console.log(res)); \ No newline at end of file +app.use((err, req, res) => { + error(err.stack); + return respondWithStatus(res, 500, 'Internal server error'); +}); + +// run the API server +if (process.env.ENABLE_HTTPS == 'true') { + const certsDir = path.join(import.meta.dir, 'certs'); + if (!fs.existsSync(certsDir)) fs.mkdirSync(certsDir); + + if (!fileExist(path.join(certsDir, process.env.HTTPS_KEY)) || !fileExist(path.join(certsDir, process.env.HTTPS_CERT))) { + error('Missing HTTPS key or certificate'); + process.exit(1); + } + const options = { + key: fs.readFileSync(path.join(certsDir, process.env.HTTPS_KEY)), + cert: fs.readFileSync(path.join(certsDir, process.env.HTTPS_CERT)), + maxVersion: 'TLSv1.3', + minVersion: 'TLSv1.2', + ciphers: 'TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256', + ecdhCurve: 'P-521:P-384', + sigalgs: 'ecdsa_secp384r1_sha384', + honorCipherOrder: true, + }; + https.createServer(options, app).listen(process.env.HTTPS_PORT, async () => { + log('running in HTTPS mode'); + log(`running at port ${process.env.HTTPS_PORT}`); + }); +} +else { + app.listen(process.env.PORT, async () => { + log('running in HTTP mode'); + log(`running at port ${process.env.PORT}`); + }); +} \ No newline at end of file diff --git a/modules/databaseManager.js b/modules/databaseManager.js index d29e367..7ee097c 100644 --- a/modules/databaseManager.js +++ b/modules/databaseManager.js @@ -1,4 +1,6 @@ import mysql from 'mysql2'; +import dbconf from '../database.json'; +import { log } from './logManager'; const connection = mysql.createConnection({ host: process.env.DATABASE_HOST, @@ -23,4 +25,58 @@ function createPool(host, user, password, db) { return newPool; } -export { connection, pool, createPool }; +function databaseSelfTest() { + log('Database self-test'); + dbconf.tables.forEach(table => { + let query = `CREATE TABLE IF NOT EXISTS ${table.name} (`; + table.columns.forEach((column, index) => { + query += `${column.name} ${column.type}`; + if (column.primary_key) query += ' PRIMARY KEY'; + if (column.constraints && column.constraints.length > 0) query += ` ${column.constraints.join(' ')}`; + if (column.index) query += `, INDEX ${column.name}_idx (${column.name})`; + if (index < table.columns.length - 1) query += ', '; + }); + if (table.constraints && table.constraints.length > 0) { + table.constraints.forEach(constraint => { + setTimeout(() => { // do not remove or it breaks /sarcasm + if (constraint.primary_key) query += `, PRIMARY KEY (${constraint.columns.join(', ')})`; + if (constraint.foreign_key) query += `, CONSTRAINT ${constraint.name} FOREIGN KEY (${constraint.column}) REFERENCES ${constraint.reference} ON DELETE ${constraint.on_delete} ON UPDATE ${constraint.on_update}`; + if (constraint.index) query += `, INDEX ${constraint.name} (${constraint.columns.join(', ')})`; + }, 500); + }); + } + query += ') ENGINE=InnoDB;'; + pool.query(query) + .then(() => log(`Table ${table.name} validated`)) + .catch(err => console.log(`Error creating table ${table.name}: ${err}`)); + + if (table.data) { + pool.query(`SELECT * FROM ${table.name}`) + .then(([rows]) => { + if (rows.length === 0) { + table.data.forEach(row => { + let insertQuery = `INSERT INTO ${table.name} (`; + let values = 'VALUES ('; + Object.keys(row).forEach((key, index) => { + insertQuery += key; + values += `'${row[key]}'`; + if (index < Object.keys(row).length - 1) { + insertQuery += ', '; + values += ', '; + } + }); + insertQuery += ') ' + values + ');'; + pool.query(insertQuery) + .then(() => log(`Row inserted in table ${table.name}`)) + .catch(err => log(`Error inserting row in table ${table.name}: ${err}`)); + }); + } + }) + .catch(err => log(`Error checking if table ${table.name} is empty: ${err}`)); + } + }); +} + +databaseSelfTest(); + +export { connection, pool, createPool, databaseSelfTest }; diff --git a/modules/mailHandler.js b/modules/mailHandler.js index 160455d..ddc1354 100644 --- a/modules/mailHandler.js +++ b/modules/mailHandler.js @@ -3,12 +3,12 @@ import { random } from './random'; import { log } from './logManager'; const transporter = nodemailer.createTransport({ - host: process.env.SMTP, + host: process.env.MAIL_SERVER, port: 465, secure: true, auth: { - user: `${process.env.MAIL}`, - pass: `${process.env.PASS}`, + user: `${process.env.MAIL_ADDRESS}`, + pass: `${process.env.PASSWORD}`, }, tls: { rejectUnauthorized: false }, }); diff --git a/modules/permissionManager.js b/modules/permissionManager.js index 649e4fe..229c779 100644 --- a/modules/permissionManager.js +++ b/modules/permissionManager.js @@ -47,6 +47,7 @@ export async function verifyPermissions(userId, permissionName, permissionType) } export async function checkIfUserEmailIsVerified(userId) { + if(process.env.DISABLE_EMAIL_VERIFICATION) return true; try { const [user] = await pool.execute('SELECT email_verified FROM users WHERE id = ? LIMIT 1', [userId]); if (user.length === 0) return false; diff --git a/modules/requestHandler.js b/modules/requestHandler.js index bdf33a3..17ae10b 100644 --- a/modules/requestHandler.js +++ b/modules/requestHandler.js @@ -6,7 +6,7 @@ import { log } from './logManager'; const requestLimiter = rateLimit({ windowMs: 60 * 1000, - max: 5, + max: process.env.RATE_LIMIT_REQUESTS || 100, standardHeaders: true, legacyHeaders: false, message: 'Too many requests from this IP, please try again later', @@ -18,6 +18,22 @@ const speedLimiter = slowDown({ delayMs: (hits) => hits * 100, }); +const antiBruteForce = rateLimit({ + windowMs: 60 * 60 * 1000, + max: process.env.RATE_LIMIT_LOGIN_ATTEMPTS || 5, + standardHeaders: true, + legacyHeaders: false, + message: 'Too many login attempts, please try again later', +}); + +const antiVerificationSpam = rateLimit({ + windowMs: 60 * 1000, + max: process.env.RATE_LIMIT_VERIFICATION_REQUESTS || 5, + standardHeaders: true, + legacyHeaders: false, + message: 'Too many verification requests, please try again later', +}); + function checkSystemLoad(req, res, next) { const load = os.loadavg()[0]; const cores = os.cpus().length; @@ -49,6 +65,8 @@ function respondWithStatusJSON(res, statusCode, JSON) { export { requestLimiter, + antiBruteForce, + antiVerificationSpam, speedLimiter, checkSystemLoad, respondWithStatus, diff --git a/package.json b/package.json index e6af035..61cbaea 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,11 @@ "dependencies": { "cookie-parser": "^1.4.6", "cors": "^2.8.5", - "express": "^4.18.2", + "express": "^4.19.2", "express-rate-limit": "^7.1.4", "express-slow-down": "^2.0.1", + "helmet": "^7.1.0", + "https": "^1.0.0", "jsonwebtoken": "^9.0.2", "level": "^8.0.1", "morgan": "^1.10.0", diff --git a/routes/doctors.js b/routes/doctors.js index 183d8b0..8532eee 100644 --- a/routes/doctors.js +++ b/routes/doctors.js @@ -63,7 +63,7 @@ router.post('/register', verifyToken, checkEmailVerified, checkBanned, async (re if ([ email, phone, speciality, status ].every(Boolean)) { try { const [result] = await pool.execute( - 'INSERT INTO doctors (user_id, email, phone, speciality, status) VALUES (?, ?, ?, ?, ?, ?)', + 'INSERT INTO doctors (user_id, email, phone, speciality, status) VALUES (?, ?, ?, ?, ?)', [req.userId, email, phone, speciality, status], ); if (result.affectedRows === 0) return await respondWithStatus(res, 500, 'Error storing doctor'); @@ -83,10 +83,13 @@ router.post('/:doctorId/validate', verifyToken, checkBanned, checkPermissions('d const { doctor_id } = req.body; if (doctor_id) { try { - const [result] = await pool.execute('UPDATE doctors SET is_verified = 1 WHERE id = ?', [doctor_id]); - if (result.affectedRows === 0) return await respondWithStatus(res, 500, 'Error validating doctor'); - const [result2] = await pool.execute('INSERT INTO user_roles (user_id, role_id) VALUES (?, (SELECT id FROM roles WHERE name = ? LIMIT 1))', [req.userId, 'Doctor']); - if (result2.affectedRows === 0) return await respondWithStatus(res, 500, 'Error adding role to user'); + const [result] = await pool.execute('SELECT * FROM doctors WHERE id = ?',[doctor_id]); + if(result.length === 0) return await respondWithStatus(res, 404, 'Doctor not found'); + if(result[0].is_verified) return await respondWithStatus(res, 400, 'Doctor already verified'); + const [result2] = await pool.execute('UPDATE doctors SET is_verified = 1 WHERE id = ?', [doctor_id]); + if (result2.affectedRows === 0) return await respondWithStatus(res, 500, 'Error validating doctor'); + const [result3] = await pool.execute('INSERT INTO user_roles (user_id, role_id) VALUES (?, (SELECT id FROM roles WHERE name = ? LIMIT 1))', [result[0].user_id, 'Doctor']); + if (result3.affectedRows === 0) return await respondWithStatus(res, 500, 'Error adding role to user'); return await respondWithStatus(res, 200, 'Doctor validated successfully'); } catch (err) { @@ -102,6 +105,10 @@ router.post('/:doctorId/validate', verifyToken, checkBanned, checkPermissions('d router.get('/:doctorId', verifyToken, checkBanned, async (req, res) => { try { const doctorId = await getDoctorId (req.userId); + if (req.params.doctorId == '@me') { + if (!doctorId) return await respondWithStatus(res, 404, 'Doctor not found'); + req.params.doctorId = doctorId; + } if (doctorId != req.params.doctorId && !verifyPermissions(req.userId, 'doctor', 2)) return await respondWithStatus(res, 403, 'Missing permission'); const [rows] = await pool.execute('SELECT * FROM doctors WHERE id = ? LIMIT 1', [req.params.doctorId]); if (rows.length === 0) return await respondWithStatus(res, 404, 'Doctor not found'); @@ -117,6 +124,10 @@ router.patch('/:doctorId', verifyToken, checkBanned, async (req, res) => { try { const { type, value } = req.body; const doctorId = await getDoctorId (req.userId); + if (req.params.doctorId == '@me') { + if (!doctorId) return await respondWithStatus(res, 404, 'Doctor not found'); + req.params.doctorId = doctorId; + } if (doctorId != req.params.doctorId && !verifyPermissions(req.userId, 'doctor', 2)) return await respondWithStatus(res, 403, 'Missing permission'); const [rows] = await pool.execute('SELECT * FROM doctors WHERE id = ? LIMIT 1', [req.params.doctorId]); if (rows.length === 0) return await respondWithStatus(res, 404, 'Doctor not found'); @@ -143,6 +154,10 @@ router.put('/:doctorId', verifyToken, checkBanned, async (req, res) => { if ([ user_id, email, phone, speciality, status, is_verified ].every(Boolean)) { try { const doctorId = await getDoctorId (req.userId); + if (req.params.doctorId == '@me') { + if (!doctorId) return await respondWithStatus(res, 404, 'Doctor not found'); + req.params.doctorId = doctorId; + } if (doctorId != req.params.doctorId && !verifyPermissions(req.userId, 'doctor', 2)) return await respondWithStatus(res, 403, 'Missing permission'); const [rows] = await pool.execute('SELECT * FROM doctors WHERE id = ? LIMIT 1', [req.params.doctorId]); if (rows.length === 0) return await respondWithStatus(res, 404, 'Doctor not found'); @@ -168,6 +183,10 @@ router.put('/:doctorId', verifyToken, checkBanned, async (req, res) => { router.delete('/:doctorId', verifyToken, checkBanned, async (req, res) => { try { const doctorId = await getDoctorId (req.userId); + if (req.params.doctorId == '@me') { + if (!doctorId) return await respondWithStatus(res, 404, 'Doctor not found'); + req.params.doctorId = doctorId; + } if (doctorId != req.params.doctorId && !verifyPermissions(req.userId, 'doctor', 4)) return await respondWithStatus(res, 403, 'Missing permission'); const [rows] = await pool.execute('SELECT * FROM doctors WHERE id = ? LIMIT 1', [req.params.doctorId]); if (rows.length === 0) return await respondWithStatus(res, 404, 'Doctor not found'); @@ -186,6 +205,10 @@ router.delete('/:doctorId', verifyToken, checkBanned, async (req, res) => { router.get('/:doctorId/appointments', verifyToken, checkBanned, async (req, res) => { try { const doctorId = await getDoctorId (req.userId); + if (req.params.doctorId == '@me') { + if (!doctorId) return await respondWithStatus(res, 404, 'Doctor not found'); + req.params.doctorId = doctorId; + } if (doctorId != req.params.doctorId && !verifyPermissions(req.userId, 'appointment', 1)) return await respondWithStatus(res, 403, 'Missing permission'); const [rows] = await pool.execute( 'SELECT a.*, u.first_name, u.last_name, p.gender, p.date_of_birth, s.service_name, a.service_id FROM appointments AS a JOIN patients AS p ON a.patient_id = p.id JOIN users AS u ON p.user_id = u.id JOIN services AS s ON a.service_id = s.id WHERE a.doctor_id = ?', @@ -204,6 +227,10 @@ router.post('/:doctorId/appointments', verifyToken, checkBanned, async (req, res const { patient_id, service_id, hospital_id, room_id = null, date, time, status } = req.body; if (!['Confirmed', 'Completed', 'Absent', 'Cancelled by Patient', 'Cancelled by Doctor'].includes(status)) return await respondWithStatus(res, 400, 'Invalid status'); const doctorId = await getDoctorId (req.userId); + if (req.params.doctorId == '@me') { + if (!doctorId) return await respondWithStatus(res, 404, 'Doctor not found'); + req.params.doctorId = doctorId; + } if (doctorId != req.params.doctorId && !verifyPermissions(req.userId, 'appointment', 2)) return await respondWithStatus(res, 403, 'Missing permission'); if ([patient_id, service_id, hospital_id, date, time, status].every(Boolean)) { try { @@ -227,6 +254,10 @@ router.post('/:doctorId/appointments', verifyToken, checkBanned, async (req, res router.get('/:doctorId/appointments/:appointmentId', verifyToken, checkBanned, async (req, res) => { try { const doctorId = await getDoctorId (req.userId); + if (req.params.doctorId == '@me') { + if (!doctorId) return await respondWithStatus(res, 404, 'Doctor not found'); + req.params.doctorId = doctorId; + } if (doctorId != req.params.doctorId && !verifyPermissions(req.userId, 'appointment', 1)) return await respondWithStatus(res, 403, 'Missing permission'); const [rows] = await pool.execute( 'SELECT a.*, u.first_name, u.last_name, p.gender, p.date_of_birth, s.service_name, a.service_id FROM appointments AS a JOIN patients AS p ON a.patient_id = p.id JOIN users AS u ON p.user_id = u.id WHERE a.id = ? AND a.doctor_id = ? LIMIT 1', @@ -245,6 +276,10 @@ router.patch('/:doctorId/appointments/:appointmentId', verifyToken, checkBanned, try { const { type, value } = req.body; const doctorId = await getDoctorId (req.userId); + if (req.params.doctorId == '@me') { + if (!doctorId) return await respondWithStatus(res, 404, 'Doctor not found'); + req.params.doctorId = doctorId; + } if (doctorId != req.params.doctorId && !verifyPermissions(req.userId, 'appointment', 2)) return await respondWithStatus(res, 403, 'Missing permission'); const [rows] = await pool.execute('SELECT * FROM appointments WHERE id = ? LIMIT 1', [req.params.appointmentId]); if (rows.length === 0) return await respondWithStatus(res, 404, 'Appointment not found'); @@ -271,6 +306,10 @@ router.put('/:doctorId/appointments/:appointmentId', verifyToken, checkBanned, a const { patient_id, service_id, hospital_id, room_id, date, time, status } = req.body; if (!['Confirmed', 'Completed', 'Absent', 'Cancelled by Patient', 'Cancelled by Doctor'].includes(status)) return await respondWithStatus(res, 400, 'Invalid status'); const doctorId = await getDoctorId (req.userId); + if (req.params.doctorId == '@me') { + if (!doctorId) return await respondWithStatus(res, 404, 'Doctor not found'); + req.params.doctorId = doctorId; + } if (doctorId != req.params.doctorId && !verifyPermissions(req.userId, 'appointment', 2)) return await respondWithStatus(res, 403, 'Missing permission'); if ([patient_id, service_id, hospital_id, room_id, date, time, status].every(Boolean)) { try { @@ -297,6 +336,10 @@ router.put('/:doctorId/appointments/:appointmentId', verifyToken, checkBanned, a router.delete('/:doctorId/appointments/:appointmentId', verifyToken, checkBanned, async (req, res) => { try { const doctorId = await getDoctorId (req.userId); + if (req.params.doctorId == '@me') { + if (!doctorId) return await respondWithStatus(res, 404, 'Doctor not found'); + req.params.doctorId = doctorId; + } if (doctorId != req.params.doctorId && !verifyPermissions(req.userId, 'appointment', 4)) return await respondWithStatus(res, 403, 'Missing permission'); const [rows] = await pool.execute('SELECT * FROM appointments WHERE id = ? LIMIT 1', [req.params.appointmentId]); if (rows.length === 0) return await respondWithStatus(res, 404, 'Appointment not found'); @@ -315,6 +358,10 @@ router.delete('/:doctorId/appointments/:appointmentId', verifyToken, checkBanned router.get('/:doctorId/services', verifyToken, checkBanned, async (req, res) => { try { const doctorId = await getDoctorId (req.userId); + if (req.params.doctorId == '@me') { + if (!doctorId) return await respondWithStatus(res, 404, 'Doctor not found'); + req.params.doctorId = doctorId; + } if (doctorId != req.params.doctorId && !verifyPermissions(req.userId, 'service', 1)) return await respondWithStatus(res, 403, 'Missing permission'); const [rows] = await pool.execute('SELECT s.* FROM services s INNER JOIN service_doctors sd ON s.id = sd.service_id WHERE sd.doctor_id = ?', [req.params.doctorId]); if (rows.length === 0) return await respondWithStatus(res, 404, 'Services not found'); @@ -329,6 +376,10 @@ router.get('/:doctorId/services', verifyToken, checkBanned, async (req, res) => router.post('/:doctorId/services', verifyToken, checkBanned, async (req, res) => { const { service_id } = req.body; const doctorId = await getDoctorId (req.userId); + if (req.params.doctorId == '@me') { + if (!doctorId) return await respondWithStatus(res, 404, 'Doctor not found'); + req.params.doctorId = doctorId; + } if (doctorId != req.params.doctorId && !verifyPermissions(req.userId, 'service', 2)) return await respondWithStatus(res, 403, 'Missing permission'); if (service_id) { try { @@ -349,6 +400,10 @@ router.post('/:doctorId/services', verifyToken, checkBanned, async (req, res) => router.patch('/:doctorId/services/:serviceId', verifyToken, checkBanned, async (req, res) => { const { type, value } = req.body; const doctorId = await getDoctorId (req.userId); + if (req.params.doctorId == '@me') { + if (!doctorId) return await respondWithStatus(res, 404, 'Doctor not found'); + req.params.doctorId = doctorId; + } if (doctorId != req.params.doctorId && !verifyPermissions(req.userId, 'service', 2)) return await respondWithStatus(res, 403, 'Missing permission'); if (type === 'service_id') { try { @@ -368,6 +423,10 @@ router.patch('/:doctorId/services/:serviceId', verifyToken, checkBanned, async ( router.delete('/:doctorId/services/:serviceId', verifyToken, checkBanned, async (req, res) => { const doctorId = await getDoctorId (req.userId); + if (req.params.doctorId == '@me') { + if (!doctorId) return await respondWithStatus(res, 404, 'Doctor not found'); + req.params.doctorId = doctorId; + } if (doctorId != req.params.doctorId && !verifyPermissions(req.userId, 'service', 4)) return await respondWithStatus(res, 403, 'Missing permission'); try { const [result] = await pool.execute('DELETE FROM service_doctors WHERE doctor_id = ? AND service_id = ?', [req.params.doctorId, req.params.serviceId]); @@ -380,6 +439,25 @@ router.delete('/:doctorId/services/:serviceId', verifyToken, checkBanned, async } }); +router.get('/:doctorId/hospitals', verifyToken, checkBanned, async (req,res) => { + const doctorId = await getDoctorId(req.userId); + if (req.params.doctorId == '@me') { + if (!doctorId) return await respondWithStatus(res, 404, 'Doctor not found'); + req.params.doctorId = doctorId; + } + if (doctorId != req.params.doctorId && !verifyPermissions(req.userId, 'service', 4)) return await respondWithStatus(res, 403, 'Missing permission'); + try { + //'SELECT s.* FROM services s INNER JOIN service_doctors sd ON s.id = sd.service_id WHERE sd.doctor_id = ?', [req.params.doctorId] + const [rows] = await pool.execute('SELECT h.* FROM hospitals h INNER JOIN hospital_doctors hd ON h.id = hd.hospital_id WHERE hd.doctor_id = ?', [req.params.doctorId]); + if (rows.length === 0) return await respondWithStatus(res, 404, 'Hospitals not found'); + return await respondWithStatusJSON(res, 200, rows); + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } +}) + export default router; diff --git a/routes/hospitals.js b/routes/hospitals.js index ed72538..eb65eee 100644 --- a/routes/hospitals.js +++ b/routes/hospitals.js @@ -85,7 +85,7 @@ router.put('/:hospitalId', verifyToken, checkBanned, checkPermissions('hospital' return await respondWithStatus(res, 404, 'Hospital not found'); } const [result] = await pool.execute( - 'UPDATE hospitals SET company_id = ?, name = ?, country = ?, region = ?, city = ?, address = ? WHERE id = ?', + 'UPDATE hospitals SET company_id = ?, name = ?, code = ?, country = ?, region = ?, city = ?, address = ? WHERE id = ?', [company_id, name, code, country, region, city, address, id], ); diff --git a/routes/patients.js b/routes/patients.js index ab44f40..7f77f0a 100644 --- a/routes/patients.js +++ b/routes/patients.js @@ -83,6 +83,10 @@ router.post('/register', verifyToken, checkEmailVerified, checkBanned, async (re router.get('/:patientId', verifyToken, checkBanned, async (req, res) => { try { const patientId = await getPatientId(req.userId); + if (req.params.patientId == '@me') { + if (!patientId) return await respondWithStatus(res, 404, 'Patient not found'); + req.params.patientId = patientId; + } if (patientId != req.params.patientId && !verifyPermissions(req.userId, 'patient', 1)) return await respondWithStatus(res, 403, 'Missing permission'); const [rows] = await pool.execute('SELECT * FROM patients WHERE id = ? LIMIT 1', [req.params.patientId]); if (rows.length === 0) return await respondWithStatus(res, 404, 'Patient not found'); @@ -98,6 +102,10 @@ router.patch('/:patientId', verifyToken, checkBanned, async (req, res) => { try { const { type, value } = req.body; const patientId = await getPatientId(req.userId); + if (req.params.patientId == '@me') { + if (!patientId) return await respondWithStatus(res, 404, 'Patient not found'); + req.params.patientId = patientId; + } if (patientId != req.params.patientId && !verifyPermissions(req.userId, 'patient', 2)) return await respondWithStatus(res, 403, 'Missing permission'); const [rows] = await pool.execute('SELECT * FROM patients WHERE id = ? LIMIT 1', [req.params.patientId]); if (rows.length === 0) return await respondWithStatus(res, 404, 'Patient not found'); @@ -124,6 +132,10 @@ router.put('/:patientId', verifyToken, checkBanned, async (req, res) => { if ([ user_id, date_of_birth, gender, address, social_security_number, insurance_number ].every(Boolean)) { try { const patientId = await getPatientId(req.userId); + if (req.params.patientId == '@me') { + if (!patientId) return await respondWithStatus(res, 404, 'Patient not found'); + req.params.patientId = patientId; + } if (patientId != req.params.patientId && !verifyPermissions(req.userId, 'patient', 2)) return await respondWithStatus(res, 403, 'Missing permission'); const [rows] = await pool.execute('SELECT * FROM patients WHERE id = ? LIMIT 1', [req.params.patientId]); if (rows.length === 0) return await respondWithStatus(res, 404, 'Patient not found'); @@ -149,6 +161,10 @@ router.put('/:patientId', verifyToken, checkBanned, async (req, res) => { router.delete('/:patientId', verifyToken, checkBanned, async (req, res) => { try { const patientId = await getPatientId(req.userId); + if (req.params.patientId == '@me') { + if (!patientId) return await respondWithStatus(res, 404, 'Patient not found'); + req.params.patientId = patientId; + } if (patientId != req.params.patientId && !verifyPermissions(req.userId, 'patient', 4)) return await respondWithStatus(res, 403, 'Missing permission'); const [rows] = await pool.execute('SELECT * FROM patients WHERE id = ? LIMIT 1', [req.params.patientId]); if (rows.length === 0) return await respondWithStatus(res, 404, 'Patient not found'); @@ -167,6 +183,10 @@ router.delete('/:patientId', verifyToken, checkBanned, async (req, res) => { router.get('/:patientId/appointments', verifyToken, checkBanned, async (req, res) => { try { const patientId = await getPatientId(req.userId); + if (req.params.patientId == '@me') { + if (!patientId) return await respondWithStatus(res, 404, 'Patient not found'); + req.params.patientId = patientId; + } if (patientId != req.params.patientId && !verifyPermissions(req.userId, 'appointment', 1)) return await respondWithStatus(res, 403, 'Missing permission'); const [rows] = await pool.execute( 'SELECT a.id, u.first_name, u.last_name, d.email, d.phone, h.name, h.address, a.date, a.time, a.status, s.name FROM appointments a JOIN doctors d ON a.doctor_id = d.id JOIN users u ON d.user_id = u.id JOIN hospitals h ON a.hospital_id = h.id JOIN services s ON a.service_id = s.id WHERE a.patient_id = ?', @@ -186,6 +206,10 @@ router.post('/:patientId/appointments', verifyToken, checkBanned, async (req, re if ([ doctor_id, service_id, hospital_id, date, time ].every(Boolean)) { try { const patientId = await getPatientId(req.userId); + if (req.params.patientId == '@me') { + if (!patientId) return await respondWithStatus(res, 404, 'Patient not found'); + req.params.patientId = patientId; + } if (patientId != req.params.patientId && !verifyPermissions(req.userId, 'appointment', 2)) return await respondWithStatus(res, 403, 'Missing permission'); const [result] = await pool.execute( 'INSERT INTO appointments (doctor_id, service_id, hospital_id, patient_id, date, time) VALUES (?, ?, ?, ?, ?, ?)', @@ -203,10 +227,54 @@ router.post('/:patientId/appointments', verifyToken, checkBanned, async (req, re return await respondWithStatus(res, 400, 'Missing fields'); } }); + // GET /:patientId/appointments/:appointmentId +router.get('/:patientId/appointments/:appointmentId', verifyToken, checkBanned, async (req, res) => { + try { + const patientId = await getPatientId(req.userId); + if (req.params.patientId == '@me') { + if (!patientId) return await respondWithStatus(res, 404, 'Patient not found'); + req.params.patientId = patientId; + } + if (patientId != req.params.patientId && !verifyPermissions(req.userId, 'appointment', 1)) return await respondWithStatus(res, 403, 'Missing permission'); + const [rows] = await pool.execute( + 'SELECT a.id, u.first_name, u.last_name, d.email, d.phone, h.name, h.address, a.date, a.time, a.status, s.name FROM appointments a JOIN doctors d ON a.doctor_id = d.id JOIN users u ON d.user_id = u.id JOIN hospitals h ON a.hospital_id = h.id JOIN services s ON a.service_id = s.id WHERE a.id = ? AND a.patient_id = ?', + [req.params.appointmentId, req.params.patientId], + ); + if (rows.length === 0) return await respondWithStatus(res, 404, 'Appointment not found'); + return await respondWithStatusJSON(res, 200, rows[0]); + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } +}); + // PATCH /:patientId/appointments/:appointmentId + // PUT /:patientId/appointments/:appointmentId + // DELETE /:patientId/appointments/:appointmentId +router.delete('/:patientId/appointments/:appointmentId', verifyToken, checkBanned, async (req, res) => { + try { + const patientId = await getPatientId(req.userId); + if (req.params.patientId == '@me') { + if (!patientId) return await respondWithStatus(res, 404, 'Patient not found'); + req.params.patientId = patientId; + } + if (patientId != req.params.patientId && !verifyPermissions(req.userId, 'appointment', 4)) return await respondWithStatus(res, 403, 'Missing permission'); + const [rows] = await pool.execute('SELECT * FROM appointments WHERE id = ? AND patient_id = ? LIMIT 1', [req.params.appointmentId, req.params.patientId]); + if (rows.length === 0) return await respondWithStatus(res, 404, 'Appointment not found'); + + const [result] = await pool.execute('DELETE FROM appointments WHERE id = ?', [req.params.appointmentId]); + if (result.affectedRows === 0) return await respondWithStatus(res, 500, 'Error removing appointment'); + return await respondWithStatus(res, 200, 'Appointment deleted successfully'); + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } +}); export default router; diff --git a/routes/roles.js b/routes/roles.js new file mode 100644 index 0000000..2df91a4 --- /dev/null +++ b/routes/roles.js @@ -0,0 +1,92 @@ +import express from 'express'; +import { error } from '../modules/logManager'; +import { pool } from '../modules/databaseManager'; +import { verifyToken } from '../modules/tokenManager'; +import { respondWithStatus, respondWithStatusJSON } from '../modules/requestHandler'; +import { checkBanned, checkPermissions } from '../modules/permissionManager'; + +const router = express.Router(); + + +// GET role list +router.get('/', verifyToken, checkBanned, checkPermissions('role', 1), async (req, res) => { + try { + const rows = await pool.execute('SELECT * FROM roles'); + if (rows[0].length === 0) return await respondWithStatus(res, 404, 'No roles found'); + return await respondWithStatusJSON(res, rows[0]); + + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } +}); + +// POST create role +router.post('/', verifyToken, checkBanned, checkPermissions('role', 2), async (req, res) => { + const { name, user_bitfield, role_bitfield, verification_code_bitfield, ban_bitfield, patient_bitfield, doctor_bitfield, service_bitfield, company_bitfield, hospital_bitfield, room_bitfield, appointment_bitfield } = req.body; + if ([ name, user_bitfield, role_bitfield, verification_code_bitfield, ban_bitfield, patient_bitfield, doctor_bitfield, service_bitfield, company_bitfield, hospital_bitfield, room_bitfield, appointment_bitfield ].every(Boolean)) { + try { + await pool.execute( + 'INSERT INTO users (name, user_bitfield, role_bitfield, verification_code_bitfield, ban_bitfield, patient_bitfield, doctor_bitfield, service_bitfield, company_bitfield, hospital_bitfield, room_bitfield, appointment_bitfield) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ name, user_bitfield, role_bitfield, verification_code_bitfield, ban_bitfield, patient_bitfield, doctor_bitfield, service_bitfield, company_bitfield, hospital_bitfield, room_bitfield, appointment_bitfield ], + ); + return await respondWithStatus(res, 200, 'Role created successfully'); + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } + } + else { + return await respondWithStatus(res, 400, 'Missing fields'); + } +}); + +// GET role +router.get('/:id', verifyToken, checkBanned, checkPermissions('role', 1), async (req, res) => { + try { + const [rows] = await pool.execute('SELECT * FROM roles WHERE id = ? LIMIT 1', [ req.params.id ]); + if (rows.length === 0) return await respondWithStatus(res, 404, 'Role not found'); + return await respondWithStatusJSON(res, rows[0]); + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } +}); + +// PUT update role +router.put('/:id', verifyToken, checkBanned, checkPermissions('role', 2), async (req, res) => { + const { name, user_bitfield, role_bitfield, verification_code_bitfield, ban_bitfield, patient_bitfield, doctor_bitfield, service_bitfield, company_bitfield, hospital_bitfield, room_bitfield, appointment_bitfield } = req.body; + if ([ name, user_bitfield, role_bitfield, verification_code_bitfield, ban_bitfield, patient_bitfield, doctor_bitfield, service_bitfield, company_bitfield, hospital_bitfield, room_bitfield, appointment_bitfield ].every(Boolean)) { + try { + await pool.execute( + 'UPDATE roles SET name = ?, user_bitfield = ?, role_bitfield = ?, verification_code_bitfield = ?, ban_bitfield = ?, patient_bitfield = ?, doctor_bitfield = ?, service_bitfield = ?, company_bitfield = ?, hospital_bitfield = ?, room_bitfield = ?, appointment_bitfield = ? WHERE id = ?', + [ name, user_bitfield, role_bitfield, verification_code_bitfield, ban_bitfield, patient_bitfield, doctor_bitfield, service_bitfield, company_bitfield, hospital_bitfield, room_bitfield, appointment_bitfield, req.params.id ], + ); + return await respondWithStatus(res, 200, 'Role updated successfully'); + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } + } + else { + return await respondWithStatus(res, 400, 'Missing fields'); + } +}); + +// DELETE role +router.delete('/:id', verifyToken, checkBanned, checkPermissions('role', 4), async (req, res) => { + try { + await pool.execute('DELETE FROM roles WHERE id = ?', [ req.params.id ]); + return await respondWithStatus(res, 200, 'Role deleted successfully'); + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } +}); + +export default router; \ No newline at end of file diff --git a/routes/services.js b/routes/services.js new file mode 100644 index 0000000..690281a --- /dev/null +++ b/routes/services.js @@ -0,0 +1,122 @@ +import express from 'express'; +import { error } from '../modules/logManager'; +import { pool } from '../modules/databaseManager'; +import { verifyToken } from '../modules/tokenManager'; +import { checkPermissions, checkBanned } from '../modules/permissionManager'; +import { respondWithStatus, respondWithStatusJSON } from '../modules/requestHandler'; + +const router = express.Router(); + +router.get('/', verifyToken, checkBanned, checkPermissions('service', 1), async (req, res) => { + try { + const [rows] = await pool.execute('SELECT * FROM services WHERE 1'); + if (rows.length === 0) return await respondWithStatus(res, 404, 'Services not found'); + return await respondWithStatusJSON(res, 200, rows); + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } +}); + +router.post('/', verifyToken, checkBanned, checkPermissions('service', 2), async (req, res) => { + const { name, description, price } = req.body; + if ([ name, description, price ].every(Boolean)) { + try { + const [result] = await pool.execute( + 'INSERT INTO services (name, description, price) VALUES (?, ?, ?)', + [ name, description, price ], + ); + if (result.affectedRows === 0) return await respondWithStatus(res, 500, 'Error storing service'); + return await respondWithStatus(res, 200, 'Service created successfully'); + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } + } + else { + return await respondWithStatus(res, 400, 'Missing fields'); + } +}); + +router.get('/:serviceId', verifyToken, checkBanned, checkPermissions('service', 1), async (req, res) => { + try { + const [rows] = await pool.execute('SELECT * FROM services WHERE id = ? LIMIT 1', [req.params.serviceId]); + if (rows.length === 0) return await respondWithStatus(res, 404, 'Services not found'); + return await respondWithStatusJSON(res, 200, rows[0]); + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } +}); + +router.patch('/:serviceId', verifyToken, checkBanned, checkPermissions('service', 2), async (req, res) => { + try { + const { type, value } = req.body; + const [rows] = await pool.execute('SELECT * FROM services WHERE id = ? LIMIT 1', [req.params.serviceId]); + if (rows.length === 0) return await respondWithStatus(res, 404, 'Service not found'); + + const fields = rows.map(row => Object.keys(row)); + if (fields[0].includes(type)) { + const [result] = await pool.execute(`UPDATE services SET ${type} = ? WHERE id = ?`, [value, req.params.serviceId]); + if (result.affectedRows === 0) return await respondWithStatus(res, 500, 'Error updating service'); + return await respondWithStatus(res, 200, 'Service updated successfully'); + } + else { + return await respondWithStatus(res, 400, 'Invalid type'); + } + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } +}); + +router.put('/:serviceId', verifyToken, checkBanned, checkPermissions('service', 2), async (req, res) => { + const id = req.params.serviceId; + const { name, description, price } = req.body; + if ([ name, description, price ].every(Boolean)) { + try { + const [rows] = await pool.execute('SELECT * FROM services WHERE id = ? LIMIT 1', [id]); + + if (rows.length === 0) { + return await respondWithStatus(res, 404, 'Service not found'); + } + const [result] = await pool.execute( + 'UPDATE services SET name = ?, description = ?, price = ? WHERE id = ?', + [name, description, price, id], + ); + + if (result.affectedRows === 0) { + return await respondWithStatus(res, 500, 'Error updating Service'); + } + return await respondWithStatus(res, 200, 'Service updated successfully'); + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } + } + else { + return await respondWithStatus(res, 400, 'Missing fields'); + } +}); + +router.delete('/:serviceId', verifyToken, checkBanned, checkPermissions('service', 4), async (req, res) => { + try { + const [rows] = await pool.execute('SELECT * FROM services WHERE id = ? LIMIT 1', [req.params.serviceId]); + if (rows.length === 0) return await respondWithStatus(res, 404, 'service not found'); + + const [result] = await pool.execute('DELETE FROM services WHERE id = ?', [req.params.serviceId]); + if (result.affectedRows === 0) return await respondWithStatus(res, 500, 'Error removing Service'); + return await respondWithStatus(res, 200, 'Service deleted successfully'); + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } +}); + +export default router; \ No newline at end of file diff --git a/routes/users.js b/routes/users.js index b7f863e..8fe7952 100644 --- a/routes/users.js +++ b/routes/users.js @@ -4,12 +4,12 @@ import { pool } from '../modules/databaseManager'; import { sendVerification } from '../modules/mailHandler'; import { verifyToken, generateToken } from '../modules/tokenManager'; import { isEmailDomainValid, isValidEmail, isPhoneNumber } from '../modules/formatManager'; -import { requestLimiter, respondWithStatus, respondWithStatusJSON } from '../modules/requestHandler'; +import { antiBruteForce, antiVerificationSpam, respondWithStatus, respondWithStatusJSON } from '../modules/requestHandler'; import { checkBanned, checkPermissions, userExists, isBanned, verifyPermissions } from '../modules/permissionManager'; const router = express.Router(); -router.post('/register', requestLimiter, async (req, res) => { +router.post('/register', antiBruteForce, async (req, res) => { const { username, email, password, first_name, last_name, phone = null } = req.body; if ([ username, email, password, first_name, last_name ].every(Boolean)) { try { @@ -30,6 +30,8 @@ router.post('/register', requestLimiter, async (req, res) => { ); if (result.affectedRows === 0) return await respondWithStatus(res, 500, 'Error storing user'); + if (process.env.DISABLE_EMAIL_VERIFICATION == 'true') return await respondWithStatus(res, 200, 'Successfully registered'); + const [rows] = await pool.execute('SELECT id FROM users WHERE email = ? LIMIT 1', [email]); const code = sendVerification(email, rows[0].id, 'email'); pool.execute('INSERT INTO verification_codes (user_id, verification_code, type) VALUES (?, ?, ?)', [ rows[0].id, code, 'email' ]); @@ -49,7 +51,7 @@ router.post('/register', requestLimiter, async (req, res) => { } }); -router.post('/login', requestLimiter, async (req, res) => { +router.post('/login', antiBruteForce, async (req, res) => { const { usernameOrEmail, password } = req.body; if ([usernameOrEmail, password].every(Boolean)) { try { @@ -74,6 +76,7 @@ router.post('/login', requestLimiter, async (req, res) => { email: user.email, first_name: user.first_name, last_name: user.last_name, + verified_status: process.env.DISABLE_EMAIL_VERIFICATION == 'true' ? true : user.email_verified, }, }); } @@ -121,7 +124,7 @@ router.post('/', verifyToken, checkBanned, checkPermissions('user', 2), async (r }); // Email verification endpoints -router.get('/email/request', verifyToken, checkBanned, async (req, res) => { +router.get('/email/request', antiVerificationSpam, verifyToken, checkBanned, async (req, res) => { const userId = req.userId; try { const [rows] = await pool.execute('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]); @@ -138,7 +141,7 @@ router.get('/email/request', verifyToken, checkBanned, async (req, res) => { } }); -router.get('/email/verify', verifyToken, checkBanned, async (req, res) => { +router.get('/email/verify', antiVerificationSpam, verifyToken, checkBanned, async (req, res) => { const { code } = req.query; const userId = req.userId; if (code) { @@ -170,7 +173,7 @@ router.get('/email/verify', verifyToken, checkBanned, async (req, res) => { // PATCH /phone/verify // Password reset endpoints -router.post('/password/request', async (req, res) => { +router.post('/password/request', antiVerificationSpam, async (req, res) => { const { usernameOrEmail } = req.body; if (usernameOrEmail) { try { @@ -201,7 +204,7 @@ router.post('/password/request', async (req, res) => { } }); -router.patch('/password/verify', async (req, res) => { +router.patch('/password/verify', antiVerificationSpam, async (req, res) => { const { usernameOrEmail, password, code } = req.body; if ([usernameOrEmail, password, code].every(Boolean)) { try { @@ -241,6 +244,7 @@ router.patch('/password/verify', async (req, res) => { router.get('/:userId', verifyToken, checkBanned, async (req, res) => { try { + if (req.params.userId == '@me') req.params.userId = req.userId; if (req.params.userId != req.userId && !verifyPermissions(req.userId, 'user', 1)) return await respondWithStatus(res, 403, 'Missing permission'); const [rows] = await pool.execute('SELECT id, first_name, last_name, username, email, phone FROM users WHERE id = ? LIMIT 1', [req.params.userId]); if (rows.length === 0) return await respondWithStatus(res, 404, 'User not found'); @@ -257,6 +261,7 @@ router.get('/:userId', verifyToken, checkBanned, async (req, res) => { router.patch('/:userId', verifyToken, checkBanned, async (req, res) => { try { + if (req.params.userId == '@me') req.params.userId = req.userId; if (req.params.userId != req.userId && !verifyPermissions(req.userId, 'user', 2)) return await respondWithStatus(res, 403, 'Missing permission'); const { type } = req.body; let { value } = req.body; @@ -282,6 +287,7 @@ router.patch('/:userId', verifyToken, checkBanned, async (req, res) => { router.put('/:userId', verifyToken, checkBanned, async (req, res) => { try { + if (req.params.userId == '@me') req.params.userId = req.userId; if (req.params.userId != req.userId && !verifyPermissions(req.userId, 'user', 2)) return await respondWithStatus(res, 403, 'Missing permission'); const { first_name, last_name, username, password = null, email, phone = null } = req.body; if ([first_name, last_name, username, email].every(Boolean)) { @@ -310,6 +316,7 @@ router.put('/:userId', verifyToken, checkBanned, async (req, res) => { router.delete('/:userId', verifyToken, checkBanned, async (req, res) => { try { + if (req.params.userId == '@me') req.params.userId = req.userId; if (req.params.userId != req.userId && !verifyPermissions(req.userId, 'user', 4)) return await respondWithStatus(res, 403, 'Missing permission'); if (!userExists(req.params.userId)) return await respondWithStatus(res, 404, 'User not found'); const [result] = await pool.execute('DELETE FROM users WHERE id = ?', [ req.params.userId ]); @@ -322,4 +329,52 @@ router.delete('/:userId', verifyToken, checkBanned, async (req, res) => { } }); +router.get('/:userId/roles', verifyToken, checkBanned, async (req, res) => { + try { + if (req.params.userId == '@me') req.params.userId = req.userId; + if (req.params.userId != req.userId && !verifyPermissions(req.userId, 'user', 1)) return await respondWithStatus(res, 403, 'Missing permission'); + const [rows] = await pool.execute('SELECT r.* FROM users u INNER JOIN user_roles ur ON u.id = ur.user_id INNER JOIN roles r ON ur.role_id = r.id WHERE u.id = ?', [ req.params.userId ]); + if (rows.length === 0) return await respondWithStatus(res, 404, 'No roles found'); + return await respondWithStatusJSON(res, 200, rows); + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } +}); + +router.post('/:userId/roles', verifyToken, checkBanned, checkPermissions('user', 2), async (req, res) => { + const { roleId } = req.body; + if (roleId) { + try { + if (req.params.userId == '@me') req.params.userId = req.userId; + const [rows] = await pool.execute('SELECT * FROM roles WHERE id = ? LIMIT 1', [ roleId ]); + if (rows.length === 0) return await respondWithStatus(res, 404, 'Role not found'); + const [result] = await pool.execute('INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)', [ req.params.userId, roleId ]); + if (result.affectedRows === 0) return await respondWithStatus(res, 500, 'Error assigning role'); + return await respondWithStatus(res, 200, 'Role assigned successfully'); + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } + } + else { + return await respondWithStatus(res, 400, 'Missing fields'); + } +}); + +router.delete('/:userId/roles/:roleId', verifyToken, checkBanned, checkPermissions('user', 4), async (req, res) => { + try { + if (req.params.userId == '@me') req.params.userId = req.userId; + const [result] = await pool.execute('DELETE FROM user_roles WHERE user_id = ? AND role_id = ?', [ req.params.userId, req.params.roleId ]); + if (result.affectedRows === 0) return await respondWithStatus(res, 500, 'Error removing role'); + return await respondWithStatus(res, 200, 'Role removed successfully'); + } + catch (err) { + error(err); + return await respondWithStatus(res, 500, 'An error has occured'); + } +}); + export default router; \ No newline at end of file