Cards!
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 2m26s

This commit is contained in:
2026-09-20 03:15:16 +02:00
parent ed187af3dd
commit 770f302bc3
34 changed files with 3302 additions and 677 deletions

View File

@@ -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=

View File

@@ -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",

View File

@@ -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"

View File

@@ -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();

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

30
backend/src/seedAdmin.js Normal file
View File

@@ -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;