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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
backend:
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:7
ports:
- 27017:27017
defaults:
run:
working-directory: backend
env:
MONGO_URI: mongodb://localhost:27017/fitforge_test
JWT_SECRET: test-secret
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: backend/package-lock.json
- run: npm ci
- run: npm test

frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm test -- --watchAll=false
- run: npm run build
58 changes: 35 additions & 23 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# production
/build

# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local

npm-debug.log*
yarn-debug.log*
yarn-error.log*
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
node_modules/
**/node_modules/
/.pnp
.pnp.js

# environment variables
.env
.env.*
!.env.example

# testing
/coverage

# build outputs
build/
dist/

# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local

# logs
npm-debug.log*
yarn-debug.log*
pnpm-debug.log*

# OS/editor
.DS_Store
.vscode/
17 changes: 14 additions & 3 deletions 404.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Refresh" content="0; URL='/'" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404 - Page Not Found</title>
<script type="text/javascript">
var path = window.location.pathname;
var redirectTo = "/FitForge" + "/index.html";
window.history.replaceState(null, "", path);
window.location.replace(redirectTo);
</script>
</head>
<body>
<p>If you are not redirected, <a href="/">click here</a>.</p>
<h1>404 - Page Not Found</h1>
<p>
The page you are looking for does not exist. You will be redirected
shortly.
</p>
</body>
</html>
3 changes: 0 additions & 3 deletions backend/.env

This file was deleted.

7 changes: 7 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
PORT=5000
MONGO_URI=mongodb://localhost:27017/fitforge
# Comma-separate multiple origins, e.g. http://localhost:3000,https://nthuang.github.io
CORS_ORIGIN=http://localhost:3000
NODE_ENV=development
# Generate: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
JWT_SECRET=f527c5dd4ef540e3f2e6060b9433cb4161c684ef4824614bf85aca955d686f182e0a0b61b922073e14d8f9de4d51d4f3acaed575527ce6e6baffbe8efc1e4b7ecd
45 changes: 45 additions & 0 deletions backend/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const bodyParser = require("body-parser");
const path = require("path");

const exerciseRoutes = require("./routes/exercises");
const workoutRoutes = require("./routes/workouts");
const splitRoutes = require("./routes/splits");
const authRoutes = require("./routes/auth");
const aiRoutes = require("./routes/ai");
const { apiLimiter, authLimiter, aiLimiter } = require("./middleware/rateLimiters");

const app = express();

const allowedOrigins = (process.env.CORS_ORIGIN || "http://localhost:3000")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);

app.use(helmet({ contentSecurityPolicy: false }));
app.use(
cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) return callback(null, true);
return callback(new Error("Not allowed by CORS"));
},
})
);
app.use(bodyParser.json({ limit: "1mb" }));

app.use("/api", apiLimiter);
app.use("/api/exercises", exerciseRoutes);
app.use("/api/workouts", workoutRoutes);
app.use("/api/splits", splitRoutes);
app.use("/api/auth", authLimiter, authRoutes);
app.use("/api/ai", aiLimiter, aiRoutes);

// Serve the React build + SPA catchall
app.use(express.static(path.join(__dirname, "../frontend/build")));
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "../frontend/build", "index.html"));
});

module.exports = app;
37 changes: 37 additions & 0 deletions backend/middleware/authMiddleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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" });
}
};

const adminOnly = (req, res, next) => {
if (!req.user || !req.user.isAdmin) {
return res.status(403).json({ message: "Admin access required" });
}
next();
};

module.exports = { protect, adminOnly };
33 changes: 33 additions & 0 deletions backend/middleware/rateLimiters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const rateLimit = require("express-rate-limit");

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." },
});

// 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,
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
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { message: "AI generation limit reached, please try again later." },
});

module.exports = { apiLimiter, authLimiter, aiLimiter };
23 changes: 20 additions & 3 deletions backend/models/Split.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
const mongoose = require("mongoose");

const splitSchema = new mongoose.Schema({
name: { type: String, required: true },
workouts: [{ type: mongoose.Schema.Types.ObjectId, ref: "Workout" }],
});
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true,
index: true,
},
name: {
type: String,
required: true,
trim: true,
},
workouts: [
{
type: mongoose.Schema.Types.ObjectId,
ref: "Workout",
}
],
},
{ timestamps: true }
);

module.exports = mongoose.model("Split", splitSchema);
57 changes: 57 additions & 0 deletions backend/models/User.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const mongoose = require("mongoose");

const userSchema = new mongoose.Schema(
{
name: {
type: String,
required: true,
trim: true
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true,
},
passwordHash: {
type: String,
required: true,
},
isAdmin: {
type: Boolean,
default: false,
},
fitnessProfile: {
goal: {
type: String,
enum: ["strength", "hypertrophy", "fat_loss", "general_fitness"],
default: "general_fitness",
},
experienceLevel: {
type: String,
enum: ["beginner", "intermediate", "advanced"],
default: "beginner",
},
daysPerWeek: {
type: Number,
default: 3,
},
sessionLengthMinutes: {
type: Number,
default: 60,
},
equipment: {
type: [String],
default: [],
},
limitations: {
type: [String],
default: [],
},
},
},
{timestamps: true}
);

module.exports = mongoose.model("User", userSchema);
26 changes: 21 additions & 5 deletions backend/models/Workout.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
const mongoose = require("mongoose");
const { Schema } = mongoose;

const workoutSchema = new Schema({
name: { type: String, required: true },
exercises: [{ type: Number }],
});
const workoutSchema = new mongoose.Schema(
{
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true,
index: true,
},
name: {
type: String,
required: true,
trim: true,
},
exercises: [
{
type: Number,
},
],
},
{ timestamps: true }
);

module.exports = mongoose.model("Workout", workoutSchema);
Loading
Loading