From 4c0c441e92ba8ca81b71badf276058f99f73729d Mon Sep 17 00:00:00 2001 From: Theis Gaedigk Date: Tue, 26 May 2026 13:01:11 +0200 Subject: [PATCH] Implement backend structure with Docker, database schema, and user authentication --- .docker/README.md | 0 .gitignore | 5 +- backend/Dockerfile | 12 +++ backend/database.mock.sql | 83 +++++++++++++++++++ backend/database.scheme.sql | 52 ++++++------ backend/package-lock.json | 10 +++ backend/package.json | 3 +- .../routes/app/database/products.database.js | 12 +++ backend/routes/app/database/users.database.js | 42 ++++++++++ backend/routes/app/products.route.js | 6 ++ backend/routes/app/users.route.js | 56 +++++++++++++ backend/server.js | 29 +++++++ backend/services/tokenService.js | 27 ++++++ docker-compose.yml | 9 +- 14 files changed, 313 insertions(+), 33 deletions(-) create mode 100644 .docker/README.md create mode 100644 backend/database.mock.sql create mode 100644 backend/routes/app/database/products.database.js create mode 100644 backend/routes/app/database/users.database.js create mode 100644 backend/routes/app/products.route.js create mode 100644 backend/routes/app/users.route.js create mode 100644 backend/services/tokenService.js diff --git a/.docker/README.md b/.docker/README.md new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index f81f2ad..d16cc03 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,7 @@ Network Trash Folder Temporary Items .apdisk -ToDo.txt \ No newline at end of file +ToDo.txt + +.env +.docker/volumes \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index e69de29..496e0cd 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +ENV NODE_ENV=production +WORKDIR /backend + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY . . + +EXPOSE 8004 +CMD ["npm", "start"] \ No newline at end of file diff --git a/backend/database.mock.sql b/backend/database.mock.sql new file mode 100644 index 0000000..aff915b --- /dev/null +++ b/backend/database.mock.sql @@ -0,0 +1,83 @@ +-- ============================================================ +-- StockHome – Mock Data +-- Run against the stockhome schema before executing this. +-- Passwords are stored in plain text (development only). +-- ============================================================ + +USE stockhome; + +SET FOREIGN_KEY_CHECKS = 0; +DELETE FROM products; +DELETE FROM storage_locations; +DELETE FROM users; +SET FOREIGN_KEY_CHECKS = 1; + +-- ============================================================ +-- USERS +-- ============================================================ +INSERT INTO users (username, first_name, last_name, email, password, is_admin, is_active, last_login) VALUES +('thomas.mueller', 'Thomas', 'Müller', 'thomas.mueller@example.com', 'Password123!', TRUE, TRUE, '2025-05-25 08:30:00'), +('sarah.mueller', 'Sarah', 'Müller', 'sarah.mueller@example.com', 'Password123!', FALSE, TRUE, '2025-05-24 19:15:00'), +('max.schmidt', 'Max', 'Schmidt', 'max.schmidt@example.com', 'Password123!', FALSE, FALSE, '2025-03-10 11:00:00'); + +-- ============================================================ +-- STORAGE LOCATIONS +-- ============================================================ +INSERT INTO storage_locations (name, description) VALUES +('Kühlschrank', 'Kühlschrank in der Küche'), +('Gefrierfach 1', 'Oberes Gefrierfach – Fleisch und Fisch'), +('Gefrierfach 2', 'Unteres Gefrierfach – Gemüse und Fertiggerichte'), +('Vorratskammer', 'Regal im Flur, Trockenware und Konserven'), +('Keller Regal A', 'Keller links – Getränke und Eingemachtes'), +('Keller Regal B', 'Keller rechts – Hygieneartikel und Reinigungsmittel'), +('Garage Regal', 'Garage – Werkzeug, Öle, Autopflege'); + +-- ============================================================ +-- PRODUCTS +-- Storage locations are looked up by name since UUIDs are +-- auto-generated above and not known at insert time. +-- ============================================================ + +-- Gefrierfach 1 +INSERT INTO products (name, description, price, amount, storage_location, expiry_date, bottling_date) VALUES +('Hähnchenbrust', 'Tiefgekühlte Hähnchenbrustfilets, vakuumverpackt', '6.49', 5, (SELECT uuid FROM storage_locations WHERE name = 'Gefrierfach 1'), '2026-01-10', '2025-01-10'), +('Lachs Filet', 'Tiefgefrorenes Lachsfilet, Atlantic, 500g', '12.99', 1, (SELECT uuid FROM storage_locations WHERE name = 'Gefrierfach 1'), '2025-12-31', '2025-02-20'), +('Hackfleisch', 'Rind und Schwein gemischt, 500g Portion', '3.99', 3, (SELECT uuid FROM storage_locations WHERE name = 'Gefrierfach 1'), '2026-02-15', '2025-01-28'); + +-- Gefrierfach 2 +INSERT INTO products (name, description, price, amount, storage_location, expiry_date, bottling_date) VALUES +('Erbsen TK', 'Tiefkühlerbsen, 750g Packung', '1.99', 3, (SELECT uuid FROM storage_locations WHERE name = 'Gefrierfach 2'), '2026-06-01', '2025-03-05'), +('Spinat TK', 'Blattspinat tiefgefroren, 450g', '1.49', 2, (SELECT uuid FROM storage_locations WHERE name = 'Gefrierfach 2'), '2026-04-01', '2025-02-10'), +('Pizza Margherita', 'Tiefkühlpizza, 350g', '2.29', 4, (SELECT uuid FROM storage_locations WHERE name = 'Gefrierfach 2'), '2025-11-30', NULL); + +-- Kühlschrank +INSERT INTO products (name, description, price, amount, storage_location, expiry_date, bottling_date) VALUES +('Vollmilch', '3,5% Fett, 1 Liter', '1.09', 2, (SELECT uuid FROM storage_locations WHERE name = 'Kühlschrank'), '2025-05-29', NULL), +('Butter', 'Deutsche Markenbutter, 250g', '1.89', 3, (SELECT uuid FROM storage_locations WHERE name = 'Kühlschrank'), '2025-07-01', NULL), +('Gouda am Stück', 'Junger Gouda, ca. 400g', '3.29', 1, (SELECT uuid FROM storage_locations WHERE name = 'Kühlschrank'), '2025-06-15', NULL); + +-- Vorratskammer +INSERT INTO products (name, description, price, amount, storage_location, expiry_date, bottling_date) VALUES +('Spaghetti', 'Barilla Nr. 5, 500g', '1.29', 5, (SELECT uuid FROM storage_locations WHERE name = 'Vorratskammer'), '2027-02-01', NULL), +('Basmatireis', 'Uncle Ben''s, 1kg', '2.49', 2, (SELECT uuid FROM storage_locations WHERE name = 'Vorratskammer'), '2027-01-05', NULL), +('Tomaten gehackt', 'Mutti, 400g Dose', '0.89', 8, (SELECT uuid FROM storage_locations WHERE name = 'Vorratskammer'), '2027-01-01', '2024-11-01'), +('Kichererbsen', 'ja!, 400g Dose in Lake', '0.79', 4, (SELECT uuid FROM storage_locations WHERE name = 'Vorratskammer'), '2027-06-01', '2024-12-01'), +('Haferflocken', 'Kölln, zarte Haferflocken, 500g', '1.49', 2, (SELECT uuid FROM storage_locations WHERE name = 'Vorratskammer'), '2026-09-10', NULL), +('Thunfisch in Öl', 'Rio Mare, 3er Pack in Olivenöl', '3.49', 2, (SELECT uuid FROM storage_locations WHERE name = 'Vorratskammer'), '2026-08-01', '2025-01-20'); + +-- Keller Regal A +INSERT INTO products (name, description, price, amount, storage_location, expiry_date, bottling_date) VALUES +('Mineralwasser', 'Volvic still, 1,5L Flasche', '0.79', 12, (SELECT uuid FROM storage_locations WHERE name = 'Keller Regal A'), '2027-02-15', NULL), +('Apfelsaft', 'Valensina naturtrüb, 1L', '1.99', 3, (SELECT uuid FROM storage_locations WHERE name = 'Keller Regal A'), '2026-03-01', '2025-03-01'), +('Erdbeerkonfitüre', 'Selbst eingemacht, 250ml Glas', NULL, 6, (SELECT uuid FROM storage_locations WHERE name = 'Keller Regal A'), '2026-07-01', '2025-07-15'); + +-- Keller Regal B +INSERT INTO products (name, description, price, amount, storage_location, expiry_date, bottling_date) VALUES +('Toilettenpapier', 'Zewa, 3-lagig, 16 Rollen', '4.99', 3, (SELECT uuid FROM storage_locations WHERE name = 'Keller Regal B'), NULL, NULL), +('Handseife', 'Dove, pflegend, 250ml', '2.29', 0, (SELECT uuid FROM storage_locations WHERE name = 'Keller Regal B'), '2026-02-10', '2025-02-10'), +('Waschmittel', 'Persil Color, 20 Waschladungen', '8.99', 1, (SELECT uuid FROM storage_locations WHERE name = 'Keller Regal B'), NULL, NULL); + +-- Garage Regal +INSERT INTO products (name, description, price, amount, storage_location, expiry_date, bottling_date) VALUES +('Motoröl 5W-30', 'Castrol EDGE, 1L Flasche', '14.99', 2, (SELECT uuid FROM storage_locations WHERE name = 'Garage Regal'), NULL, NULL), +('Fahrradkette', 'Shimano HG-54, 11-fach', '18.50', 1, (SELECT uuid FROM storage_locations WHERE name = 'Garage Regal'), NULL, NULL); \ No newline at end of file diff --git a/backend/database.scheme.sql b/backend/database.scheme.sql index 74f66e0..eab27ea 100644 --- a/backend/database.scheme.sql +++ b/backend/database.scheme.sql @@ -1,37 +1,39 @@ USE stockhome; CREATE TABLE IF NOT EXISTS users ( - uuid BINARY(16) PRIMARY KEY, - username VARCHAR(255) NOT NULL UNIQUE, - email VARCHAR(255) NOT NULL UNIQUE, - password VARCHAR(255) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - is_admin BOOLEAN DEFAULT FALSE, - is_active BOOLEAN DEFAULT TRUE, - last_login TIMESTAMP NULL, + uuid BINARY(16) PRIMARY KEY DEFAULT (UUID_TO_BIN(UUID())), + username VARCHAR(255) NOT NULL UNIQUE, + first_name VARCHAR(255) NOT NULL, + last_name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + is_admin BOOLEAN DEFAULT FALSE, + is_active BOOLEAN DEFAULT TRUE, + last_login TIMESTAMP NULL, profile_picture VARCHAR(500) NULL ); CREATE TABLE IF NOT EXISTS storage_locations ( - uuid BINARY(16) PRIMARY KEY, - name VARCHAR(255) NOT NULL UNIQUE, - description TEXT DEFAULT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + uuid BINARY(16) PRIMARY KEY DEFAULT (UUID_TO_BIN(UUID())), + name VARCHAR(255) NOT NULL UNIQUE, + description TEXT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS products ( - uuid BINARY(16) PRIMARY KEY, - name VARCHAR(255) NOT NULL, - description TEXT DEFAULT NULL, - price VARCHAR(10) DEFAULT NULL, - amount INT NOT NULL, - storage_location BINARY(16) NOT NULL, - expiry_date DATE DEFAULT NULL, - bottling_date DATE DEFAULT NULL, - picture VARCHAR(500) DEFAULT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + uuid BINARY(16) PRIMARY KEY DEFAULT (UUID_TO_BIN(UUID())), + name VARCHAR(255) NOT NULL, + description TEXT DEFAULT NULL, + price VARCHAR(10) DEFAULT NULL, + amount INT NOT NULL, + storage_location BINARY(16) NOT NULL, + expiry_date DATE DEFAULT NULL, + bottling_date DATE DEFAULT NULL, + picture VARCHAR(500) DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (storage_location) REFERENCES storage_locations(uuid) ON DELETE CASCADE ); \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 25e6369..c9a6ac5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,7 @@ "dotenv": "^17.2.3", "ejs": "^3.1.10", "express": "^5.2.1", + "jose": "^6.0.12", "mysql2": "^3.16.0" } }, @@ -586,6 +587,15 @@ "node": ">=10" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", diff --git a/backend/package.json b/backend/package.json index 8570381..da2d174 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,12 +10,13 @@ "keywords": [], "author": "", "license": "ISC", - "type": "commonjs", + "type": "module", "dependencies": { "cors": "^2.8.5", "dotenv": "^17.2.3", "ejs": "^3.1.10", "express": "^5.2.1", + "jose": "^6.0.12", "mysql2": "^3.16.0" } } \ No newline at end of file diff --git a/backend/routes/app/database/products.database.js b/backend/routes/app/database/products.database.js new file mode 100644 index 0000000..df4ea4d --- /dev/null +++ b/backend/routes/app/database/products.database.js @@ -0,0 +1,12 @@ +import mysql from "mysql2"; +import dotenv from "dotenv"; +dotenv.config(); + +const pool = mysql + .createPool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + }) + .promise(); \ No newline at end of file diff --git a/backend/routes/app/database/users.database.js b/backend/routes/app/database/users.database.js new file mode 100644 index 0000000..b7b0ca8 --- /dev/null +++ b/backend/routes/app/database/users.database.js @@ -0,0 +1,42 @@ +import mysql from "mysql2"; +import dotenv from "dotenv"; +dotenv.config(); + +const pool = mysql + .createPool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + }) + .promise(); + +export const findUser = async (username, password) => { + const [result] = await pool.query( + "SELECT BIN_TO_UUID(uuid) AS uuid, username, first_name, last_name, email, is_admin, is_active, last_login FROM users WHERE username = ? AND password = ?;", + [username, password], + ); + + if (result.length <= 0) { + return { code: "e001" }; // username or password is wrong + } + + if (!result[0].is_active) { + return { code: "e002" }; // user is deactivated + } + + return { code: "s001", data: result[0] }; // user found +}; + +export const loginUser = async (username) => { + const [result] = await pool.query( + "UPDATE users SET last_login = NOW() WHERE username = ?;", + [username], + ); + + if (result.affectedRows > 0) { + return { code: "s002" }; + } else { + return { code: "e003" }; + } +}; diff --git a/backend/routes/app/products.route.js b/backend/routes/app/products.route.js new file mode 100644 index 0000000..d24a755 --- /dev/null +++ b/backend/routes/app/products.route.js @@ -0,0 +1,6 @@ +import express from "express"; +import dotenv from "dotenv"; +dotenv.config(); +const router = express.Router(); + +export default router; diff --git a/backend/routes/app/users.route.js b/backend/routes/app/users.route.js new file mode 100644 index 0000000..5a9473d --- /dev/null +++ b/backend/routes/app/users.route.js @@ -0,0 +1,56 @@ +import express from "express"; +import dotenv from "dotenv"; +import { generateToken } from "../../services/tokenService.js"; +import { findUser, loginUser } from "./database/users.database.js"; +dotenv.config(); +const router = express.Router(); + +router.post("/login", async (req, res) => { + const username = req.body.username; + const password = req.body.password; + + const result = await findUser(username, password); + + if (result.code === "e001") { + res.status(404).json({ + success: false, + code: "e001", + data: null, + message: "username oder password is wrong", + }); + } + + if (result.code === "e002") { + res.status(403).json({ + success: false, + code: "e002", + data: null, + message: "user is deactivated", + }); + } + + if (result.code === "s001") { + const token = await generateToken(result.data); + const login = await loginUser(result.data.username); + + if (login.code === "e003") { + res.status(500).json({ + success: false, + code: "e003", + data: null, + message: "Unexpected server error. Please contact system admin.", + }); + } + + res.status(202).json({ + success: true, + code: "s001", + data: { + token, + }, + message: "User token generated successfully", + }); + } +}); + +export default router; diff --git a/backend/server.js b/backend/server.js index e69de29..616a80a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -0,0 +1,29 @@ +import express from "express"; +import cors from "cors"; +import dotenv from "dotenv"; +dotenv.config(); + +const app = express(); +const PORT = process.env.PORT; +app.set("view engine", "ejs"); + +app.use(cors()); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// frontend routes +import userRouter from "./routes/app/users.route.js"; +app.use("/users", userRouter); + +import productRouter from "./routes/app/products.route.js"; +app.use("/products", productRouter); + +app.listen(PORT, () => { + console.log(`Server is running on http://localhost:${PORT}`); +}); + +// error handling code +app.use((err, req, res, next) => { + console.error(err.stack); + res.status(500).send("Something broke!"); +}); diff --git a/backend/services/tokenService.js b/backend/services/tokenService.js new file mode 100644 index 0000000..8c00c49 --- /dev/null +++ b/backend/services/tokenService.js @@ -0,0 +1,27 @@ +import bodyParser from "body-parser"; +import { SignJWT, jwtVerify } from "jose"; +import env from "dotenv"; +env.config(); +const secret = new TextEncoder().encode(process.env.SECRET_KEY); + +export async function generateToken(payload) { + const newToken = await new SignJWT(payload) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("24h") // Token valid for 24 hours + .sign(secret); + console.log("Generated token: ", newToken); + return newToken; +} + +export async function authenticate(req, res, next) { + const authHeader = req.headers["authorization"]; + const token = authHeader && authHeader.split(" ")[1]; // Bearer + + if (token == null) return res.sendStatus(401); // No token present + + const { payload } = await jwtVerify(token, secret); + req.user = payload; + + next(); +} diff --git a/docker-compose.yml b/docker-compose.yml index 983a674..0f90e16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: database: container_name: stockhome-mysql - image: mysql:latest + image: mysql:8.0 restart: unless-stopped ports: - "3312:3306" @@ -10,7 +10,7 @@ services: MYSQL_DATABASE: stockhome TZ: Europe/Berlin volumes: - - stockhome_mysql:/var/lib/mysql + - ./.docker/volumes/stockhome_mysql:/var/lib/mysql - ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro backend: @@ -20,11 +20,8 @@ services: dockerfile: Dockerfile ports: - "8004:8004" - entrypoint: + environment: NODE_ENV: production depends_on: - database restart: unless-stopped - -volumes: - stockhome_mysql: