lab 05
This commit is contained in:
16 files changed
+1171
No files matched your search
@@ -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 <host> -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/<url encoded sql>` | runs the query as the read-only user |
|
||||||
|
|
||||||
|
A query typed straight into the address bar, quotes and all:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://<api-host>/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.
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="./css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="lab-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1 id="page-title"></h1>
|
||||||
|
<p id="student-name" class="student-name"></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="insert-heading">
|
||||||
|
<h2 id="insert-heading"></h2>
|
||||||
|
<p id="insert-description" class="description"></p>
|
||||||
|
|
||||||
|
<button id="insert-button" class="action-button" type="button"></button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="query-heading">
|
||||||
|
<h2 id="query-heading"></h2>
|
||||||
|
<p id="query-description" class="description"></p>
|
||||||
|
|
||||||
|
<label id="query-label" class="query-label" for="query-input"></label>
|
||||||
|
<textarea id="query-input" class="query-input" rows="5"></textarea>
|
||||||
|
|
||||||
|
<button id="query-button" class="action-button" type="button"></button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="response-heading">
|
||||||
|
<h2 id="response-heading"></h2>
|
||||||
|
<p id="status" class="status" aria-live="polite"></p>
|
||||||
|
|
||||||
|
<div id="response" class="response" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="./js/script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 = "http://localhost:8080";
|
||||||
|
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();
|
||||||
@@ -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: ",
|
||||||
|
};
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
@@ -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.",
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+147
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Reference in new issue
Block a user