diff --git a/README.md b/README.md index 0f9f073..a084e8b 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,43 @@ # Project 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. +Render: https://js-project-api-e8xy.onrender.com +Netlify: https://project-happy-thoughts-ml.netlify.app/ -## Getting started +Welcome to my first backend project! A RESTful API for sharing and liking thoughts with user authentication. -Install dependencies with `npm install`, then start the server by running `npm run dev` +## Features -## View it live +- User Authentication** - Sign up and log in with email/password +- Create Thoughts** - Share your thoughts (5-140 characters) +- Like Thoughts** - Increase heart count on any thought +- Update Thoughts** - Edit your own thoughts +- Delete Thoughts** - Remove your own thoughts +- Password Encryption** - Bcrypt for secure password storage -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. +## Tech Stack + +Backend: +- Node.js +- Express.js +Database: +- MongoDB with Mongoose +Authentication: +- access tokens +Security: +- Bcrypt password hashing +- CORS + +## API Endpoints +Authentication endpoints: +- POST /signup- Create new account +- POST /login - Log in to existing account + +Thoughts endpoints: +- GET /thoughts - Get all thoughts +- GET /thoughts/:id - Get single thought +- POST /thoughts - Create thought (authenticated) +- PATCH /thoughts/:id - Update thought (authenticated) +- DELETE /thoughts/:id - Delete thought (authenticated) +- POST /thoughts/:id/like - Like a thought + +# ENJOY # \ No newline at end of file diff --git a/middleware/authMiddleware.js b/middleware/authMiddleware.js new file mode 100644 index 0000000..bce0756 --- /dev/null +++ b/middleware/authMiddleware.js @@ -0,0 +1,15 @@ +import { User } from "../schema/User.js"; + +export const authenticateUser = async (req, res, next) => { + const user = await User.findOne({ + accessToken: req.header("Authorization") + }); + if (user) { + req.user = user + next(); + } else { + res.status(401).json({ + loggedOut: true + }); + } +}; diff --git a/package.json b/package.json index bf25bb6..0f599f4 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,13 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcrypt": "^6.0.0", "cors": "^2.8.5", + "dotenv": "^17.2.3", "express": "^4.17.3", + "express-list-endpoints": "^7.1.1", + "mongoose": "^9.1.5", + "node.js": "^0.0.1-security", "nodemon": "^3.0.1" } } diff --git a/routes/thoughtRoutes.js b/routes/thoughtRoutes.js new file mode 100644 index 0000000..b5da847 --- /dev/null +++ b/routes/thoughtRoutes.js @@ -0,0 +1,119 @@ +import express from "express"; +import { Thought } from "../schema/Thoughts.js"; +import { authenticateUser } from "../middleware/authMiddleware.js"; + +export const router = express.Router(); + +// Endpoint for all the thoughts. +router.get("/thoughts", async (req, res) => { + try { + const thoughts = await Thought.find() + res.json(thoughts); + } catch (error) { + res.status(500).json({ error: "Could not fetch thoughts" }) + } +}); + +// Endpoint for the thoughts id, to get one specific thought. +router.get("/thoughts/:id", async (req, res) => { + try { + const thoughtsId = await Thought.findById(req.params.id) + + if (!thoughtsId) { + return res.status(404).json({ error: `Thought with id ${req.params.id} does not exist` }) + } + res.json(thoughtsId) + + } catch (error) { + return res.status(500).json({ error: `Could not fetch thoughts` }) + } +}); + +// Adding a new message to the database +router.post("/thoughts", authenticateUser, async (req, res) => { + try { + const { message } = req.body + + if (!message || message.trim().length === 0) { + return res.status(400).json({ error: `Message is required` }) + } + + const newThought = await Thought.create({ message }) + + res.status(201).json(newThought) + } catch (error) { + res.status(500).json({ error: `Could not create thought` }) + } +}); + +// Endpoint for liking a thought, increases hearts by 1 +router.post("/thoughts/:id/like", async (req, res) => { + try { + const id = req.params.id; + const thought = await Thought.findById(id); + + if (!thought) { + return res.status(404).json({ error: "Thought not found" }); + } + thought.hearts += 1; + await thought.save(); + res.json(thought); + + } catch (error) { + res.status(500).json({ error: "Could not like thought" }); + } +}); + + +// Updates a thought - can update message and/or hearts +router.patch("/thoughts/:id", authenticateUser, async (req, res) => { + try { + const id = req.params.id + const { message, hearts } = req.body + + const update = {} + + if (message !== undefined) { + if (message.trim().length === 0) { + return res.status(400).json({ error: "Message can not be empty" }) + } + update.message = message + } + + if (hearts !== undefined) { + if (isNaN(hearts)) { + return res.status(400).json({ error: "Hearts must be a number" }) + } + update.hearts = hearts + } + + const updatedThought = await Thought.findByIdAndUpdate( + id, + update, + { new: true } + ) + + if (!updatedThought) { + return res.status(404).json({ error: "Thought not found" }) + } + + res.json(updatedThought) + } catch (error) { + res.status(500).json({ error: "Could not update thought" }) + } +}); + +// Deletes a thought +router.delete("/thoughts/:id", authenticateUser, async (req, res) => { + try { + const id = req.params.id + const deletedThought = await Thought.findByIdAndDelete(id) + + if (!deletedThought) { + return res.status(404).json({ error: `Thought with id ${id} does not exist` }) + } + res.json(deletedThought) + } catch (error) { + res.status(500).json({ error: "Could not delete thought " }) + } +}); \ No newline at end of file diff --git a/routes/userRoutes.js b/routes/userRoutes.js new file mode 100644 index 0000000..f7ff2ad --- /dev/null +++ b/routes/userRoutes.js @@ -0,0 +1,93 @@ +import express from "express"; +import bcrypt from "bcrypt"; +import { User } from "../schema/User.js"; +import { authenticateUser } from "../middleware/authMiddleware.js"; + +export const router = express.Router(); + +// Creates a new user. Sign-up +router.post("/signup", async (req, res) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ + success: false, + message: "Email and password are required" + }); + } + + const existingUser = await User.findOne({ email: email.toLowerCase() }) + + if (existingUser) { + return res.status(400).json({ + success: false, + message: "Invalid email or password", + }); + } + + const salt = bcrypt.genSaltSync(10) // 10 making it harder to hack the password. + const hashedPassword = bcrypt.hashSync(password, salt) + const user = new User({ email, password: hashedPassword }); + + await user.save(); + res.status(201).json({ + success: true, + message: "User created", + response: { + email: user.email, + id: user._id, + accessToken: user.accessToken, + } + }); + + } catch (error) { + res.status(400).json({ + success: false, + message: "Could not create user", + response: error, + }); + } +}); + +// Log-in endpoint. Finds user that has created an account. +router.post("/login", async (req, res) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ + success: false, + message: "Email and password are required", + }); + } + + const user = await User.findOne({ email: email.toLowerCase() }); + + if (!user || !bcrypt.compareSync(password, user.password)) { + return res.status(401).json({ + success: false, + message: "Invalid email or password", + }) + } + res.json({ + success: true, + message: "Login successful", + response: { + email: user.email, + id: user._id, + accessToken: user.accessToken + } + }); + } catch (error) { + res.status(500).json({ + success: false, + message: "Something went wrong", + }); + } +}); + +// ======= Protected Routes - not in use ======= +router.get("/secrets", authenticateUser, (req, res) => { + res.json({ secret: "This is a super secret message." }) +}); diff --git a/schema/Thoughts.js b/schema/Thoughts.js new file mode 100644 index 0000000..feef293 --- /dev/null +++ b/schema/Thoughts.js @@ -0,0 +1,18 @@ +import { Schema, model } from "mongoose"; + +const thoughtSchema = new Schema({ + message: { + type: String, + required: true + }, + hearts: { + type: Number, + default: 0, + }, + createdAt: { + type: Date, + default: () => new Date() + } +}) + +export const Thought = model("thought", thoughtSchema); \ No newline at end of file diff --git a/schema/User.js b/schema/User.js new file mode 100644 index 0000000..ce65bf8 --- /dev/null +++ b/schema/User.js @@ -0,0 +1,20 @@ +import { Schema, model } from "mongoose"; +import crypto from "crypto"; + +const userSchema = new Schema({ + email: { + type: String, + unique: true, + required: true, + }, + password: { + type: String, + required: true + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(128).toString("hex") + } +}); + +export const User = model("User", userSchema); \ No newline at end of file diff --git a/server.js b/server.js index f47771b..48e7dc4 100644 --- a/server.js +++ b/server.js @@ -1,22 +1,54 @@ -import cors from "cors" -import express from "express" +import cors from "cors"; +import express from "express"; +import mongoose from "mongoose"; +import "dotenv/config"; +import listEndpoints from "express-list-endpoints"; +import { router as thoughtRouter } from "./routes/thoughtRoutes.js"; +import { router as userRouter } from "./routes/userRoutes.js"; -// 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 +// ======= Config & setup ======= +const port = process.env.PORT || 9090 const app = express() -// Add middlewares to enable cors and json body parsing app.use(cors()) app.use(express.json()) -// Start defining your routes here +// Showing all the endpoints and documentation. app.get("/", (req, res) => { - res.send("Hello Technigo!") + const endpoints = listEndpoints(app); + + res.json([{ + message: "Welcome to the Happy thoughts API", + endpoints: endpoints, + }]) +}); + +// Database connection +const mongoUrl = process.env.MONGO_URL +mongoose.connect(mongoUrl); + +app.use((req, res, next) => { + if (mongoose.connection.readyState === 1) { + next() + } else { + res.status(503).json({ error: "Server is unavailable" }) + } }) +// The connections to the different routes with endpoints +app.use(userRouter); +app.use(thoughtRouter); + // Start the server -app.listen(port, () => { +const server = app.listen(port, () => { console.log(`Server running on http://localhost:${port}`) -}) +}); + +// Graceful shutdown for Nodemon +process.on('SIGTERM', () => { + console.log('Server shutting down...') + server.close(() => { + mongoose.connection.close() + process.exit(0) + }) +}); \ No newline at end of file