From e602fbf5589da6704149c62ff606f202ab66ef8e Mon Sep 17 00:00:00 2001 From: Repinoid Date: Fri, 2 Oct 2026 11:48:03 +0300 Subject: [PATCH] Port Node.js CRUD to MariaDB --- .gitignore | 4 ++ README.md | 5 ++ package.json | 14 +++++ public/favicon.svg | 55 +++++++++++++++++++ public/logo.svg | 25 +++++++++ public/style.css | 111 +++++++++++++++++++++++++++++++++++++ server.js | 133 +++++++++++++++++++++++++++++++++++++++++++++ views/index.ejs | 110 +++++++++++++++++++++++++++++++++++++ 8 files changed, 457 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100755 public/logo.svg create mode 100644 public/style.css create mode 100644 server.js create mode 100644 views/index.ejs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a433ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.log +.env +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..16124c9 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Node.js MariaDB CRUD + +Express + MariaDB через `mysql2`. Подключение задаётся переменными `DB_HOST`, `DB_PORT`, +`DB_NAME`, `DB_USER`, `DB_PASSWORD`; таблица `crud_items` создаётся при старте. +Деплоится через Terraform (`nubes_nodejs`). diff --git a/package.json b/package.json new file mode 100644 index 0000000..b66d0d8 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "maria-nodejs-crud", + "version": "1.0.0", + "description": "Node.js CRUD — MariaDB", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "ejs": "^3.1.10", + "express": "^4.21.0", + "mysql2": "^3.14.1" + } +} \ No newline at end of file diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..11a9ece --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,55 @@ + + diff --git a/public/logo.svg b/public/logo.svg new file mode 100755 index 0000000..d76eef6 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..8a5eb67 --- /dev/null +++ b/public/style.css @@ -0,0 +1,111 @@ +:root { + --brand-primary: #2563eb; + --brand-primary-dark: #1d4ed8; + --brand-gray: #d1d5db; + --brand-grey-light: #f3f4f6; + --brand-destructive: #ef4444; + --brand-success: #22c55e; + --text-primary: #1a1a1a; + --text-muted: #6b7280; + --bg-card: #ffffff; + --radius: 12px; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +body { + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + color: var(--text-primary); + background: var(--brand-grey-light); + min-height: 100vh; + padding: 40px 16px; +} +.container { max-width: 720px; margin: 0 auto; } + +/* Header */ +.header { + display: flex; align-items: center; gap: 12px; + margin-bottom: 24px; +} +.header img { height: 36px; } +.header h1 { font-size: 20px; font-weight: 600; color: #001C34; } + +/* Card */ +.card { + background: var(--bg-card); + border-radius: var(--radius); + border: 1px solid var(--brand-gray); + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + overflow: hidden; + margin-bottom: 20px; +} +.card-header { + background: var(--brand-grey-light); + padding: 12px 16px; + font-weight: 600; + font-size: 16px; +} +.card-body { padding: 16px; } + +/* Table */ +table { width: 100%; border-collapse: collapse; } +th { + background: var(--brand-grey-light); + text-transform: uppercase; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + padding: 8px 12px; + text-align: left; + border-right: 1px solid var(--brand-gray); +} +th:last-child { border-right: none; text-align: center; width: 60px; } +td { + padding: 8px 12px; + border-bottom: 1px solid var(--brand-gray); + border-right: 1px solid var(--brand-gray); +} +td:last-child { border-right: none; text-align: center; width: 60px; } +tr:hover { background: rgba(243,244,246,0.5); } +tr:last-child td { border-bottom: none; } + +/* Buttons */ +.btn { + display: inline-flex; align-items: center; gap: 4px; + height: 32px; padding: 0 12px; + border: 1px solid var(--brand-gray); + border-radius: 6px; + background: var(--bg-card); + font-size: 14px; font-family: inherit; + cursor: pointer; white-space: nowrap; + text-decoration: none; + color: var(--text-primary); +} +.btn:hover { background: var(--brand-grey-light); } +.btn-primary { + background: var(--brand-primary); color: #fff; border-color: var(--brand-primary); +} +.btn-primary:hover { background: var(--brand-primary-dark); } +.btn-sm { height: 28px; padding: 0 8px; font-size: 13px; } + +/* Form */ +.form-grid { + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} +.form-row { + display: flex; gap: 8px; align-items: flex-end; +} +.form-row input { flex: 1; } +input[type="text"] { + height: 36px; padding: 0 12px; + border: 1px solid var(--brand-gray); + border-radius: 6px; font-size: 14px; font-family: inherit; +} +input[type="text"]:focus { outline: none; border-color: var(--brand-primary); box-shadow: 0 0 0 2px rgba(37,99,235,0.15); } + +/* Footer */ +.footer { text-align: center; padding: 16px; color: #9ca3af; font-size: 12px; } + +/* Empty state */ +.empty-cell { color: var(--text-muted); padding: 24px; text-align: center; } diff --git a/server.js b/server.js new file mode 100644 index 0000000..aca510a --- /dev/null +++ b/server.js @@ -0,0 +1,133 @@ +/** + * server.js — Node.js CRUD (Express + MariaDB). + * + * Переменные окружения: + * DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD + * TABLE_NAME (по умолчанию crud_items) + * PORT (по умолчанию 3000) + * + * При старте: CREATE TABLE IF NOT EXISTS. + */ +const express = require("express"); +const mysql = require("mysql2/promise"); +const path = require("path"); + +const VERSION = "1.0.0"; +const TABLE_NAME = process.env.TABLE_NAME || "crud_items"; +if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(TABLE_NAME)) { + throw new Error("TABLE_NAME must be a simple SQL identifier"); +} +const TABLE_SQL = `\`${TABLE_NAME}\``; +// SERVICE_NAME — какое приложение добавило строку (колонка created_by). +// Задаётся в json_env манифеста для каждого сервиса (flask / lucee / nodejs). +const SERVICE_NAME = process.env.SERVICE_NAME || "nodejs"; +const PORT = process.env.PORT || 3000; + +const pool = mysql.createPool({ + host: process.env.DB_HOST || "127.0.0.1", + port: Number.parseInt(process.env.DB_PORT || "3306", 10), + database: process.env.DB_NAME || "maria", + user: process.env.DB_USER || "", + password: process.env.DB_PASSWORD || "", + waitForConnections: true, + connectionLimit: 5, + queueLimit: 0, + charset: "utf8mb4", +}); + +const app = express(); +app.use(express.urlencoded({ extended: true })); +app.use(express.static(path.join(__dirname, "public"))); +app.set("view engine", "ejs"); +app.set("views", path.join(__dirname, "views")); + +// --- Инициализация БД --- +// Таблица выставочная: кроме id/value — две метки: created_at (когда добавлено) +// и created_by (какое приложение добавило). ALTER ... IF NOT EXISTS досыпает +// колонки в таблицу, созданную прошлой версией приложения, не теряя данные. +async function initDB() { + const client = await pool.getConnection(); + try { + await client.query(` + CREATE TABLE IF NOT EXISTS ${TABLE_SQL} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + \`value\` TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(255) NOT NULL DEFAULT '' + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `); + await client.query( + `ALTER TABLE ${TABLE_SQL} ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP` + ); + await client.query( + `ALTER TABLE ${TABLE_SQL} ADD COLUMN IF NOT EXISTS created_by VARCHAR(255) NOT NULL DEFAULT ''` + ); + } finally { + client.release(); + } +} + +// --- Главная страница (GET + POST) --- +app.get("/", async (req, res) => { + try { + let editRow = null; + if (req.query.edit) { + const [editRows] = await pool.execute( + `SELECT id, \`value\`, created_at, created_by FROM ${TABLE_SQL} WHERE id = ?`, + [parseInt(req.query.edit)] + ); + if (editRows.length > 0) editRow = editRows[0]; + } + + const [rows] = await pool.execute( + `SELECT id, \`value\`, created_at, created_by FROM ${TABLE_SQL} ORDER BY id` + ); + + res.render("index", { + table_name: TABLE_NAME, + rows, + editRow, + version: VERSION, + }); + } catch (err) { + res.status(500).send(err.message); + } +}); + +app.post("/", async (req, res) => { + try { + const { action, value, id } = req.body; + + if (action === "create") { + // created_at ставит СУБД (DEFAULT now()), created_by — имя этого сервиса + await pool.execute( + `INSERT INTO ${TABLE_SQL} (\`value\`, created_by) VALUES (?, ?)`, + [value || "", SERVICE_NAME] + ); + } else if (action === "update") { + await pool.execute(`UPDATE ${TABLE_SQL} SET \`value\` = ? WHERE id = ?`, [ + value || "", + parseInt(id || 0), + ]); + } else if (action === "delete") { + await pool.execute(`DELETE FROM ${TABLE_SQL} WHERE id = ?`, [ + parseInt(id || 0), + ]); + } + + res.redirect("/"); + } catch (err) { + res.status(500).send(err.message); + } +}); + +// --- Старт --- +// Сервер стартует всегда, даже если PG недоступен. +// initDB — асинхронно, ошибка логируется но не роняет под. +initDB() + .then(() => console.log("DB ready")) + .catch((err) => console.error("DB init failed:", err.message)); + +app.listen(PORT, () => { + console.log(`Node.js CRUD v${VERSION} — http://0.0.0.0:${PORT}`); +}); diff --git a/views/index.ejs b/views/index.ejs new file mode 100644 index 0000000..a633c71 --- /dev/null +++ b/views/index.ejs @@ -0,0 +1,110 @@ + + + + + + + CRUD Node.js Example — <%= table_name %> + + + + + + +
+ +
+ Nubes +

CRUD Node.js Example

+
+ +
+
Записи
+
+ + + + + + + + + + + + + <% rows.forEach(function(row) { %> + + + + + + + + + <% }); %> + +
IDVALUEДОБАВЛЕНОКЕМ
+ <%= row.id %> + + <%= row.value %> + + <%= row.created_at ? new Date(row.created_at).toLocaleString('ru-RU') : '' %> + + <%= row.created_by %> + + ✏️ + +
+ + + +
+
+ <% if (rows.length===0) { %> +
Нет записей. Создайте первую.
+ <% } %> +
+
+ +
+ <% if (editRow) { %> +
Редактировать #<%= editRow.id %> +
+
+
+ + +
+ + + Отмена +
+
+
+ <% } else { %> +
Добавить
+
+
+ +
+ + +
+
+
+ <% } %> +
+ +
+ + + + + \ No newline at end of file