diff --git a/backend/.env.production b/backend/.env.production index e749afb..1c1b538 100644 --- a/backend/.env.production +++ b/backend/.env.production @@ -1,3 +1,9 @@ PORT=5000 DB_URI=mongodb://192.168.1.7:27017/ NODE_ENV=production + +# Admin dashboard (used once to seed the admin account on first boot) +ADMIN_USERNAME= +ADMIN_PASSWORD= +# Secret for signing admin JWT tokens (generate: openssl rand -hex 32) +JWT_SECRET= diff --git a/backend/package-lock.json b/backend/package-lock.json index 59c23aa..bcc01ef 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,9 +9,11 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "bcryptjs": "^3.0.3", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", + "jsonwebtoken": "^9.0.3", "mongoose": "^9.3.0", "nodemon": "^3.1.14", "socket.io": "^4.8.3" @@ -118,6 +120,15 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -187,6 +198,12 @@ "node": ">=20.19.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -357,6 +374,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -806,6 +832,49 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kareem": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz", @@ -815,6 +884,48 @@ "node": ">=18.0.0" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1208,6 +1319,26 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", diff --git a/backend/package.json b/backend/package.json index 411a23b..ced4947 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,9 +10,11 @@ "dev": "nodemon src/index.js" }, "dependencies": { + "bcryptjs": "^3.0.3", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", + "jsonwebtoken": "^9.0.3", "mongoose": "^9.3.0", "nodemon": "^3.1.14", "socket.io": "^4.8.3" diff --git a/backend/src/index.js b/backend/src/index.js index 8e130b9..b9f1966 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -25,12 +25,16 @@ const io = new Server(httpServer, { }); const connectDB = require("./db"); +const seedAdmin = require("./seedAdmin"); const gridCellsRouter = require("./routes/gridCells"); +const authRouter = require("./routes/auth"); +const adminRouter = require("./routes/admin"); +const hiboliaRouter = require("./routes/hibolia"); gridCellsRouter.setIO(io); // connect database -connectDB(); +connectDB().then(seedAdmin); app.use( cors({ @@ -56,6 +60,9 @@ app.get("/api/test", (req, res) => { }); app.use("/api/grid-cells", gridCellsRouter); +app.use("/api/auth", authRouter); +app.use("/api/admin", adminRouter); +app.use("/api/hibolia", hiboliaRouter); app.get("/api/status", (req, res) => { const mem = process.memoryUsage(); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..4cb4f68 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,33 @@ +const jwt = require("jsonwebtoken"); +const crypto = require("crypto"); + +let fallbackSecret = null; + +function getJwtSecret() { + if (process.env.JWT_SECRET) return process.env.JWT_SECRET; + if (!fallbackSecret) { + fallbackSecret = crypto.randomBytes(32).toString("hex"); + console.warn( + "JWT_SECRET is not set — using an ephemeral secret. Sessions will reset on every restart." + ); + } + return fallbackSecret; +} + +function requireAuth(req, res, next) { + const header = req.headers.authorization || ""; + const [scheme, token] = header.split(" "); + + if (scheme !== "Bearer" || !token) { + return res.status(401).json({ error: "Unauthorized" }); + } + + try { + req.user = jwt.verify(token, getJwtSecret()); + return next(); + } catch (e) { + return res.status(401).json({ error: "Invalid or expired session" }); + } +} + +module.exports = { requireAuth, getJwtSecret }; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js new file mode 100644 index 0000000..c47e3a8 --- /dev/null +++ b/backend/src/routes/admin.js @@ -0,0 +1,35 @@ +const express = require("express"); +const mongoose = require("mongoose"); +const GridCell = require("../schemas/GridCell"); +const { requireAuth } = require("../middleware/auth"); + +const router = express.Router(); + +router.use(requireAuth); + +// GET /api/admin/stats — dashboard overview +router.get("/stats", async (req, res) => { + try { + const uptime = Math.floor(process.uptime()); + const mem = process.memoryUsage(); + + const totalCells = await GridCell.countDocuments(); + + res.json({ + status: "online", + mongo: mongoose.connection.readyState === 1 ? "connected" : "disconnected", + uptime, + memory: { + rss: mem.rss, + heapUsed: mem.heapUsed, + }, + grid: { + totalCells, + }, + }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..b5f70da --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,82 @@ +const express = require("express"); +const bcrypt = require("bcryptjs"); +const jwt = require("jsonwebtoken"); +const AdminUser = require("../schemas/AdminUser"); +const { requireAuth, getJwtSecret } = require("../middleware/auth"); + +const router = express.Router(); + +const MAX_ATTEMPTS = 5; +const WINDOW_MS = 15 * 60 * 1000; +const attempts = new Map(); + +function isRateLimited(key) { + const entry = attempts.get(key); + if (!entry) return false; + if (Date.now() > entry.resetAt) { + attempts.delete(key); + return false; + } + return entry.count >= MAX_ATTEMPTS; +} + +function registerFailure(key) { + const entry = attempts.get(key); + if (!entry || Date.now() > entry.resetAt) { + attempts.set(key, { count: 1, resetAt: Date.now() + WINDOW_MS }); + } else { + entry.count++; + } +} + +// POST /api/auth/login +router.post("/login", async (req, res) => { + try { + const key = req.ip; + if (isRateLimited(key)) { + return res.status(429).json({ error: "Too many attempts. Try again later." }); + } + + const { username, password } = req.body || {}; + if (!username || !password) { + return res.status(400).json({ error: "username and password are required" }); + } + + const admin = await AdminUser.findOne({ + username: String(username).trim().toLowerCase(), + }); + const valid = admin && (await bcrypt.compare(String(password), admin.passwordHash)); + + if (!valid) { + registerFailure(key); + return res.status(401).json({ error: "Invalid credentials" }); + } + + attempts.delete(key); + admin.lastLoginAt = new Date(); + await admin.save(); + + const token = jwt.sign( + { sub: admin._id.toString(), username: admin.username }, + getJwtSecret(), + { expiresIn: "12h" } + ); + + res.json({ token, username: admin.username }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// GET /api/auth/me — validates the stored token +router.get("/me", requireAuth, async (req, res) => { + try { + const admin = await AdminUser.findById(req.user.sub).lean(); + if (!admin) return res.status(401).json({ error: "Invalid or expired session" }); + res.json({ username: admin.username, lastLoginAt: admin.lastLoginAt }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/hibolia.js b/backend/src/routes/hibolia.js new file mode 100644 index 0000000..fbd5ab0 --- /dev/null +++ b/backend/src/routes/hibolia.js @@ -0,0 +1,51 @@ +const express = require("express"); +const HiboliaOrder = require("../schemas/HiboliaOrder"); +const { requireAuth } = require("../middleware/auth"); + +const router = express.Router(); + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +// POST /api/hibolia/orders — public order submission +router.post("/orders", async (req, res) => { + try { + const { name, surname, email, message, color, shipping } = req.body || {}; + if (!name || !surname || !email || !message || !color || !shipping) { + return res + .status(400) + .json({ error: "name, surname, email, message, color and shipping are required" }); + } + if (!HiboliaOrder.COLORS.includes(color)) { + return res.status(400).json({ error: "invalid color" }); + } + if (!EMAIL_RE.test(String(email))) { + return res.status(400).json({ error: "invalid email" }); + } + const order = await HiboliaOrder.create({ + name, + surname, + email, + message, + color, + shipping, + }); + res.status(201).json({ id: order._id }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// GET /api/hibolia/orders — admin only (dashboard) +router.get("/orders", requireAuth, async (req, res) => { + try { + const orders = await HiboliaOrder.find() + .sort({ createdAt: -1 }) + .limit(500) + .lean(); + res.json(orders); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +module.exports = router; diff --git a/backend/src/schemas/AdminUser.js b/backend/src/schemas/AdminUser.js new file mode 100644 index 0000000..85804b8 --- /dev/null +++ b/backend/src/schemas/AdminUser.js @@ -0,0 +1,20 @@ +const mongoose = require("mongoose"); + +const adminUserSchema = new mongoose.Schema( + { + username: { + type: String, + required: true, + unique: true, + lowercase: true, + trim: true, + }, + passwordHash: { type: String, required: true }, + lastLoginAt: { type: Date }, + }, + { timestamps: true } +); + +const AdminUser = mongoose.model("AdminUser", adminUserSchema); + +module.exports = AdminUser; diff --git a/backend/src/schemas/HiboliaOrder.js b/backend/src/schemas/HiboliaOrder.js new file mode 100644 index 0000000..9eef8bb --- /dev/null +++ b/backend/src/schemas/HiboliaOrder.js @@ -0,0 +1,34 @@ +const mongoose = require("mongoose"); + +const COLORS = [ + "red", + "gold", + "silver", + "orange", + "yellow", + "green", + "blue", + "purple", + "black", + "white", + "pink", + "none", +]; + +const hiboliaOrderSchema = new mongoose.Schema( + { + name: { type: String, required: true, trim: true, maxlength: 100 }, + surname: { type: String, required: true, trim: true, maxlength: 100 }, + email: { type: String, required: true, trim: true, lowercase: true }, + message: { type: String, required: true, trim: true, maxlength: 2000 }, + color: { type: String, required: true, enum: COLORS }, + shipping: { type: String, required: true, trim: true, maxlength: 2000 }, + }, + { timestamps: true } +); + +const HiboliaOrder = mongoose.model("HiboliaOrder", hiboliaOrderSchema); + +HiboliaOrder.COLORS = COLORS; + +module.exports = HiboliaOrder; diff --git a/backend/src/seedAdmin.js b/backend/src/seedAdmin.js new file mode 100644 index 0000000..f9f5178 --- /dev/null +++ b/backend/src/seedAdmin.js @@ -0,0 +1,30 @@ +const bcrypt = require("bcryptjs"); +const AdminUser = require("./schemas/AdminUser"); + +// Seeds the single admin account from env vars on first boot. +// ADMIN_PASSWORD is only used to generate the initial bcrypt hash; +// after seeding, change credentials directly in the database. +const seedAdmin = async () => { + try { + const count = await AdminUser.countDocuments(); + if (count > 0) return; + + const username = process.env.ADMIN_USERNAME; + const password = process.env.ADMIN_PASSWORD; + + if (!username || !password) { + console.warn( + "No admin account exists. Set ADMIN_USERNAME and ADMIN_PASSWORD to seed one." + ); + return; + } + + const passwordHash = await bcrypt.hash(password, 12); + await AdminUser.create({ username, passwordHash }); + console.log(`Admin account seeded for user "${username}"`); + } catch (error) { + console.error("Admin seeding error:", error); + } +}; + +module.exports = seedAdmin; diff --git a/frontend/app/app.vue b/frontend/app/app.vue index fa56e7c..0dd9628 100644 --- a/frontend/app/app.vue +++ b/frontend/app/app.vue @@ -4,9 +4,8 @@
+ {{ $t('admin.dashboard.welcome') }} {{ auth.user.value.username }} +
+ +✗ {{ errorMsg }}
+{{ $t('admin.dashboard.loading') }}
+ + +{{ $t('admin.dashboard.orders_empty') }}
+| {{ $t('admin.dashboard.col_date') }} | +{{ $t('admin.dashboard.col_name') }} | +{{ $t('admin.dashboard.col_email') }} | +{{ $t('admin.dashboard.col_message') }} | +{{ $t('admin.dashboard.col_color') }} | +{{ $t('admin.dashboard.col_shipping') }} | +
|---|---|---|---|---|---|
| {{ formatDate(order.createdAt) }} | +{{ order.name }} {{ order.surname }} | +{{ order.email }} | +{{ order.message }} | ++ + + {{ $t(`order.hibolia.colors.${order.color}`) }} + + | +{{ order.shipping }} | +
{{ $t('admin.login.prompt') }}
+ + +{{ project.title }}
-{{ project.description }}
-{{ project.title }}
+{{ project.description }}
+
+
-
+
-
{{ $t('order.hibolia.thanks.next') }}
+{{ project.title }}
+{{ project.description }}
+