diff --git a/backend/backend/package.json b/backend/backend/package.json new file mode 100644 index 000000000..09d13ed92 --- /dev/null +++ b/backend/backend/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "cloudinary": "^1.41.3" + } +} diff --git a/backend/middleware/authenticate.js b/backend/middleware/authenticate.js new file mode 100644 index 000000000..16bb6a8d4 --- /dev/null +++ b/backend/middleware/authenticate.js @@ -0,0 +1,18 @@ +import jwt from "jsonwebtoken" + +export default function authenticate(req, res, next) { + const authHeader = req.headers.authorization + + if (!authHeader?.startsWith('Bearer ')) + return res.status(401).json({ msg: 'Missing token' }) + + + const token = authHeader.split(' ')[1] + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET) + req.user = decoded + next() + } catch (err) { + return res.status(401).json({ msg: 'Invalid token' }) + } +} \ No newline at end of file diff --git a/backend/middleware/authorize.js b/backend/middleware/authorize.js new file mode 100644 index 000000000..26440562e --- /dev/null +++ b/backend/middleware/authorize.js @@ -0,0 +1,11 @@ +export default function authorize(...allowedRoles) { + return (req, res, next) => { + if (!req.user) { + return res.status(401).json({ msg: "Unauthenticated" }) + } + if (!allowedRoles.includes(req.user.role)) { + return res.status(403).json({ msg: "Insufficient rights" }) + } + next() + } +} \ No newline at end of file diff --git a/backend/middleware/verifyToken.js b/backend/middleware/verifyToken.js new file mode 100644 index 000000000..307ee015d --- /dev/null +++ b/backend/middleware/verifyToken.js @@ -0,0 +1,28 @@ +import jwt from "jsonwebtoken" +import User from "../models/User.js" + +export const verifyToken = async (req, res, next) => { + const authHeader = req.headers.authorization + + if (!authHeader?.startsWith("Bearer ")) { + return res.status(401).json({ message: "No token, authorization denied" }) + } + + const token = authHeader.split(" ")[1] + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET) + + const user = await User.findById(decoded.sub) + if (!user) throw new Error("User not found") + + req.user = { + _id: user._id, + name: user.name, + role: user.role, + } + next() + } catch (err) { + return res.status(401).json({ message: `Invalid token: ${err.message}` }) + } +} diff --git a/backend/models/Cat.js b/backend/models/Cat.js new file mode 100644 index 000000000..0dd467ee1 --- /dev/null +++ b/backend/models/Cat.js @@ -0,0 +1,17 @@ +import mongoose from "mongoose" +import commentSchema from "./Comments.js" + +const catSchema = new mongoose.Schema( + { + name: { type: String, required: true }, + gender: { + type: String, + enum: ["male", "female"], + required: true, + }, + imageUrl: { type: String, required: true }, + location: { type: String, required: true }, + comments: [commentSchema], default: [], + }, { timestamps: true }) + +export default mongoose.models.Cat || mongoose.model("Cat", catSchema) \ No newline at end of file diff --git a/backend/models/Comments.js b/backend/models/Comments.js new file mode 100644 index 000000000..95eed9181 --- /dev/null +++ b/backend/models/Comments.js @@ -0,0 +1,23 @@ +import mongoose from "mongoose" + +const commentSchema = new mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + userName: { + type: String, + required: true, + }, + text: { + type: String, + required: true, + minlength: 1, + }, + }, + { timestamps: true } +) + +export default commentSchema \ No newline at end of file diff --git a/backend/models/User.js b/backend/models/User.js new file mode 100644 index 000000000..8dd669b9c --- /dev/null +++ b/backend/models/User.js @@ -0,0 +1,12 @@ +import mongoose from "mongoose" + +const UserSchema = new mongoose.Schema({ + name: { type: String, required: true }, + email: { type: String, required: true, unique: true }, + password: { type: String, required: true }, + role: { type: String, enum: ["admin", "user"], default: "user" }, +}, { timestamps: true }) + +const User = mongoose.models.User || mongoose.model("User", UserSchema) + +export default User \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 08f29f244..1def31f2a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,9 +12,15 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcryptjs": "^3.0.3", + "cloudinary": "^1.21.0", "cors": "^2.8.5", - "express": "^4.17.3", - "mongoose": "^8.4.0", + "dotenv": "^17.3.1", + "express": "^4.22.1", + "jsonwebtoken": "^9.0.3", + "mongoose": "^8.23.0", + "multer": "^2.0.2", + "multer-storage-cloudinary": "^4.0.0", "nodemon": "^3.0.1" } -} \ No newline at end of file +} diff --git a/backend/parseBoolean.js b/backend/parseBoolean.js new file mode 100644 index 000000000..59bcca8e1 --- /dev/null +++ b/backend/parseBoolean.js @@ -0,0 +1,10 @@ +export const parseBoolean = (value) => { + if (typeof value === "boolean") return value + if (typeof value === "string") { + const lowered = value.toLowerCase().trim() + if (["true", "1", "yes", "y"].includes(lowered)) return true + if (["false", "0", "no", "n"].includes(lowered)) return false + } + + return false +} \ No newline at end of file diff --git a/backend/routes/adminRoutes.js b/backend/routes/adminRoutes.js new file mode 100644 index 000000000..50ab52318 --- /dev/null +++ b/backend/routes/adminRoutes.js @@ -0,0 +1,86 @@ +import express from "express" +import authenticate from "../middleware/authenticate.js" +import authorize from "../middleware/authorize.js" +import User from "../models/User.js" +import bcrypt from "bcryptjs" + +const adminRouter = express.Router() + +adminRouter.get( + "/", + authenticate, + authorize("admin"), + async (req, res) => { + try { + const stats = { + totalUsers: await User.countDocuments(), + totalAdmins: await User.countDocuments({ role: "admin" }), + } + res.status(200).json({ success: true, data: stats }) + } catch (error) { + console.error("Admin stats error:", error) + res.status(500).json({ success: false, message: error.message }) + } + } +) + +// Create User +adminRouter.post( + "/users", + authenticate, + authorize("admin"), + async (req, res) => { + try { + const { name, email, role, password } = req.body + + // Validate required fields + if (!name || !email || !password) { + return res.status(400).json({ + success: false, + message: "Name, email, and password are required" + }) + } + + // Check if user already exists + const existingUser = await User.findOne({ email }) + if (existingUser) { + return res.status(400).json({ + success: false, + message: "User with this email already exists" + }) + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10) + + // Create new user + const newUser = new User({ + name, + email, + role: role || "user", + password: hashedPassword + }) + + await newUser.save() + + res.status(201).json({ + success: true, + message: "User created successfully", + data: { + id: newUser._id, + name: newUser.name, + email: newUser.email, + role: newUser.role + } + }) + } catch (error) { + console.error("User creation error:", error) + res.status(500).json({ + success: false, + message: error.message + }) + } + } +) + +export default adminRouter \ No newline at end of file diff --git a/backend/routes/catRoutes.js b/backend/routes/catRoutes.js new file mode 100644 index 000000000..e143c1fa9 --- /dev/null +++ b/backend/routes/catRoutes.js @@ -0,0 +1,142 @@ +import express, { } from "express" +import multer from "multer" +import dotenv from "dotenv" +import { CloudinaryStorage } from "multer-storage-cloudinary" +import cloudinary from "cloudinary" +import Cat from "../models/Cat" +import authenticate from "../middleware/authenticate" +import authorize from "../middleware/authorize" + + +dotenv.config() + +// Cloudinary +const { v2: cloudinaryV2 } = cloudinary +cloudinaryV2.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}) + +const storage = new CloudinaryStorage({ + cloudinary: cloudinaryV2, + params: { + folder: "cats", + allowedFormats: ["jpg", "png"], + transformation: [{ + width: 500, + height: 500, + crop: "limit", + effect: "background_removal:fineedges" + }], + }, +}) + +const parser = multer({ storage }) + +const router = express.Router() + +// All cats +router.get("/cats", async (req, res) => { + try { + const cats = await Cat.find().sort({ createdAt: -1 }) + res.json(cats) + + } catch (error) { + res.status(500).json({ error: "Failed to fetch cats" }) + } +}) + +// One cat +router.get("/cats/:id", async (req, res) => { + const id = req.params.id + try { + const cat = await Cat.findById(id) + res.json(cat) + + } catch (error) { + res.status(500).json({ error: "Failed to fetch cat" }) + } +}) + +// Post +router.post("/cats", authenticate, authorize('admin', 'editor'), parser.single('picture'), async (req, res) => { + try { + + const { filename, gender, location } = req.body + + if (!req.file) { + return res.status(400).json({ message: "Image file missing" }) + } + if (!filename || !gender || !location) { + return res.status(400).json({ message: "All fields are required" }) + } + + const cat = await new Cat({ + name: filename, + imageUrl: req.file.path, + gender, + location, + }).save() + + res.status(201).json(cat) + } catch (err) { + console.error('Save error:', err) + if (err.name === 'ValidationError') { + return res.status(400).json({ message: err.message, errors: err.errors }) + } + res.status(500).json({ message: err.message || 'Server error' }) + } +}) + +// Edit +router.put("/cats/:id", authenticate, authorize('admin', 'editor'), async (req, res) => { + try { + + const editedCat = req.body + + const cat = await Cat.findById(editedCat._id) + + cat.name = editedCat.name + cat.gender = editedCat.gender + cat.location = editedCat.location + + await cat.save() + res.json(cat) + + } catch (error) { + res.status(500).json({ error: "Failed to edit cat" }) + } +}) + +// Delete +router.delete("/cats/:id", authenticate, authorize('admin', 'editor'), async (req, res) => { + const id = req.params.id + try { + const cat = await Cat.findById(id) + + if (!cat) { + return res.status(404).json({ + success: false, + response: [], + message: "Cat not found" + }) + } + + await Cat.findByIdAndDelete(id) + + res.status(200).json({ + success: true, + response: id, + message: "Cat deleted successfully" + }) + } catch (error) { + res.status(500).json({ + success: false, + response: null, + message: error, + }) + } +}) + +export default router \ No newline at end of file diff --git a/backend/routes/commentRoutes.js b/backend/routes/commentRoutes.js new file mode 100644 index 000000000..b936471f5 --- /dev/null +++ b/backend/routes/commentRoutes.js @@ -0,0 +1,77 @@ +import express from "express" +import { verifyToken } from "../middleware/verifyToken.js" +import Cat from "../models/Cat.js" + +const router = express.Router() + +// Comments +router.get("/:catId/comments", async (req, res) => { + const { catId } = req.params + try { + const cat = await Cat.findById(catId).select("comments") + if (!cat) return res.status(404).json({ message: "Cat not found" }) + + const sorted = cat.comments.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + res.json(sorted) + } catch (err) { + console.error("Get comments error:", err) + res.status(500).json({ message: err.message || "Server error" }) + } +}) + +// Post comment +router.post("/:catId/comments", verifyToken, async (req, res) => { + const { catId } = req.params + const { text } = req.body + + if (!text || !text.trim()) { + return res.status(400).json({ message: "Comment text cannot be empty" }) + } + + try { + const cat = await Cat.findById(catId) + if (!cat) return res.status(404).json({ message: "Cat not found" }) + + // New comment + cat.comments.push({ + userId: req.user._id, + userName: req.user.name, + text: text.trim(), + createdAt: new Date(), + }) + + await cat.save() + + const newComment = cat.comments[cat.comments.length - 1] + res.status(201).json(newComment) + } catch (err) { + console.error("Create comment error:", err) + res.status(500).json({ message: err.message || "Server error" }) + } +}) + +// Delete comment +router.delete("/:catId/comments/:commentId", verifyToken, async (req, res) => { + const { catId, commentId } = req.params + const cat = await Cat.findById(catId) + if (!cat) return res.status(404).json({ message: "Cat not found" }) + try { + cat.comments.pull({ _id: commentId }) + + await cat.save() + + res.status(200).json({ + success: true, + response: commentId, + message: "Comment deleted successfully" + }) + } catch (error) { + res.status(500).json({ + success: false, + response: null, + message: error, + }) + } +}) + +export default router \ No newline at end of file diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js new file mode 100644 index 000000000..42db8ec55 --- /dev/null +++ b/backend/routes/userRoutes.js @@ -0,0 +1,133 @@ +import bcrypt from "bcryptjs" +import express from "express" +import dotenv from "dotenv" +import User from "../models/User.js" +import { signJwt } from "../utils/jwt.js" +dotenv.config() + +const router = express.Router() + +// Signup +router.post("/signup", async (req, res) => { + try { + const { name, email, password } = req.body + + if (!name?.trim() || !email?.trim() || !password) { + return res.status(400).json({ + success: false, + message: "Name, e‑mail and password are required", + }) + } + + const existingUser = await User.findOne({ email: email.toLowerCase() }) + if (existingUser) { + return res.status(400).json({ + success: false, + message: "User with this email already exists", + }) + } + + const salt = bcrypt.genSaltSync() + const hashedPassword = bcrypt.hashSync(password, salt) + + const user = new User({ + name: name.trim(), + email: email.toLowerCase(), + password: hashedPassword, + }) + + await user.save() + + const token = signJwt({ sub: user._id, role: user.role, name: user.name }) + + res.status(201).json({ + success: true, + message: "User created", + response: { + id: user._id, + name: user.name, + email: user.email, + role: user.role, + token, + }, + }) + } catch (error) { + console.error("Signup error:", error) + if (error.name === "ValidationError") { + return res.status(400).json({ + success: false, + message: "Invalid user data", + response: error.errors, + }) + } + res.status(500).json({ + success: false, + message: "Could not create user", + response: error.message, + }) + } +}) + +// Login +router.post("/login", async (req, res) => { + try { + const { email, password } = req.body + const user = await User.findOne({ email: email.toLowerCase() }) + if (!user) { + return res.status(401).json({ + success: false, + message: "Wrong e‑mail or password", + response: null, + }) + } + + const passwordMatches = bcrypt.compareSync(password, user.password) + if (!passwordMatches) { + return res.status(401).json({ + success: false, + message: "Wrong e‑mail or password", + response: null, + }) + } + + const token = signJwt({ sub: user._id, role: user.role, name: user.name }) + res.json({ + success: true, + message: "Logged in successfully", + response: { + id: user._id, + name: user.name, + email: user.email, + role: user.role, + token, + }, + }) + } catch (error) { + console.error("Login error details:", error) + res.status(500).json({ + success: false, + message: "Something went wrong", + response: error.message, + }) + } +}) + +// Delete +router.delete( + "/users/:id", + async (req, res) => { + try { + const { id } = req.params + const deleted = await User.findByIdAndDelete(id) + if (!deleted) { + return res.status(404).json({ success: false, message: "User not found" }) + } + res.json({ success: true, message: "User removed" }) + } catch (err) { + console.error("Delete user error:", err) + res.status(500).json({ success: false, message: err.message }) + } + } +) + +export default router \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 070c87518..3ae27ce59 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,22 +1,50 @@ -import express from "express"; -import cors from "cors"; -import mongoose from "mongoose"; +import express from "express" +import cors from "cors" +import dotenv from "dotenv" +import mongoose from "mongoose" -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; +import userRouter from "./routes/userRoutes.js" +import catRouter from "./routes/catRoutes.js" +import commentRouter from "./routes/commentRoutes.js" +import adminRouter from "./routes/adminRoutes.js" -const port = process.env.PORT || 8080; -const app = express(); +dotenv.config() -app.use(cors()); -app.use(express.json()); +const app = express() -app.get("/", (req, res) => { - res.send("Hello Technigo!"); -}); +app.use( + cors({ + origin: true, + credentials: true, + allowedHeaders: ["Content-Type", "Authorization"], + }) +) +app.use(express.json({ limit: "10mb" })) +app.use(express.urlencoded({ limit: "10mb", extended: true })) -// Start the server -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); -}); +const mongoUrl = process.env.MONGO_URL || "mongodb://127.0.0.1:27017/final-project" +mongoose + .connect(mongoUrl, { + useNewUrlParser: true, + useUnifiedTopology: true, + }) + .then(() => console.log("Connected to MongoDB")) + .catch((err) => { + console.error("MongoDB connection error:", err) + process.exit(1) + }) + +app.use("/admin", adminRouter) +app.use("/users", userRouter) +app.use("/", catRouter) +app.use("/comments", commentRouter) +app.all("*", (req, res) => { + res.status(404).json({ message: "Not Found" }) +}) + + +// Server start +const PORT = process.env.PORT || 8080 +app.listen(PORT, () => { + console.log(`Server listening on http://localhost:${PORT}`) +}) \ No newline at end of file diff --git a/backend/utils/jwt.js b/backend/utils/jwt.js new file mode 100644 index 000000000..c92259560 --- /dev/null +++ b/backend/utils/jwt.js @@ -0,0 +1,15 @@ +import jwt from "jsonwebtoken" + +export const signJwt = (payload) => { + if (!process.env.JWT_SECRET) { + throw new Error("JWT_SECRET is not defined in .env") + } + return jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: "7d" }) +} + +export const verifyJwt = (token) => { + if (!process.env.JWT_SECRET) { + throw new Error("JWT_SECRET is not defined in .env") + } + return jwt.verify(token, process.env.JWT_SECRET) +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 664410b5b..006669622 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,39 @@ -
- - - -