diff --git a/apps/api/src/config/corsConfig.ts b/apps/api/src/config/corsConfig.ts new file mode 100644 index 00000000..045cf517 --- /dev/null +++ b/apps/api/src/config/corsConfig.ts @@ -0,0 +1,15 @@ +const allowedOrigins = ['http://localhost:4200', 'http://localhost:3000']; + +export const corsOptions = { + origin: (origin, callback) => { + // Note: origin will be undefined from same route in local development + if (allowedOrigins.indexOf(origin) !== -1 || !origin) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + optionsSuccessStatus: 200 +}; + +export default { corsOptions }; diff --git a/apps/api/src/controllers/auth.ts b/apps/api/src/controllers/auth.ts new file mode 100644 index 00000000..96bb74b5 --- /dev/null +++ b/apps/api/src/controllers/auth.ts @@ -0,0 +1,97 @@ +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; + +import { User } from '../models/user'; + +const users: User[] = []; + +const register = async (req, res) => { + const { username, password } = req.body; + + // Check that we have the correct payload + if (!username || !password) { + return res.status(400).json({ + message: 'Username and password are required' + }); + } + + // Check that we don't have duplicates + const duplicate = users.find((u) => u.username === username); + if (duplicate) { + return res.status(409).json({ message: 'User already exist' }); + } + + try { + // Encrypt the password + const hashedPassword = await bcrypt.hash(password, 10); + + // Store new user + users.push({ username, password: hashedPassword }); + + res.status(201).json({ message: 'User registered successfully' }); + } catch (e) { + res.status(500).json({ message: e.message }); + } +}; + +const login = async (req, res) => { + const { username, password } = req.body; + + // Check that we have the correct payload + if (!username || !password) { + return res.status(400).json({ + message: 'Username and password are required' + }); + } + + // Retrieve user + const user = users.find((u) => u.username === username); + + // Check if we found the user and the password matches + if (!user || !(await bcrypt.compare(password, user.password))) { + return res.status(401).json({ message: 'Invalid credentials' }); + } + + // Generate access token and refresh token + const accessToken = jwt.sign({ username }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' }); + const refreshToken = jwt.sign({ username }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: '7d' }); + + // Save refresh token + res.cookie('refreshToken', refreshToken, { + httpOnly: true, + maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days in milliseconds + }); + + res.json({ accessToken }); +}; + +const refresh = (req, res) => { + // Get refresh token from cookies + const refreshToken = req.cookies.refreshToken; + + if (!refreshToken) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, { username }) => { + if (err) { + // Invalid token + return res.status(403).json({ message: 'Forbidden' }); + } + + const accessToken = jwt.sign({ username }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' }); + res.json({ accessToken }); + }); +}; + +const logout = (req, res) => { + res.clearCookie('refreshToken'); + res.json({ message: 'Logged out successfully' }); +}; + +export default { + register, + login, + refresh, + logout +}; diff --git a/apps/api/src/controllers/category.ts b/apps/api/src/controllers/category.ts new file mode 100644 index 00000000..4480713c --- /dev/null +++ b/apps/api/src/controllers/category.ts @@ -0,0 +1,83 @@ +import Category from '../models/category'; + +// Get all categories +const getCategories = async (req, res) => { + try { + const categories = await Category.find(); + res.status(200).json(categories); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Get category by id +const getCategoryById = async (req, res) => { + const { id } = req.params; + + try { + const category = await Category.findById(id); + + if (!category) { + return res.status(404).json({ message: 'Category not found' }); + } + res.status(200).json(category); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Create category +const createCategory = async (req, res) => { + try { + const category = await Category.create(req.body); + res.status(201).json(category); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Update category +const updateCategory = async (req, res) => { + const { id } = req.params; + + try { + const category = await Category.findByIdAndUpdate(id, req.body, { new: true }); + if (!category) { + return res.status(404).json({ message: 'Category not found' }); + } + + res.status(200).json(category); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Delete category +const deleteCategory = async (req, res) => { + const { id } = req.params; + + try { + const category = await Category.findByIdAndDelete(id); + + if (!category) { + return res.status(404).json({ message: 'Category not found' }); + } + + res.status(200).json(category); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +export default { + getCategories, + getCategoryById, + createCategory, + updateCategory, + deleteCategory +}; diff --git a/apps/api/src/controllers/post.ts b/apps/api/src/controllers/post.ts new file mode 100644 index 00000000..29f713af --- /dev/null +++ b/apps/api/src/controllers/post.ts @@ -0,0 +1,145 @@ +import Comment from '../models/comment'; +import Post from '../models/post'; + + +export const getPost = (id: string) => { + return posts.find((p) => p.id === id); +}; + +const posts = []; + +const getPosts = async (req, res) => { + try { + const posts = await Post.find(); + res.status(200).json(posts); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// GET /posts/category/:category +const getPostsByCategory = async (req, res) => { + try { + const { category } = req.params; + const post = await Post.find({category}); + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + res.status(200).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// GET /posts/:id +const getPostById = async (req, res) => { + try { + const { id } = req.params; + const post = await Post.findById(id); + + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + res.status(200).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +const createPost = async (req, res) => { + try { + const { title, image, description, category, comments} = req.body; + + if (!title || !image || !description || !category) { + return res.status(400).json({ message: 'Review some fields are missing.' }); + } + const newPost = { + title, + image, + description, + category, + comments + }; + const post = await Post.create(newPost); + res.status(201).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// POST /posts/:id/comments +const createCommentForPost = async (req, res) => { + try { + const { id } = req.params; + const { author, content} = req.body; + + const post = await Post.findById(id); + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + + if (!author || !content) { + return res.status(400).json({ message: 'Review some fields are missing.' }); + } + + const newComment = { + author, + content + }; + const comment = await Comment.create(newComment); + post.comments.push(comment._id.toString()); + await post.save(); + + res.status(201).json(comment); + + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } + }; + + +// PATCH /posts/:id +const updatePost = async (req, res) => { + try { + const { id } = req.params; + const post = await Post.findByIdAndUpdate(id, req.body, { new: true }); + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + res.status(200).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// DELETE /posts/:id +const deletePost = async (req, res) => { + try { + const { id } = req.params; + const post = await Post.findByIdAndDelete(id, {}); + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + await Comment.deleteMany({ _id: { $in: post.comments } }); + res.status(200).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +export default { + getPosts, + getPostsByCategory, + getPostById, + createPost, + createCommentForPost, + updatePost, + deletePost +}; \ No newline at end of file diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index e5fad103..96888e1e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,14 +1,41 @@ +import cors from 'cors'; import express from 'express'; +import helmet from 'helmet'; +import mongoose from 'mongoose'; + +import { corsOptions } from './config/corsConfig'; +import { verifyToken } from './middleware/auth'; +import { errorHandler } from './middleware/errorHandler'; +import auth from './routes/auth'; +import categories from './routes/categories'; +import posts from './routes/posts'; const host = process.env.HOST ?? 'localhost'; const port = process.env.PORT ? Number(process.env.PORT) : 3000; const app = express(); -app.get('/', (req, res) => { - res.send({ message: 'Hello MFEE!' }); -}); +app.use(express.json()); +app.use(helmet()); +app.use(cors(corsOptions)); + +app.use('/api/auth', auth); + +app.use(verifyToken); +app.use('/api/categories', categories); +app.use('/api/posts', posts); + +app.use(errorHandler); + +mongoose + .connect(process.env.MONGO_URL) + .then(() => { + console.log('Connected to MongoDB'); -app.listen(port, host, () => { - console.log(`[ ready ] http://${host}:${port}`); -}); + app.listen(port, host, () => { + console.log(`[ ready ] http://${host}:${port}`); + }); + }) + .catch((e) => { + console.error(e); + }); diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts new file mode 100644 index 00000000..193a5a9e --- /dev/null +++ b/apps/api/src/middleware/auth.ts @@ -0,0 +1,24 @@ +import jwt from 'jsonwebtoken'; + +export const verifyToken = (req, res, next) => { + const authHeader = req.headers['authorization']; + + if (!authHeader) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const token = authHeader.split(' ')[1]; + jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => { + if (err) { + // Invalid token + return res.status(403).json({ message: 'Forbidden' }); + } + + req.user = user; + next(); + }); +}; + +export default { + verifyToken +}; diff --git a/apps/api/src/middleware/errorHandler.ts b/apps/api/src/middleware/errorHandler.ts new file mode 100644 index 00000000..90969d56 --- /dev/null +++ b/apps/api/src/middleware/errorHandler.ts @@ -0,0 +1,9 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const errorHandler = (err, req, res, next) => { + // Log this for debug purposes + console.error(err.stack); + // Return custom error to user + res.status(500).json({ error: 'Internal Server Error' }); +}; + +export default { errorHandler }; diff --git a/apps/api/src/models/category.ts b/apps/api/src/models/category.ts new file mode 100644 index 00000000..c69bbc87 --- /dev/null +++ b/apps/api/src/models/category.ts @@ -0,0 +1,21 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +interface ICategory extends Document { + name: string; +} + +export const categorySchema = new Schema( + { + name: { + type: String, + required: [true, 'Property is required'] + } + }, + { + timestamps: true + } +); + +const Category = mongoose.model('Category', categorySchema); + +export default Category; diff --git a/apps/api/src/models/comment.ts b/apps/api/src/models/comment.ts new file mode 100644 index 00000000..4ecf0646 --- /dev/null +++ b/apps/api/src/models/comment.ts @@ -0,0 +1,26 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +interface IComment extends Document { + author: string; + content: string; +} + +export const commentSchema = new Schema( + { + author: { + type: String, + required: [true, 'Property is required'] + }, + content: { + type: String, + required: [true, 'Property is required'] + } + }, + { + timestamps: true + } +); + +const Comment = mongoose.model('Comment', commentSchema); + +export default Comment; \ No newline at end of file diff --git a/apps/api/src/models/post.ts b/apps/api/src/models/post.ts new file mode 100644 index 00000000..4d3db0e2 --- /dev/null +++ b/apps/api/src/models/post.ts @@ -0,0 +1,41 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +interface IPost extends Document { + id: string; + title: string; + image: string; + description: string; + category: string; + comments: string[]; +} + +export const postSchema = new Schema( + { + title: { + type: String, + required: [true, 'Property is required'] + }, + image: { + type: String, + required: [true, 'Property is required'] + }, + description: { + type: String, + required: [true, 'Property is required'] + }, + category: { + type: String, + required: [true, 'Property is required'] + }, + comments: { + type: [String] + }, + }, + { + timestamps: true + } +); + +const Post = mongoose.model('Post', postSchema); + +export default Post; \ No newline at end of file diff --git a/apps/api/src/models/user.ts b/apps/api/src/models/user.ts new file mode 100644 index 00000000..6ac7896e --- /dev/null +++ b/apps/api/src/models/user.ts @@ -0,0 +1,4 @@ +export type User = { + username: string; + password: string; +}; diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 00000000..062e9bc9 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,15 @@ +import express from 'express'; + +import authController from '../controllers/auth'; + +const router = express.Router(); + +router.post('/register', authController.register); + +router.post('/login', authController.login); + +router.post('/refresh', authController.refresh); + +router.post('/logout', authController.logout); + +export default router; diff --git a/apps/api/src/routes/categories.ts b/apps/api/src/routes/categories.ts new file mode 100644 index 00000000..78e120a1 --- /dev/null +++ b/apps/api/src/routes/categories.ts @@ -0,0 +1,22 @@ +import express from 'express'; + +import categoryController from '../controllers/category'; + +const router = express.Router(); + +// Get all categories +router.get('/', categoryController.getCategories); + +// Get category by id +router.get('/:id', categoryController.getCategoryById); + +// Create category +router.post('/', categoryController.createCategory); + +// Update category +router.patch('/:id', categoryController.updateCategory); + +// Delete category +router.delete('/:id', categoryController.deleteCategory); + +export default router; diff --git a/apps/api/src/routes/posts.ts b/apps/api/src/routes/posts.ts new file mode 100644 index 00000000..0f13fc3d --- /dev/null +++ b/apps/api/src/routes/posts.ts @@ -0,0 +1,28 @@ +import express from 'express'; + +import postController from '../controllers/post'; + +const router = express.Router(); + +// Get all posts +router.get('/', postController.getPosts); + +// Get posts by category +router.get('/category/:category', postController.getPostsByCategory); + +// Get post by id +router.get('/:id', postController.getPostById); + +// Create posts +router.post('/', postController.createPost); + +// Create comment for post +router.post('/:id/comments', postController.createCommentForPost); + +// Update posts +router.patch('/:id', postController.updatePost); + +// Delete posts +router.delete('/:id', postController.deletePost); + +export default router; \ No newline at end of file