This commit is contained in:
SowinskiBraeden committed 2026-10-18 21:27:59 -07:00
1 parent b8a795060b
commit 824e10191b
16 files changed
+1171

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
PORT=8080
DB_HOST=localhost
DB_PORT=3306
DB_NAME=lab5
DB_SSL=false
DB_READER_USER=lab5_reader
DB_READER_PASSWORD=change-me
DB_WRITER_USER=lab5_writer
DB_WRITER_PASSWORD=change-me
+2
View File
@@ -0,0 +1,2 @@
node_modules
.env
+15
View File
@@ -0,0 +1,15 @@
export const MESSAGES = {
TABLE_READY: "Patient table is ready.",
ROWS_INSERTED: "Rows inserted successfully.",
INVALID_ENDPOINT: "Not found: unknown API endpoint.",
METHOD_NOT_ALLOWED: "Method not allowed on this endpoint.",
MISSING_QUERY: "Bad request: no SQL query was provided.",
INSERT_ONLY: "This endpoint only accepts INSERT statements.",
SINGLE_STATEMENT_ONLY: "Only one statement per request is allowed.",
LISTENING: "Server 2 listening on port ",
CONNECTED: "Connected to the database as ",
CONNECTION_FAILED: "Could not reach the database: ",
MISSING_CONFIG: "Missing database configuration, check your environment variables.",
};
+200
View File
@@ -0,0 +1,200 @@
import http from "node:http";
import { SqlStatement } from "./SqlStatement.js";
import { MESSAGES } from "../lang/messages/en/user.js";
const SQL_ROUTE = "/lab5/api/v1/sql";
export class ApiServer {
constructor(port, patients) {
this.port = port;
this.patients = patients;
this.server = http.createServer((request, response) => this.handle(request, response));
}
start() {
this.server.listen(this.port, () => {
console.log(MESSAGES.LISTENING + this.port);
});
}
setCorsHeaders(response) {
// the client is on another origin by design, so it needs to be let through
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "Content-Type");
}
sendJson(response, status, payload) {
this.setCorsHeaders(response);
response.writeHead(status, { "Content-Type": "application/json" });
response.end(JSON.stringify(payload));
}
sendError(response, status, message) {
this.sendJson(response, status, { success: false, error: message });
}
readBody(request) {
return new Promise((resolve, reject) => {
let body = "";
request.on("data", (chunk) => {
body += chunk;
});
request.on("end", () => resolve(body));
request.on("error", reject);
});
}
// the query arrives as the tail of the path, url encoded and
// sometimes wrapped in the quotes the lab sheet shows
extractSql(pathname) {
const prefix = `${SQL_ROUTE}/`;
if (!pathname.startsWith(prefix)) {
return "";
}
let sql = pathname.slice(prefix.length);
try {
sql = decodeURIComponent(sql);
} catch {
// a malformed escape stays as typed, the database will reject it
}
sql = sql.trim();
if (this.isQuoted(sql)) {
sql = sql.slice(1, -1).trim();
}
return sql;
}
isQuoted(text) {
if (text.length < 2) {
return false;
}
return (text.startsWith('"') && text.endsWith('"'))
|| (text.startsWith("'") && text.endsWith("'"));
}
async handleGet(request, response, url) {
const statement = new SqlStatement(
this.extractSql(url.pathname) || url.searchParams.get("query")
);
if (statement.isEmpty()) {
return this.sendError(response, 400, MESSAGES.MISSING_QUERY);
}
if (!statement.isSingleStatement()) {
return this.sendError(response, 400, MESSAGES.SINGLE_STATEMENT_ONLY);
}
// anything that is not a read is still handed to the reader user on
// purpose, so the database privileges are what refuse it.
try {
const rows = await this.patients.select(statement);
this.sendJson(response, 200, {
success: true,
query: statement.toString(),
data: rows,
});
} catch (error) {
this.sendJson(response, 403, {
success: false,
error: error.message,
code: error.code,
});
}
}
async handlePost(request, response, url) {
if (url.pathname !== SQL_ROUTE && url.pathname !== `${SQL_ROUTE}/`) {
return this.sendError(response, 404, MESSAGES.INVALID_ENDPOINT);
}
const body = await this.readBody(request);
const statement = new SqlStatement(this.parseQuery(body));
try {
// an empty body means the insert button, which uses the fixed rows
const result = statement.isEmpty()
? await this.patients.insertSamplePatients()
: await this.insertCustom(response, statement);
if (result === null) {
return;
}
this.sendJson(response, 200, {
success: true,
message: MESSAGES.ROWS_INSERTED,
rowCount: result.affectedRows,
});
} catch (error) {
this.sendJson(response, 403, {
success: false,
error: error.message,
code: error.code,
});
}
}
// the writer user is the only one that can change data, so this route
// refuses anything that is not a single insert before it gets there.
async insertCustom(response, statement) {
if (!statement.isSingleStatement()) {
this.sendError(response, 400, MESSAGES.SINGLE_STATEMENT_ONLY);
return null;
}
if (!statement.isInsert()) {
this.sendError(response, 403, MESSAGES.INSERT_ONLY);
return null;
}
return this.patients.insert(statement);
}
parseQuery(body) {
if (!body.trim()) {
return "";
}
try {
return JSON.parse(body).query ?? "";
} catch {
return body.trim();
}
}
handle(request, response) {
if (request.method === "OPTIONS") {
this.setCorsHeaders(response);
response.writeHead(204);
return response.end();
}
const url = new URL(request.url, `http://${request.headers.host}`);
if (!url.pathname.startsWith(SQL_ROUTE)) {
return this.sendError(response, 404, MESSAGES.INVALID_ENDPOINT);
}
if (request.method === "GET") {
return this.handleGet(request, response, url);
}
if (request.method === "POST") {
return this.handlePost(request, response, url);
}
return this.sendError(response, 405, MESSAGES.METHOD_NOT_ALLOWED);
}
}
+50
View File
@@ -0,0 +1,50 @@
import mysql from "mysql2/promise";
export class Database {
constructor(config) {
this.readerPool = this.createPool(config, config.readerUser, config.readerPassword);
this.writerPool = this.createPool(config, config.writerUser, config.writerPassword);
}
createPool(config, user, password) {
return mysql.createPool({
host: config.host,
port: config.port,
database: config.database,
user: user,
password: password,
ssl: config.ssl,
waitForConnections: true,
connectionLimit: 5,
// hand back DATETIME exactly as it is stored instead of a JS Date,
// which would otherwise be shifted into UTC on the way to the client
dateStrings: true,
// chained statements never even reach the database
multipleStatements: false,
});
}
async testConnections() {
const [reader] = await this.readerPool.query("SELECT CURRENT_USER() AS user;");
const [writer] = await this.writerPool.query("SELECT CURRENT_USER() AS user;");
return {
reader: reader[0].user,
writer: writer[0].user,
};
}
// reads go through the user that only holds SELECT
async read(statement) {
const [rows] = await this.readerPool.query(statement.toString());
return rows;
}
// writes go through the user that holds SELECT, INSERT and CREATE, nothing else
async write(sql) {
const [result] = await this.writerPool.query(sql);
return result;
}
}
@@ -0,0 +1,41 @@
const SAMPLE_PATIENTS = `
INSERT INTO patient (name, dateOfBirth) VALUES
('Sara Brown', '1901-01-01'),
('John Smith', '1941-01-01'),
('Jack Ma', '1961-01-30'),
('Elon Musk', '1999-01-01');
`;
const CREATE_PATIENT_TABLE = `
CREATE TABLE IF NOT EXISTS patient (
patientid INT(11) NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
dateOfBirth DATETIME NOT NULL,
PRIMARY KEY (patientid)
) ENGINE=InnoDB;
`;
export class PatientRepository {
constructor(database) {
this.database = database;
}
// called before every insert, so a dropped table comes back on the next press
async ensureTable() {
await this.database.write(CREATE_PATIENT_TABLE);
}
async insertSamplePatients() {
await this.ensureTable();
return this.database.write(SAMPLE_PATIENTS);
}
async insert(statement) {
await this.ensureTable();
return this.database.write(statement.toString());
}
async select(statement) {
return this.database.read(statement);
}
}
+35
View File
@@ -0,0 +1,35 @@
const SELECT_PATTERN = /^select\b/i;
const INSERT_PATTERN = /^insert\b/i;
export class SqlStatement {
constructor(text) {
// a trailing semicolon is harmless, it only makes the url uglier
this.text = String(text ?? "").trim().replace(/;+$/, "").trim();
}
isEmpty() {
return this.text.length === 0;
}
isSelect() {
return SELECT_PATTERN.test(this.text);
}
isInsert() {
return INSERT_PATTERN.test(this.text);
}
// a semicolon inside a quoted literal is data, not a second statement,
// so blank the literals out before looking for one.
isSingleStatement() {
const withoutLiterals = this.text
.replace(/'(?:[^']|'')*'/g, "''")
.replace(/"(?:[^"]|"")*"/g, '""');
return !withoutLiterals.includes(";");
}
toString() {
return this.text;
}
}
+147
View File
@@ -0,0 +1,147 @@
{
"name": "comp4537-lab5-server2",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "comp4537-lab5-server2",
"version": "1.0.0",
"dependencies": {
"mysql2": "^3.11.5"
}
},
"node_modules/@types/node": {
"version": "26.6.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru.min": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz",
"integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/mysql2": {
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.6.tgz",
"integrity": "sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.3",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.5.1"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sql-escaper": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz",
"integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"license": "MIT",
"peer": true
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "comp4537-lab5-server2",
"version": "1.0.0",
"description": "COMP 4537 Lab 5 API server (origin 2)",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --env-file=.env server.js"
},
"dependencies": {
"mysql2": "^3.11.5"
}
}
+67
View File
@@ -0,0 +1,67 @@
import { ApiServer } from "./modules/ApiServer.js";
import { Database } from "./modules/Database.js";
import { PatientRepository } from "./modules/PatientRepository.js";
import { MESSAGES } from "./lang/messages/en/user.js";
const DEFAULT_PORT = 8080;
class Config {
constructor(env) {
this.port = Number(env.PORT) || DEFAULT_PORT;
this.host = env.DB_HOST;
this.dbPort = Number(env.DB_PORT) || 3306;
this.database = env.DB_NAME;
this.readerUser = env.DB_READER_USER;
this.readerPassword = env.DB_READER_PASSWORD;
this.writerUser = env.DB_WRITER_USER;
this.writerPassword = env.DB_WRITER_PASSWORD;
// most hosted databases require tls, a local container does not
this.ssl = env.DB_SSL === "true" ? { rejectUnauthorized: true } : undefined;
}
isComplete() {
return Boolean(
this.host
&& this.database
&& this.readerUser
&& this.readerPassword
&& this.writerUser
&& this.writerPassword
);
}
toDatabaseConfig() {
return {
host: this.host,
port: this.dbPort,
database: this.database,
readerUser: this.readerUser,
readerPassword: this.readerPassword,
writerUser: this.writerUser,
writerPassword: this.writerPassword,
ssl: this.ssl,
};
}
}
const config = new Config(process.env);
if (!config.isComplete()) {
console.error(MESSAGES.MISSING_CONFIG);
process.exit(1);
}
const database = new Database(config.toDatabaseConfig());
const patients = new PatientRepository(database);
const api = new ApiServer(config.port, patients);
api.start();
try {
const users = await database.testConnections();
console.log(MESSAGES.CONNECTED + `${users.reader} / ${users.writer}`);
} catch (error) {
console.error(MESSAGES.CONNECTION_FAILED + error.message);
}
+17
View File
@@ -0,0 +1,17 @@
-- Two database users, because the read route and the write route need
-- different privileges. The reader is what makes DROP, DELETE, UPDATE and
-- CREATE impossible from the query textarea.
CREATE DATABASE IF NOT EXISTS lab5 CHARACTER SET utf8mb4;
CREATE USER IF NOT EXISTS 'lab5_reader'@'%' IDENTIFIED BY 'change-me';
CREATE USER IF NOT EXISTS 'lab5_writer'@'%' IDENTIFIED BY 'change-me';
-- the reader can read and nothing else
GRANT SELECT ON lab5.* TO 'lab5_reader'@'%';
-- the writer can create the patient table and add rows to it,
-- but has no DROP, DELETE, UPDATE or ALTER
GRANT SELECT, INSERT, CREATE ON lab5.* TO 'lab5_writer'@'%';
FLUSH PRIVILEGES;