Fork spilum-net-backend

This commit is contained in:
Apher 2026-06-28 02:53:52 +00:00
commit d2ab2dff9d
21 changed files with 2505 additions and 0 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
dist
dist-ssr
npm-debug.log
.DS_Store
.env

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.env
node_modules/

12
Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5000
CMD ["node", "src/server.js"]

1796
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "spilum-backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "nodemon ./src/server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"bcryptjs": "^3.0.3",
"body-parser": "^2.2.2",
"cors": "^2.8.6",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.1",
"pg": "^8.19.0",
"sequelize": "^6.37.7"
},
"devDependencies": {
"dotenv": "^17.3.1",
"nodemon": "^3.1.14"
}
}

View File

@ -0,0 +1,3 @@
module.exports = {
secret: process.env.JWT_SECRET
};

14
src/config/db.config.js Normal file
View File

@ -0,0 +1,14 @@
module.exports = {
HOST: process.env.PG_HOST,
USER: process.env.PG_USER,
PASSWORD: process.env.PG_PASS,
DB: process.env.PG_DB,
PORT: process.env.PG_PORT,
dialect: "postgres",
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
}
}

View File

@ -0,0 +1,102 @@
const db = require("../models");
const config = require("../config/auth.config");
const User = db.user;
const Role = db.role;
const Op = db.Sequelize.Op;
var jwt = require("jsonwebtoken");
var bcrypt = require("bcryptjs");
// Sign Up user
exports.signup = async (req, res) => {
try {
const {username, email, password, roles} = req.body;
if (!username || !email || !password) {
return res.status(400).send({message: "Missing required fields: username, email, password"});
}
const user = await User.create({
username,
email,
password: bcrypt.hashSync(password, 8)
})
.then(user => {
if (roles) {
Role.findAll({
where: {
name: {
[Op.or]: req.body.roles
}
}
})
.then(roles => {
user.setRoles(roles).then(() => {
res.status(201).send({message: "User was created and roles were added successfully.", user, roles});
})
});
} else {
user.setRoles([1]).then(() => {
res.status(201).send({message: "User was created successfully.", user})
})
}
})
} catch (err) {
console.error("signup error:", err);
return res.status(500).send({message: err.message || "internal server error."});
}
};
exports.signin = async (req, res) => {
try {
const {username, password} = req.body;
if (!username || !password) {
return res.status(400).send({message: "Missing required fields: username, password."});
}
const user = await User.findOne({
where: {
username: username
}
});
if (!user) return res.status(404).send({message: "User not found."});
let passwordIsValid = bcrypt.compareSync(
password,
user.password
);
if (!passwordIsValid) return res.status(401).send({accessToken: null,message: "Invalid password."});
const token = jwt.sign(
{id: user.id},
config.secret,
{
algorithm: "HS256",
allowInsecureKeySizes: true,
expiresIn: 86400 // 24 hours
}
);
let authorities = [];
user.getRoles().then(roles => {
for (let i = 0; i < roles.length; i++) {
authorities.push("ROLE_" + roles[i].name.toUpperCase());
}
res.status(200).send({
id: user.id,
username: user.username,
email: user.email,
roles: authorities,
accessToken: token
})
})
} catch (err) {
console.error("signin error:", err);
res.status(500).send({message: err.message || "Internal server error."});
}
};

View File

@ -0,0 +1,119 @@
const db = require("../models");
const {sequelize, Sequelize} = db;
const {literal} = Sequelize;
const Bill = db.bill;
// Get all bills
exports.getBills = async (req, res) => {
try {
const bills = await Bill.findAll();
return res.status(200).send(bills);
} catch (err) {
console.error("getBills error:", err);
return res.status(500).send({message: err.message || "Internal server error."});
}
};
// Get all bills by month
exports.getBillsByMonth = async (req, res) => {
try {
const month = parseInt(req.params.month, 10);
if (!Number.isInteger(month) || month < 1 || month > 12) {
return res.status(400).send({message: "Invalid month parameter. Must be 1-12."});
}
const bills = await Bill.findAll({
where: literal(`EXTRACT(MONTH FROM "due") = ${month}`)
});
return res.status(200).send(bills);
} catch (err) {
console.error("getBillsByMonth error:", err);
return res.status(500).send({message: err.message || "Internal server error."});
}
};
// Get one bill by ID
exports.getBillById = async (req, res) => {
try {
const id = req.params.id;
const bill = await Bill.findByPk(id);
if (!bill) return res.status(404).send({message: "Bill not found."});
return res.status(200).send(bill);
} catch (err) {
console.error("getBillsByMonth error:", err);
return res.status(500).send({message: err.message || "Internal server error."});
}
};
// Create bill
exports.createBill = async (req, res) => {
try {
const {name, amount, due, type, shared} = req.body;
if (!name || amount == null || !due) {
return res.status(400).send({message: "Missing required fields: name, amount, due."});
}
const bill = await Bill.create({
name,
amount,
due,
type: type || null,
shared: !!shared // wtf is this?
});
return res.status(201).send({message: "Bill created successfully.", bill});
} catch (err) {
console.error("createBill error:", err);
return res.status(500).send({message: err.message || "Internal server error."});
}
};
// Delete one bill by id
exports.deleteBill = async (req, res) => {
try {
const id = req.params.id;
const deleted = await Bill.destroy({where: {id}});
if (!deleted) return res.status(404).send({message: "Bill not found."});
return res.status(200).send({message: "Bill deleted successfully."});
} catch (err) {
console.error("deleteBill error:", err);
return res.status(500).send({message: err.message || "Internal server error."});
}
};
// Update one bill by id
exports.updateBill = async (req, res) => {
try {
const id = req.params.id;
const bill = await Bill.findByPk(id);
if (!bill) return res.status(404).send({message: "Bill not found."});
const {name, amount, due, type, shared} = req.body;
// Only update provided fields
if (name !== undefined) bill.name = name;
if (amount !== undefined) bill.amount = amount;
if (due !== undefined) bill.due = due;
if (type !== undefined) bill.type = type;
if (shared !== undefined) bill.shared = shared;
await bill.save();
return res.status(200).send({message: "Bill updated successfully.", bill});
} catch (err) {
console.error("updateBill error:", err);
return res.status(500).send({message: err.message || "Internal server error."});
}
};

View File

@ -0,0 +1,15 @@
exports.allAccess = async (req, res) => {
res.status(200).send("Public Content.");
};
exports.userBoard = async (req, res) => {
res.status(200).send("User Content.");
};
exports.adminBoard = async (req, res) => {
res.status(200).send("Admin Content.");
};
exports.moderatorBoard = async (req, res) => {
res.status(200).send("Moderator Content.");
};

41
src/middleware/authJwt.js Normal file
View File

@ -0,0 +1,41 @@
const jwt = require("jsonwebtoken");
const config = require("../config/auth.config");
const db = require("../models");
const User = db.user;
verifyToken = (req, res, next) => {
const token = req.headers["x-access-token"] || req.headers.authorization;
if (!token) return res.status(403).send({message: "No token provided!"});
const raw = token.startsWith("Bearer ") ? token.slice(7) : token;
jwt.verify(raw, config.secret, (err, decoded) => {
if (err) return res.status(401).send({message: "Unauthorized!",});
req.userId = decoded.id;
next();
});
};
const requireRole = (roleNames) => async (req, res, next) => {
try {
if (!req.userId) return res.status(401).send({message: "User ID missing in request."});
const user = await User.findByPk(req.userId);
if (!user) return res.status(404).send({message: "User not found."});
const roles = await user.getRoles();
const has = roles.some(r => roleNames.includes(r.name));
if (!has) return res.status(403).send({message: `Require ${roleNames.join(" or ")} Role!`});
next();
} catch (err) {
console.error("Role check error:", err);
res.status(500).send({message: "Internal server error."});
}
};
module.exports = {
verifyToken,
isAdmin: requireRole(["admin"]),
isModerator: requireRole(["moderator"]),
isModeratorOrAdmin: requireRole(["moderator", "admin"])
};

7
src/middleware/index.js Normal file
View File

@ -0,0 +1,7 @@
const authJwt = require("./authJwt");
const verifySignUp = require("./verifySignUp");
module.exports = {
authJwt,
verifySignUp
};

View File

@ -0,0 +1,57 @@
const db = require('../models');
const ROLES = db.ROLES;
const User = db.user;
checkDuplicateUsernameOrEmail = (req, res, next) => {
// Username
User.findOne({
where: {
username: req.body.username
}
}).then(user => {
if (user) {
res.status(400).send({
message: "Failed! Username is already in use!"
});
return;
}
// Email
User.findOne({
where: {
email: req.body.email
}
}).then(user => {
if (user) {
res.status(400).send({
message: "Failed! Email is already in use!"
});
return
}
next();
});
});
};
checkRolesExisted = (req, res, next) => {
if (req.body.roles) {
for (let i = 0; i < req.body.roles.length; i++) {
if (!ROLES.includes(req.body.roles[i])) {
res.status(400).send({
message: "Failed! Role does not exist = " + req.body.roles[i]
});
return;
}
}
}
next();
}
const verifySignUp = {
checkDuplicateUsernameOrEmail: checkDuplicateUsernameOrEmail,
checkRolesExisted: checkRolesExisted
};
module.exports = verifySignUp;

36
src/models/bill.model.js Normal file
View File

@ -0,0 +1,36 @@
module.exports = (sequelize, DataTypes) => {
const Bill = sequelize.define("bills", {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
name: {
type: DataTypes.STRING,
allowNull: false
},
amount: {
type: DataTypes.INTEGER,
allowNull: false
},
due: {
type: DataTypes.DATEONLY,
allowNull: false
},
type: {
type: DataTypes.STRING,
allowNull: true
},
shared: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
}
}, {
tableName: "bills",
timestamps: true
});
return Bill;
};

40
src/models/index.js Normal file
View File

@ -0,0 +1,40 @@
const config = require("../config/db.config");
const Sequelize = require("sequelize");
const sequelize = new Sequelize(
config.DB,
config.USER,
config.PASSWORD,
{
host: config.HOST,
dialect: config.dialect,
port: config.PORT,
pool: {
max: config.pool.max,
min: config.pool.min,
acquire: config.pool.acquire,
idle: config.pool.idle
}
}
);
const db = {};
db.Sequelize = Sequelize;
db.sequelize = sequelize;
db.user = require('../models/user.model')(sequelize, Sequelize);
db.role = require('../models/role.model')(sequelize, Sequelize);
db.bill = require('../models/bill.model')(sequelize, Sequelize);
db.role.belongsToMany(db.user, {
through: "user_roles"
});
db.user.belongsToMany(db.role, {
through: "user_roles"
});
db.ROLES = ["user", "admin", "moderator"];
module.exports = db;

19
src/models/role.model.js Normal file
View File

@ -0,0 +1,19 @@
module.exports = (sequelize, DataTypes) => {
const Role = sequelize.define("roles", {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
name: {
type: DataTypes.STRING,
allowNull: false
}
}, {
tableName: "roles",
timestamps: true
});
return Role;
}

15
src/models/user.model.js Normal file
View File

@ -0,0 +1,15 @@
module.exports = (sequelize, Sequelize) => {
const User = sequelize.define("users", {
username: {
type: Sequelize.STRING
},
email: {
type: Sequelize.STRING
},
password: {
type: Sequelize.STRING
}
});
return User;
}

View File

@ -0,0 +1,9 @@
const express = require("express");
const router = express.Router();
const controller = require("../controllers/auth.controller");
// Auth routes
router.post("/signup", controller.signup);
router.post("/signin", controller.signin);
module.exports = router;

35
src/routes/bill.routes.js Normal file
View File

@ -0,0 +1,35 @@
const express = require("express");
const router = express.Router();
const { authJwt } = require("../middleware");
const controller = require("../controllers/bill.controller");
/* Bill model
id: PK int
name: string
amount: int
due: date
type: string
shared: bool
*/
// Bill routes
// Get all bills
router.get("/", [authJwt.verifyToken, authJwt.isAdmin], controller.getBills);
// Get bills by month
router.get("/month/:month", [authJwt.verifyToken, authJwt.isAdmin], controller.getBillsByMonth);
// Get bill by id
router.get("/:id", [authJwt.verifyToken, authJwt.isAdmin], controller.getBillById);
// Create bill
router.post("/", [authJwt.verifyToken, authJwt.isAdmin], controller.createBill);
// Delete bill by id
router.delete("/:id", [authJwt.verifyToken, authJwt.isAdmin], controller.deleteBill);
// Update bill by id
router.put("/:id", [authJwt.verifyToken, authJwt.isAdmin], controller.updateBill);
module.exports = router;

20
src/routes/user.routes.js Normal file
View File

@ -0,0 +1,20 @@
const express = require("express");
const router = express.Router();
const { authJwt } = require("../middleware");
const controller = require("../controllers/user.controller");
// User routes
// All access page
router.get("/all", /*[authJwt.verifyToken, authJwt.isAdmin],*/ controller.allAccess);
// User access page
router.get("/user", [authJwt.verifyToken], controller.userBoard);
// Mod access page
router.get("/mod", [authJwt.verifyToken, authJwt.isModerator], controller.moderatorBoard);
// Admin access page
router.get("/admin", [authJwt.verifyToken, authJwt.isAdmin], controller.adminBoard);
module.exports = router;

127
src/server.js Normal file
View File

@ -0,0 +1,127 @@
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
const morgan = require("morgan");
const { sequelize, Sequelize } = require('./models');
const db = require("./models");
const Role = db.role;
const createApp = () => {
const app = express();
// Security headers
app.use(helmet());
// CORS restrict in production (set ALLOWED_ORIGINS env to comma-separated list)
const corsOptions = {
origin: process.env.NODE_ENV === "production"
? (process.env.ALLOWED_ORIGINS || "").split(",").map(s => s.trim())
: true,
optionsSuccessStatus: 200
};
app.use(cors(corsOptions));
// Logging verbose in dev
app.use(morgan(process.env.NODE_ENV === "production" ? "combined" : "dev"));
// Body parsing with size limits
app.use(express.json({limit: "10kb"}));
app.use(express.urlencoded({extended: false, limit: "10kb"}));
// Apply rate limiting in production
if (process.env.NODE_ENV === "production") {
app.use(rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit per IP
standardHeaders: true,
legacyHeaders: false
}));
}
// Health check
app.get("/health", (req, res) => res.status(200).send({status: "ok"}));
// Mount routes which should have an Express Router
app.use("/auth", require("./routes/auth.routes"));
app.use("/user", require("./routes/user.routes"));
app.use("/bills", require("./routes/bill.routes"));
// 404 handler
app.use((req, res) => res.status(404).send({message: "Not Found"}));
// Error handler (MUST BE LAST)
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status || 500).send({message: err.message || "Internal Server Error"});
});
return app;
};
// Initialize roles safely
const initRoles = async () => {
const roles = ["user", "moderator", "admin"];
for (const name of roles) {
await Role.findOrCreate({where: {name}, defaults: {name}});
}
};
const start = async () => {
try {
// Validate important env vars
if (!process.env.JWT_SECRET) {
console.warn("JWT_SECRET not set. Ensure secure secret in production.");
}
// Sync DB (not forced in production)
const doForce = process.env.FORCE_DB_SYNC === "true" && process.env.NODE_ENV !== "production";
await sequelize.authenticate();
await sequelize.sync({force: doForce});
if (doForce) console.info("Database dropped and resynced (force=true).");
await initRoles();
const app = createApp();
// Set port and listen
const PORT = parseInt(process.env.PORT, 10) || 5000;
const server = app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
// Graceful shutdown
const shutdown = async (signal) => {
console.info(`Received signal: ${signal}. Shutting down server...`);
server.close(async () => {
try {
await sequelize.close();
console.info("Database connection closed. Exiting.");
process.exit(0);
} catch (err) {
console.error("Error during shutdown:", err);
process.exit(1);
}
});
// Force exit after timeout
setTimeout(() => {
console.error("Could not close connections in time, forcing shut down");
process.exit(1);
}, 30_000);
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
} catch (err) {
console.error("Failed to start server:", err);
process.exit(1);
}
};
start();
// Export createApp for tests
module.exports = { createApp };