diff --git a/README.md b/README.md index 0f9f073..cb3c23c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,65 @@ -# Project API +Happy Thoughts API 💬❤️ -This project includes the packages and babel setup for an express server, and is just meant to make things a little simpler to get up and running with. +This is a RESTful API built with Node.js, Express, and MongoDB for a “Happy Thoughts” application. +Users can sign up, log in, post happy thoughts, like them, and manage their own posts. -## Getting started +The API is deployed on Render and connected to MongoDB Atlas. -Install dependencies with `npm install`, then start the server by running `npm run dev` +🚀 Features -## View it live + User authentication (signup & login) + Secure password hashing + Create, read, update, and delete thoughts + Like thoughts + Ownership protection (users can only edit/delete their own posts) + API documentation endpoint + Deployed online + +🛠️ Tech Stack + Node.js + Express.js + MongoDB + Mongoose + bcryptjs (password hashing) + dotenv (environment variables) + express-list-endpoints + +CORS + +🧠 Data Models +User Model + + email (unique) + password (hashed) + accessToken + +HappyThought Model + + message (5–140 chars) + hearts (number) + createdAt + userId (reference to User) + +🔒 Security + + Passwords are hashed using bcrypt + Access tokens are stored securely + Protected routes require authentication + Users can only edit/delete their own posts + +❗ Error Handling + + The API returns meaningful status codes: + 400 Bad Request + 401 Unauthorized + 403 Forbidden + 404 Not Found + 500 Server Error + +This project was built to practice: + +REST APIs +Authentication +MongoDB +Full-stack integration +Deployment -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. diff --git a/middleware/authMiddleware.js b/middleware/authMiddleware.js new file mode 100644 index 0000000..321fab3 --- /dev/null +++ b/middleware/authMiddleware.js @@ -0,0 +1,31 @@ +import { User } from "../models/User.js"; + +export const authenticateUser = async (req, res, next) => { + try { + const authHeader = req.header("Authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return res.status(401).json({ + message: "Authentication missing or invalid.", + loggedOut: true, + }); + } + + const token = authHeader.replace("Bearer ", "").trim(); + const user = await User.findOne({ accessToken: token }); + + if (!user) { + return res.status(401).json({ + message: "Authentication missing or invalid.", + loggedOut: true, + }); + } + req.user = user; + return next(); + } catch (err) { + return res.status(500).json({ + message: "Internal server error", + error: err.message, + }); + } +}; diff --git a/models/User.js b/models/User.js new file mode 100644 index 0000000..f4bcad3 --- /dev/null +++ b/models/User.js @@ -0,0 +1,26 @@ +import mongoose from "mongoose"; +import crypto from "crypto"; + +const UserSchema = new mongoose.Schema( + { + email: { + type: String, + required: true, + unique: true, + lowercase: true, + trim: true, + }, + password: { + type: String, + required: true, + minlength: 6, + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(128).toString("hex"), + }, + }, + { timestamps: true }, +); + +export const User = mongoose.model("User", UserSchema); diff --git a/models/happyThought.js b/models/happyThought.js new file mode 100644 index 0000000..51f292e --- /dev/null +++ b/models/happyThought.js @@ -0,0 +1,28 @@ +import mongoose from "mongoose"; + +const HappyThoughtsSchema = new mongoose.Schema({ + message: { + type: String, + required: true, + minlength: 5, + maxlength: 140, + }, + hearts: { + type: Number, + default: 0, + }, + createdAt: { + type: Date, + default: Date.now, + }, + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, +}); + +export const HappyThoughts = mongoose.model( + "HappyThoughts", + HappyThoughtsSchema, +); diff --git a/package.json b/package.json index bf25bb6..5a68e4f 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,12 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcryptjs": "^3.0.3", "cors": "^2.8.5", + "dotenv": "^17.2.3", "express": "^4.17.3", + "express-list-endpoints": "^7.1.1", + "mongoose": "^9.1.5", "nodemon": "^3.0.1" } } diff --git a/routes/happyThoughtRoutes.js b/routes/happyThoughtRoutes.js new file mode 100644 index 0000000..3a5f313 --- /dev/null +++ b/routes/happyThoughtRoutes.js @@ -0,0 +1,143 @@ +import express from "express"; +import mongoose from "mongoose"; +import { HappyThoughts } from "../models/happyThought.js"; +import { authenticateUser } from "../middleware/authMiddleware.js"; + +const router = express.Router(); + +router.get("/", async (req, res) => { + try { + const query = {}; + + if (req.query.minHearts) { + const minHearts = Number(req.query.minHearts); + if (Number.isNaN(minHearts)) { + return res.status(400).json({ error: "minHearts must be a number" }); + } + query.hearts = { $gte: minHearts }; + } + + const thoughts = await HappyThoughts.find(query).sort({ createdAt: -1 }); //greater than or equal to + return res.json(thoughts); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.get("/:id", async (req, res) => { + const { id } = req.params; + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ error: "Invalid id" }); // will check if the id is valid + } + try { + const thought = await HappyThoughts.findById(id); + if (!thought) { + return res.status(404).json({ error: "Thought not found" }); + } + return res.json(thought); + } catch (err) { + return res.status(500).json({ error: err.message }); + } +}); + +router.post("/", authenticateUser, async (req, res) => { + const { message } = req.body; + if (!message || message.length < 5 || message.length > 140) { + return res.status(400).json({ + error: "Message is required and must be between 5 and 140 characters", // will validate the message length or if it's empty + }); + } + try { + const newThought = await HappyThoughts.create({ + message, + userId: req.user.id, + }); + return res.status(201).json(newThought); + } catch (err) { + return res.status(500).json({ error: err.message }); + } +}); + +router.put("/:id", authenticateUser, async (req, res) => { + const { id } = req.params; + const { message } = req.body; + + if (!message || message.length < 5 || message.length > 140) { + return res.status(400).json({ + error: "Message is required and must be between 5 and 140 characters", + }); + } + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ error: "Invalid id" }); + } + + try { + const thought = await HappyThoughts.findById(id); + if (!thought) { + return res.status(404).json({ error: "Thought not found" }); + } + if (thought.userId.toString() !== req.user.id) { + return res + .status(403) + .json({ error: "Unauthorized to update this thought" }); + } + + thought.message = message; + await thought.save(); + + return res.json(thought); + } catch (err) { + return res.status(500).json({ error: err.message }); + } +}); + +router.delete("/:id", authenticateUser, async (req, res) => { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ error: "Invalid id" }); + } + + try { + const thought = await HappyThoughts.findById(id); + if (!thought) { + return res.status(404).json({ error: "Thought not found" }); + } + if (thought.userId.toString() !== req.user.id) { + return res + .status(403) + .json({ error: "Unauthorized to delete this thought" }); + } + await HappyThoughts.findByIdAndDelete(id); + return res.json({ message: "Thought deleted successfully" }); + } catch (err) { + return res.status(500).json({ error: err.message }); + } +}); + +router.post("/:id/like", async (req, res) => { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ error: "Invalid id" }); + } + + try { + const thought = await HappyThoughts.findByIdAndUpdate( + id, + { $inc: { hearts: 1 } }, + { new: true }, + ); + + if (!thought) { + return res.status(404).json({ error: "Thought not found" }); + } + + return res.json(thought); + } catch (err) { + return res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/routes/userRoutes.js b/routes/userRoutes.js new file mode 100644 index 0000000..9d6d912 --- /dev/null +++ b/routes/userRoutes.js @@ -0,0 +1,104 @@ +import express, { response } from "express"; +import bcrypt from "bcryptjs"; +import { User } from "../models/User.js"; + +const router = express.Router(); + +router.post("/signup", async (req, res) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ + success: false, + error: "Email and password are required", + }); + } + + if (password.length < 8) { + return res.status(400).json({ + success: false, + error: "Password must be at least 8 characters long", + }); + } + + const normalizedEmail = email.toLowerCase().trim(); + + const existingUser = await User.findOne({ email: normalizedEmail }); + if (existingUser) { + return res.status(409).json({ + success: false, + error: "Email already in use", + }); + } + + const salt = await bcrypt.genSalt(); + const hashedPassword = await bcrypt.hash(password, salt); + + const user = new User({ email: normalizedEmail, password: hashedPassword }); + await user.save(); + + return res.status(201).json({ + success: true, + message: "User created successfully", + response: { + email: user.email, + id: user._id, + accessToken: user.accessToken, + }, + }); + } catch (error) { + return res.status(400).json({ + success: false, + message: "Sorry, I could not create a user", + response: error.message, + }); + } +}); + +router.post("/login", async (req, res) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ + success: false, + error: "Email and password are required", + }); + } + + const normalizedEmail = email.toLowerCase().trim(); + + const user = await User.findOne({ email: normalizedEmail }); + + const passwordMatch = user + ? await bcrypt.compare(password, user.password) + : false; + + if (user && passwordMatch) { + return res.json({ + success: true, + message: "Login successful", + response: { + email: user.email, + id: user._id, + accessToken: user.accessToken, + }, + }); + } else { + return res.status(401).json({ + success: false, + message: "Invalid email or password", + info: "https://http.dog/401", + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: "Login failed", + response: error, + }); + } +}); + +export default router; diff --git a/server.js b/server.js index f47771b..d04c9c9 100644 --- a/server.js +++ b/server.js @@ -1,22 +1,41 @@ -import cors from "cors" -import express from "express" +import cors from "cors"; +import express from "express"; +import expressListEndpoints from "express-list-endpoints"; +import mongoose from "mongoose"; +import "dotenv/config"; -// Defines the port the app will run on. Defaults to 8080, but can be overridden -// when starting the server. Example command to overwrite PORT env variable value: -// PORT=9000 npm start -const port = process.env.PORT || 8080 -const app = express() +import happyThoughtRoutes from "./routes/happyThoughtRoutes.js"; +import userRoutes from "./routes/userRoutes.js"; -// Add middlewares to enable cors and json body parsing -app.use(cors()) -app.use(express.json()) +const password = process.env.PASSWORD; +const mongoDB = + process.env.MONGO_URL || + `mongodb+srv://artakjato:${password}@clusterhappythoughts.fhtetam.mongodb.net/?appName=ClusterHappyThoughts`; +main().catch((err) => console.log(err)); +async function main() { + console.log("Connecting to MongoDB..."); + await mongoose.connect(mongoDB); +} + +const port = process.env.PORT || 8080; +const app = express(); + +app.use(cors()); +app.use(express.json()); -// Start defining your routes here app.get("/", (req, res) => { - res.send("Hello Technigo!") -}) + //listing all available endpoints + const endpoints = expressListEndpoints(app); + res.json({ + message: "Welcome to the Happy Thoughts API!", + endpoints: endpoints, + }); +}); + +app.use("/api/thoughts", happyThoughtRoutes); +app.use("/api/users", userRoutes); // Start the server app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`) -}) + console.log(`Server running on http://localhost:${port}`); +});