diff --git a/labs/05/README.md b/labs/05/README.md new file mode 100644 index 0000000..17f6854 --- /dev/null +++ b/labs/05/README.md @@ -0,0 +1,69 @@ +# Lab 5 - DB Intro + +Two origins, as the lab requires. + +- `server1/` - static client. Plain HTML, CSS and ES modules, no build step. +- `server2/` - Node API server. Raw `node:http`, no Express and no routing modules. + +The only dependency anywhere is `mysql2`, which the lab allows for database access. + +## Database + +Any MySQL or MariaDB instance works. Create the schema and the two users: + +```sh +mysql -h -u root -p < server2/sql/setup.sql +``` + +Change both passwords in that file before running it. + +**Two users, on purpose.** The read route connects as `lab5_reader`, which holds nothing +but `SELECT`. That is what makes `DROP`, `DELETE`, `UPDATE` and `CREATE` impossible from +the query textarea - the refusal comes from the database itself, not from a check in the +JavaScript. The write route connects as `lab5_writer`, which holds `SELECT`, `INSERT` and +`CREATE` so it can build the `patient` table and add rows, and nothing else. + +The `patient` table is created with `ENGINE=InnoDB` and is rebuilt automatically before +every insert, so dropping it by hand only lasts until the next press of the button. + +## Running server2 + +```sh +cd server2 +npm install +cp .env.example .env # fill in the host, database and both user passwords +npm run dev # or: npm start, when the host injects the variables itself +``` + +| Variable | Purpose | +| --- | --- | +| `PORT` | port to listen on, defaults to 8080 | +| `DB_HOST`, `DB_PORT`, `DB_NAME` | where the database lives | +| `DB_SSL` | `true` for a hosted database that requires TLS | +| `DB_READER_USER`, `DB_READER_PASSWORD` | the SELECT-only user | +| `DB_WRITER_USER`, `DB_WRITER_PASSWORD` | the INSERT user | + +### Endpoints + +| Method | Path | Does | +| --- | --- | --- | +| `POST` | `/lab5/api/v1/sql` | empty body inserts the four sample patients; a `{"query": "INSERT ..."}` body inserts that row | +| `GET` | `/lab5/api/v1/sql/` | runs the query as the read-only user | + +A query typed straight into the address bar, quotes and all: + +``` +https:///lab5/api/v1/sql/%22select%20*%20from%20patient%22 +``` + +## Running server1 + +Any static host. Point `API_BASE_URL` at the top of `server1/js/script.js` at server 2, +then upload the folder. + +```sh +cd server1 +python3 -m http.server 3000 +``` + +Server 1 and server 2 must not share an origin. diff --git a/labs/05/server1/css/style.css b/labs/05/server1/css/style.css new file mode 100644 index 0000000..9c61070 --- /dev/null +++ b/labs/05/server1/css/style.css @@ -0,0 +1,157 @@ +:root { + color: #172033; + background-color: #f5f7fb; + font-family: Arial, Helvetica, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; +} + +main { + width: min(92%, 52rem); + margin: 3rem auto; +} + +h1 { + margin: 0; + font-size: clamp(1.8rem, 5vw, 2.6rem); +} + +h2 { + margin: 0 0 0.5rem; + font-size: 1.25rem; +} + +.page-header { + margin-bottom: 2rem; + text-align: center; +} + +.student-name { + margin: 0.75rem 0 0; + color: #536078; + font-size: 1.15rem; +} + +.panel { + margin-bottom: 1.5rem; + padding: 1.5rem; + background-color: white; + border: 1px solid #d8deea; + border-radius: 0.75rem; + box-shadow: 0 0.75rem 2rem rgb(23 32 51 / 8%); +} + +.description { + margin: 0 0 1rem; + color: #536078; +} + +.query-label { + display: block; + margin-bottom: 0.4rem; + font-weight: 600; +} + +.query-input { + display: block; + width: 100%; + margin-bottom: 1rem; + padding: 0.8rem; + color: #172033; + background-color: #eef3ff; + border: 1px solid #8398c2; + border-radius: 0.35rem; + font-family: "Courier New", Courier, monospace; + font-size: 1rem; + resize: vertical; +} + +button { + padding: 0.65rem 1rem; + border: 0; + border-radius: 0.35rem; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.action-button { + color: white; + background-color: #315fc4; +} + +.action-button:disabled { + background-color: #8398c2; + cursor: progress; +} + +.status { + min-height: 1.2rem; + margin: 0 0 1rem; + color: #536078; +} + +.response { + overflow-x: auto; +} + +.result-table { + width: 100%; + border-collapse: collapse; +} + +.result-table th, +.result-table td { + padding: 0.6rem 0.8rem; + border: 1px solid #d8deea; + text-align: left; + white-space: nowrap; +} + +.result-table th { + color: white; + background-color: #315fc4; +} + +.result-table tbody tr:nth-child(even) { + background-color: #eef3ff; +} + +.success-message { + margin: 0; + color: #2f7a42; + font-weight: 600; +} + +.error-message { + margin: 0; + color: #c43e45; + font-weight: 600; + overflow-wrap: anywhere; +} + +.empty-message { + margin: 0; + color: #536078; +} + +@media (max-width: 36rem) { + main { + margin: 1rem auto; + } + + .panel { + padding: 1.25rem; + } + + .action-button { + width: 100%; + } +} diff --git a/labs/05/server1/index.html b/labs/05/server1/index.html new file mode 100644 index 0000000..cc20c1c --- /dev/null +++ b/labs/05/server1/index.html @@ -0,0 +1,43 @@ + + + + + + + + + +
+ + +
+

+

+ + +
+ +
+

+

+ + + + + +
+ +
+

+

+ +
+
+
+ + + + diff --git a/labs/05/server1/js/script.js b/labs/05/server1/js/script.js new file mode 100644 index 0000000..c911edc --- /dev/null +++ b/labs/05/server1/js/script.js @@ -0,0 +1,275 @@ +import { STRINGS } from "../lang/messages/en/user.js"; + +// server 2 lives on its own origin, change this one line when it moves +const API_BASE_URL = "https://lab5-api.sowinski.dev"; +const SQL_ROUTE = "/lab5/api/v1/sql"; + +const SELECT_PATTERN = /^select\b/i; +const INSERT_PATTERN = /^insert\b/i; + +class SqlStatement { + constructor(text) { + this.text = 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 + isSingleStatement() { + const withoutLiterals = this.text + .replace(/'(?:[^']|'')*'/g, "''") + .replace(/"(?:[^"]|"")*"/g, '""'); + + return !withoutLiterals.includes(";"); + } + + toString() { + return this.text; + } +} + +class ApiClient { + constructor(baseUrl, route) { + this.baseUrl = baseUrl; + this.route = route; + } + + getRouteUrl() { + return `${this.baseUrl}${this.route}`; + } + + buildQueryUrl(statement) { + return `${this.getRouteUrl()}/${encodeURIComponent(statement.toString())}`; + } + + select(statement) { + return this.send(this.buildQueryUrl(statement), { + method: "GET", + }); + } + + insert(statement) { + return this.send(this.getRouteUrl(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: statement.toString() }), + }); + } + + // an empty body tells server 2 to insert the four sample patients + insertSamplePatients() { + return this.send(this.getRouteUrl(), { + method: "POST", + }); + } + + async send(url, options) { + const response = await fetch(url, options); + const body = await response.text(); + + let payload; + + try { + payload = JSON.parse(body); + } catch { + payload = { success: false, error: body }; + } + + return { + ok: response.ok, + status: response.status, + payload: payload, + }; + } +} + +class ResponseView { + constructor(responseElement, statusElement) { + this.responseElement = responseElement; + this.statusElement = statusElement; + } + + showStatus(message) { + this.statusElement.textContent = message; + } + + clear() { + this.statusElement.textContent = ""; + this.responseElement.replaceChildren(); + } + + showMessage(message, className) { + const paragraph = document.createElement("p"); + paragraph.classList.add(className); + paragraph.textContent = message; + + this.responseElement.replaceChildren(paragraph); + } + + showError(message) { + this.showMessage(message, "error-message"); + } + + showRows(rows) { + if (rows.length === 0) { + this.showMessage(STRINGS.NO_ROWS, "empty-message"); + return; + } + + this.responseElement.replaceChildren(this.buildTable(rows)); + } + + buildTable(rows) { + const table = document.createElement("table"); + const head = document.createElement("thead"); + const body = document.createElement("tbody"); + const columns = Object.keys(rows[0]); + + table.classList.add("result-table"); + head.appendChild(this.buildRow(columns, "th", (column) => column)); + + for (const row of rows) { + body.appendChild(this.buildRow(columns, "td", (column) => row[column])); + } + + table.append(head, body); + return table; + } + + buildRow(columns, cellTag, getValue) { + const tableRow = document.createElement("tr"); + + for (const column of columns) { + const cell = document.createElement(cellTag); + cell.textContent = this.formatValue(getValue(column)); + tableRow.appendChild(cell); + } + + return tableRow; + } + + formatValue(value) { + if (value === null || value === undefined) { + return STRINGS.NULL_VALUE; + } + + return String(value); + } +} + +class LabPage { + constructor(api) { + this.api = api; + + this.insertButton = document.getElementById("insert-button"); + this.queryButton = document.getElementById("query-button"); + this.queryInput = document.getElementById("query-input"); + + this.view = new ResponseView( + document.getElementById("response"), + document.getElementById("status") + ); + } + + init() { + document.title = STRINGS.PAGE_TITLE; + + document.getElementById("page-title").textContent = STRINGS.PAGE_TITLE; + document.getElementById("student-name").textContent = STRINGS.STUDENT_NAME; + document.getElementById("insert-heading").textContent = STRINGS.INSERT_HEADING; + document.getElementById("insert-description").textContent = STRINGS.INSERT_DESCRIPTION; + document.getElementById("query-heading").textContent = STRINGS.QUERY_HEADING; + document.getElementById("query-description").textContent = STRINGS.QUERY_DESCRIPTION; + document.getElementById("query-label").textContent = STRINGS.QUERY_LABEL; + document.getElementById("response-heading").textContent = STRINGS.RESPONSE_HEADING; + + this.insertButton.textContent = STRINGS.INSERT_BUTTON; + this.queryButton.textContent = STRINGS.QUERY_BUTTON; + this.queryInput.placeholder = STRINGS.QUERY_PLACEHOLDER; + + this.view.showMessage(STRINGS.RESPONSE_EMPTY, "empty-message"); + + this.insertButton.addEventListener("click", () => this.handleInsert()); + this.queryButton.addEventListener("click", () => this.handleQuery()); + } + + handleInsert() { + this.sendRequest(() => this.api.insertSamplePatients()); + } + + handleQuery() { + const statement = new SqlStatement(this.queryInput.value); + + if (statement.isEmpty()) { + this.view.clear(); + this.view.showError(STRINGS.EMPTY_QUERY); + return; + } + + if (!statement.isSingleStatement()) { + this.view.clear(); + this.view.showError(STRINGS.SINGLE_STATEMENT_ONLY); + return; + } + + // reads go out as GET, inserts as POST. anything else still goes out + // as GET so the read only database user is the one that rejects it. + if (statement.isInsert()) { + this.sendRequest(() => this.api.insert(statement)); + } else { + this.sendRequest(() => this.api.select(statement)); + } + } + + async sendRequest(request) { + this.view.clear(); + this.view.showStatus(STRINGS.SENDING); + this.setBusy(true); + + try { + this.showResult(await request()); + } catch (error) { + this.view.showStatus(""); + this.view.showError(STRINGS.NETWORK_ERROR + error.message); + } finally { + this.setBusy(false); + } + } + + showResult(result) { + const payload = result.payload; + + if (!result.ok || payload.success === false) { + this.view.showStatus(""); + this.view.showError(STRINGS.SERVER_ERROR + (payload.error || result.status)); + return; + } + + if (Array.isArray(payload.data)) { + this.view.showStatus(STRINGS.ROWS_RETURNED + payload.data.length); + this.view.showRows(payload.data); + return; + } + + this.view.showStatus(STRINGS.ROWS_INSERTED + (payload.rowCount ?? 0)); + this.view.showMessage(payload.message, "success-message"); + } + + setBusy(isBusy) { + this.insertButton.disabled = isBusy; + this.queryButton.disabled = isBusy; + } +} + +const api = new ApiClient(API_BASE_URL, SQL_ROUTE); +new LabPage(api).init(); diff --git a/labs/05/server1/lang/messages/en/user.js b/labs/05/server1/lang/messages/en/user.js new file mode 100644 index 0000000..57e1d27 --- /dev/null +++ b/labs/05/server1/lang/messages/en/user.js @@ -0,0 +1,28 @@ +export const STRINGS = { + PAGE_TITLE: "Lab 5: DB Intro", + STUDENT_NAME: "Braeden Sowinski", + + INSERT_HEADING: "Insert Patients", + INSERT_DESCRIPTION: "Sends a POST request to server 2 that inserts the four sample patients. Press it as often as you like, the table keeps growing.", + INSERT_BUTTON: "Insert Rows", + + QUERY_HEADING: "Run a Query", + QUERY_DESCRIPTION: "SELECT statements are sent with GET, INSERT statements are sent with POST. Anything else is still sent, and the read only database user is what refuses it.", + QUERY_LABEL: "SQL query", + QUERY_PLACEHOLDER: "SELECT * FROM patient", + QUERY_BUTTON: "Send Query", + + RESPONSE_HEADING: "Server Response", + RESPONSE_EMPTY: "No request has been sent yet.", + + SENDING: "Sending request...", + ROWS_RETURNED: "Rows returned: ", + ROWS_INSERTED: "Rows inserted: ", + NO_ROWS: "The query returned no rows.", + NULL_VALUE: "NULL", + + EMPTY_QUERY: "Please enter a SQL query first.", + SINGLE_STATEMENT_ONLY: "Please send one statement at a time.", + SERVER_ERROR: "Server responded with an error: ", + NETWORK_ERROR: "Could not reach the API server: ", +}; diff --git a/labs/05/server2/.env.example b/labs/05/server2/.env.example new file mode 100644 index 0000000..c452b30 --- /dev/null +++ b/labs/05/server2/.env.example @@ -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 diff --git a/labs/05/server2/.gitignore b/labs/05/server2/.gitignore new file mode 100644 index 0000000..37d7e73 --- /dev/null +++ b/labs/05/server2/.gitignore @@ -0,0 +1,2 @@ +node_modules +.env diff --git a/labs/05/server2/lang/messages/en/user.js b/labs/05/server2/lang/messages/en/user.js new file mode 100644 index 0000000..a5aad0e --- /dev/null +++ b/labs/05/server2/lang/messages/en/user.js @@ -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.", +}; diff --git a/labs/05/server2/modules/ApiServer.js b/labs/05/server2/modules/ApiServer.js new file mode 100644 index 0000000..f7ce6de --- /dev/null +++ b/labs/05/server2/modules/ApiServer.js @@ -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); + } +} diff --git a/labs/05/server2/modules/Database.js b/labs/05/server2/modules/Database.js new file mode 100644 index 0000000..eb82fea --- /dev/null +++ b/labs/05/server2/modules/Database.js @@ -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; + } +} diff --git a/labs/05/server2/modules/PatientRepository.js b/labs/05/server2/modules/PatientRepository.js new file mode 100644 index 0000000..bd20069 --- /dev/null +++ b/labs/05/server2/modules/PatientRepository.js @@ -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); + } +} diff --git a/labs/05/server2/modules/SqlStatement.js b/labs/05/server2/modules/SqlStatement.js new file mode 100644 index 0000000..d4e2be4 --- /dev/null +++ b/labs/05/server2/modules/SqlStatement.js @@ -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; + } +} diff --git a/labs/05/server2/package-lock.json b/labs/05/server2/package-lock.json new file mode 100644 index 0000000..f89e96f --- /dev/null +++ b/labs/05/server2/package-lock.json @@ -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 + } + } +} diff --git a/labs/05/server2/package.json b/labs/05/server2/package.json new file mode 100644 index 0000000..5b50c5c --- /dev/null +++ b/labs/05/server2/package.json @@ -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" + } +} diff --git a/labs/05/server2/server.js b/labs/05/server2/server.js new file mode 100644 index 0000000..29c8cbb --- /dev/null +++ b/labs/05/server2/server.js @@ -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); +} diff --git a/labs/05/server2/sql/setup.sql b/labs/05/server2/sql/setup.sql new file mode 100644 index 0000000..eca4d9c --- /dev/null +++ b/labs/05/server2/sql/setup.sql @@ -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;