Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ node_modules/

# testing
/coverage
coverage/

# build outputs
build/
Expand All @@ -32,4 +33,4 @@ pnpm-debug.log*

# OS/editor
.DS_Store
.vscode/
.vscode/
4 changes: 4 additions & 0 deletions backend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const cors = require("cors");
const helmet = require("helmet");
const bodyParser = require("body-parser");
const path = require("path");
require("express-async-errors");

const exerciseRoutes = require("./routes/exercises");
const workoutRoutes = require("./routes/workouts");
Expand Down Expand Up @@ -42,4 +43,7 @@ app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "../frontend/build", "index.html"));
});

const errorHandler = require("./middleware/errorHandler");
app.use(errorHandler);

module.exports = app;
42 changes: 14 additions & 28 deletions backend/middleware/authMiddleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,22 @@ const jwt = require("jsonwebtoken");
const User = require("../models/User");

const protect = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ message: "Not authorized, no token" });
}

const token = authHeader.split(" ")[1];

const decoded = jwt.verify(token, process.env.JWT_SECRET);

const user = await User.findById(decoded.userId).select("-passwordHash");

if (!user) {
return res.status(401).json({ message: "Not authorized, user not found" });
}

req.user = user;
next();

} catch (error) {
return res.status(401).json({ message: "Not authorized, token failed" });
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ message: "Not authorized, no token" });
}
};

const adminOnly = (req, res, next) => {
if (!req.user || !req.user.isAdmin) {
return res.status(403).json({ message: "Admin access required" });
const token = authHeader.split(" ")[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findById(decoded.userId).select("-passwordHash");
if (!user) {
return res.status(401).json({ message: "Not authorized, user not found" });
}
req.user = user;
next();
} catch (error) {
return res.status(401).json({ message: "Not authorized, token failed" });
}
};

module.exports = { protect, adminOnly };
module.exports = { protect };
13 changes: 13 additions & 0 deletions backend/middleware/errorHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Central error handler — must be the LAST app.use().
const errorHandler = (err, req, res, next) => {
if (err.name === "CastError") {
return res.status(400).json({ message: "Invalid ID format" }); // bad ObjectId
}
if (err.name === "ValidationError") {
return res.status(400).json({ message: err.message }); // mongoose validation
}
console.error("Unhandled error:", err.message);
res.status(500).json({ message: "Server Error" });
};

module.exports = errorHandler;
20 changes: 10 additions & 10 deletions backend/middleware/rateLimiters.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
const rateLimit = require("express-rate-limit");

// Disable rate limiting during tests so the suite isn't throttled.
const skip = () => process.env.NODE_ENV === "test";

// Applied to every /api route as a baseline guard against abuse.
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 300,
standardHeaders: true,
legacyHeaders: false,
skip,
message: { message: "Too many requests, please try again later." },
windowMs: 15 * 60 * 1000,
max: 300,
standardHeaders: true,
legacyHeaders: false,
skip,
message: { message: "Too many requests, please try again later." },
});

// Tighter limit for auth endpoints to slow down credential stuffing / brute force.
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
skip,
message: { message: "Too many authentication attempts, please try again later." },
});

// AI generation is expensive (per-call LLM cost), so it gets the strictest limit.
const aiLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
windowMs: 60 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
skip,
message: { message: "AI generation limit reached, please try again later." },
});

Expand Down
4 changes: 0 additions & 4 deletions backend/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,6 @@ const userSchema = new mongoose.Schema(
type: String,
required: true,
},
isAdmin: {
type: Boolean,
default: false,
},
fitnessProfile: {
goal: {
type: String,
Expand Down
10 changes: 10 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "jest --runInBand --forceExit"
"test": "jest --runInBand --forceExit",
"seed": "node scripts/seedExercises.js",
"test:coverage": "jest --runInBand --forceExit --coverage"
},
"jest": {
"testEnvironment": "node",
"setupFilesAfterEnv": ["<rootDir>/tests/setup.js"]
"setupFilesAfterEnv": ["<rootDir>/tests/setup.js"],
"coverageThreshold": {
"global": { "statements": 90, "branches": 80, "functions": 90, "lines": 90 }
}
},
"keywords": [],
"author": "",
Expand All @@ -21,6 +26,7 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^8.5.2",
"helmet": "^8.2.0",
"jsonwebtoken": "^9.0.3",
Expand Down
Loading
Loading