diff --git a/app.js b/app.js index f0fdaaf..00e8d72 100644 --- a/app.js +++ b/app.js @@ -4,9 +4,12 @@ const mongoose = require('mongoose'); const path = require('path'); const multer = require('multer'); const { v4: uuidv4 } = require('uuid'); +const { graphqlHTTP } = require('express-graphql'); -const feedRoutes = require('./router/feed'); -const authRoutes = require('./router/auth'); +const graphqlSchema = require('./graphql/schema'); +const graphqlResolver = require('./graphql/resolvers'); +const auth = require('./middleware/auth'); +const { clearImage } = require('./utils/file'); const MONGODB_URI = 'mongodb+srv://alterego:tNXWSnypMpjgrFkX@clusterfirstnodeapp.ubtp1kv.mongodb.net/messages?retryWrites=true&w=majority'; @@ -48,11 +51,41 @@ app.use((req, res, next) => { 'GET, POST, PUT, PATCH, DELETE' ); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + if (req.method === 'OPTIONS') { + return res.sendStatus(200); + } next(); }); -app.use('/feed', feedRoutes); -app.use('/auth', authRoutes); +app.use(auth); + +app.put('/post-image', (req, res, next) => { + if (!req.isAuth) throw new Error('Not authenticated'); + if (!req.file) res.status(200).json({ message: 'No image provided' }); + if (req.body.oldPath) clearImage(req.body.oldPath); + return res.status(201).json({ + message: 'File stored', + filePath: req.file.path.replace('\\', '/'), + }); +}); + +app.use( + '/graphql', + graphqlHTTP({ + schema: graphqlSchema, + rootValue: graphqlResolver, + graphiql: true, + customFormatErrorFn(err) { + if (!err.originalError) { + return err; + } + const data = err.originalError.data; + const message = err.message || 'An error occurred'; + const code = err.originalError.code || 500; + return { message: message, status: code, data: data }; + }, + }) +); app.use((error, req, res, next) => { console.log(error); @@ -65,12 +98,6 @@ app.use((error, req, res, next) => { mongoose .connect(MONGODB_URI) .then(() => { - const server = app.listen(8080); - const io = require('./socket').init(server, { - cors: { origin: 'http://localhost:3000', methods: ['GET', 'POST'] }, - }); - io.on('connection', (socket) => { - console.log('Client connected.'); - }); + app.listen(8080); }) .catch((err) => console.log(err)); diff --git a/graphql/resolvers.js b/graphql/resolvers.js new file mode 100644 index 0000000..f72167f --- /dev/null +++ b/graphql/resolvers.js @@ -0,0 +1,239 @@ +const bcrypt = require('bcryptjs'); +const validator = require('validator'); +const jwt = require('jsonwebtoken'); + +const User = require('../models/user'); +const Post = require('../models/post'); +const { clearImage } = require('../utils/file'); + +module.exports = { + createUser: async function ({ userInput }, req) { + const errors = []; + if (!validator.isEmail(userInput.email)) { + errors.push({ message: 'Email is invalid' }); + } + if (!validator.isLength(userInput.password, { min: 5 })) { + errors.push({ message: 'Password is too short' }); + } + if (errors.length > 0) { + const error = new Error('Invalid input'); + error.data = errors; + error.code = 422; + throw error; + } + const existingUser = await User.findOne({ email: userInput.email }); + if (existingUser) { + throw new Error('User exist already'); + } + const hashedPw = await bcrypt.hash(userInput.password, 12); + + const user = new User({ + email: userInput.email, + name: userInput.name, + password: hashedPw, + }); + const createdUser = await user.save(); + return { ...createdUser._doc, _id: createdUser._id.toString() }; + }, + login: async function ({ email, password }) { + const user = await User.findOne({ email: email }); + if (!user) { + const error = new Error('User not found.'); + error.code = 401; + throw error; + } + const isEqual = await bcrypt.compare(password, user.password); + if (!isEqual) { + const error = new Error('Password is incorrect'); + error.code = 401; + throw error; + } + const token = jwt.sign( + { + userId: user._id.toString(), + email: user.email, + }, + 'secret', + { expiresIn: '1h' } + ); + return { token: token, userId: user._id.toString() }; + }, + createPost: async function ({ postInput }, req) { + if (!req.isAuth) { + const error = new Error('Not authenticated'); + error.code = 401; + throw error; + } + const errors = []; + if (!validator.isLength(postInput.title, { min: 5 })) { + errors.push({ message: 'Title is too short' }); + } + if (!validator.isLength(postInput.content, { min: 5 })) { + errors.push({ message: 'Content is too short' }); + } + if (errors.length > 0) { + const error = new Error('Invalid input'); + error.data = errors; + error.code = 422; + throw error; + } + const user = await User.findById(req.userId); + if (!user) { + const error = new Error('No such user'); + error.code = 401; + throw error; + } + const post = new Post({ + title: postInput.title, + imageUrl: postInput.imageUrl, + content: postInput.content, + creator: user, + }); + await post.save(); + user.posts.push(post); + await user.save(); + return { ...post._doc, createdAt: post.createdAt.toDateString() }; + }, + showPosts: async function ({ page }, req) { + if (!req.isAuth) { + const error = new Error('Not authenticated'); + error.code = 401; + throw error; + } + if (!page) page = 1; + const perPage = 2; + const postsCount = await Post.find().countDocuments(); + const posts = await Post.find() + .sort({ createdAt: -1 }) + .skip((page - 1) * perPage) + .limit(perPage) + .populate('creator'); + return { + posts: posts.map((post) => { + return { ...post._doc, createdAt: post.createdAt.toDateString() }; + }), + totalPosts: postsCount, + }; + }, + post: async function ({ id }, req) { + if (!req.isAuth) { + const error = new Error('Not authenticated'); + error.code = 401; + throw error; + } + const post = await Post.findById(id).populate('creator'); + if (!post) { + const error = new Error('No current post'); + error.code = 404; + throw error; + } + return { ...post._doc, createdAt: post.createdAt.toDateString() }; + }, + updatePost: async function ({ id, postInput }, req) { + // if (!req.isAuth) { + // const error = new Error('Not authenticated'); + // error.code = 401; + // throw error; + // } + const post = await Post.findById(id).populate('creator'); + if (!post) { + const error = new Error('No current post'); + error.code = 404; + throw error; + } + // if (post.creator._id.toString() !== req.userId.toString()) { + // const error = new Error('Not authorized'); + // error.code = 403; + // throw error; + // } + const errors = []; + if (!validator.isLength(postInput.title, { min: 5 })) { + errors.push({ message: 'Title is too short' }); + } + if (!validator.isLength(postInput.content, { min: 5 })) { + errors.push({ message: 'Content is too short' }); + } + if (errors.length > 0) { + const error = new Error('Invalid input'); + error.data = errors; + error.code = 422; + throw error; + } + post.title = postInput.title; + post.content = postInput.content; + if (postInput.imageUrl) { + post.imageUrl = postInput.imageUrl; + } else { + postInput.imageUrl = post.imageUrl; + } + const updatedPost = await post.save(); + return { + ...updatedPost._doc, + createdAt: updatedPost.createdAt.toDateString(), + }; + }, + deletePost: async function ({ id }, req) { + if (!req.isAuth) { + const error = new Error('Not authenticated'); + error.code = 401; + throw error; + } + const post = await Post.findById(id); + if (!post) { + const error = new Error('Post not found'); + error.code = 404; + throw error; + } + if (post.creator.toString() !== req.userId.toString()) { + const error = new Error('Not authorized'); + error.code = 403; + throw error; + } + clearImage(post.imageUrl); + await Post.findByIdAndDelete(id); + const user = await User.findById(req.userId); + user.posts.pull(id); + await user.save(); + return true; + }, + user: async function (args, req) { + if (!req.isAuth) { + const error = new Error('Not authenticated'); + error.code = 401; + throw error; + } + const user = await User.findById(req.userId); + if (!user) { + const error = new Error('User not found'); + error.code = 404; + throw error; + } + return user; + }, + newStatus: async function ({ status }, req) { + if (!req.isAuth) { + const error = new Error('Not authenticated'); + error.code = 401; + throw error; + } + const user = await User.findById(req.userId); + if (!user) { + const error = new Error('User not found'); + error.code = 404; + throw error; + } + const errors = []; + if (!validator.isLength(status, { min: 1 })) { + errors.push({ message: 'Status can not be empty' }); + } + if (errors.length > 0) { + const error = new Error('Invalid input'); + error.data = errors; + error.code = 422; + throw error; + } + user.status = status; + await user.save(); + return user; + }, +}; diff --git a/graphql/schema.js b/graphql/schema.js new file mode 100644 index 0000000..ecfc161 --- /dev/null +++ b/graphql/schema.js @@ -0,0 +1,64 @@ +const { buildSchema } = require('graphql'); + +module.exports = buildSchema(` +type Post { + _id: ID! + title: String! + content: String! + imageUrl: String! + creator: User! + createdAt: String! + updatedAt: String! +} + +type PostData { + posts: [Post!]! + totalPosts: Int! +} + +type User { + _id: ID! + name: String! + email: String! + password: String + status: String! + posts: [Post!]! +} + +type AuthData { + token: String! + userId: String! +} + +input UserInputData { + email: String! + name: String! + password: String! +} + +input PostInputData { + title: String! + content: String! + imageUrl: String +} + +type RootQuery { + login(email: String!, password: String!): AuthData! + showPosts(page: Int): PostData + post(id: ID!): Post! + user: User! +} + +type RootMutation { + createUser(userInput: UserInputData): User! + createPost(postInput: PostInputData): Post! + updatePost(id: ID!, postInput: PostInputData): Post! + deletePost(id: ID!): Boolean + newStatus(status: String!): User! +} + +schema { + query: RootQuery + mutation: RootMutation +} +`); diff --git a/images/25184ab8-5b3a-474e-8771-05f010ccb77b-photo_2023-12-01_21-41-46.png b/images/25184ab8-5b3a-474e-8771-05f010ccb77b-photo_2023-12-01_21-41-46.png new file mode 100644 index 0000000..b509266 Binary files /dev/null and b/images/25184ab8-5b3a-474e-8771-05f010ccb77b-photo_2023-12-01_21-41-46.png differ diff --git "a/images/51b29f41-8fe2-46f7-9d11-0418f515d4fa-\303\220\302\263+\303\220\302\271.png" "b/images/ab9d75d2-c97a-4025-a715-e53219d8b012-\303\220\302\263+\303\220\302\271.png" similarity index 100% rename from "images/51b29f41-8fe2-46f7-9d11-0418f515d4fa-\303\220\302\263+\303\220\302\271.png" rename to "images/ab9d75d2-c97a-4025-a715-e53219d8b012-\303\220\302\263+\303\220\302\271.png" diff --git a/images/aed2f8fb-68e3-42e6-a85b-58df7eada558-photo_2022-08-31_20-17-10.jpg b/images/aed2f8fb-68e3-42e6-a85b-58df7eada558-photo_2022-08-31_20-17-10.jpg new file mode 100644 index 0000000..8fa9bed Binary files /dev/null and b/images/aed2f8fb-68e3-42e6-a85b-58df7eada558-photo_2022-08-31_20-17-10.jpg differ diff --git "a/images/bde5cac0-2871-4629-8fbb-3047ab1b1b1f-\303\220\302\241\303\220\302\275\303\220\302\270\303\220\302\274\303\220\302\276\303\220\302\272 \303\221\302\215\303\220\302\272\303\221\302\200\303\220\302\260\303\220\302\275\303\220\302\260 2022-12-30 174352.png" "b/images/bde5cac0-2871-4629-8fbb-3047ab1b1b1f-\303\220\302\241\303\220\302\275\303\220\302\270\303\220\302\274\303\220\302\276\303\220\302\272 \303\221\302\215\303\220\302\272\303\221\302\200\303\220\302\260\303\220\302\275\303\220\302\260 2022-12-30 174352.png" deleted file mode 100644 index dc7d35e..0000000 Binary files "a/images/bde5cac0-2871-4629-8fbb-3047ab1b1b1f-\303\220\302\241\303\220\302\275\303\220\302\270\303\220\302\274\303\220\302\276\303\220\302\272 \303\221\302\215\303\220\302\272\303\221\302\200\303\220\302\260\303\220\302\275\303\220\302\260 2022-12-30 174352.png" and /dev/null differ diff --git a/middleware/is-auth.js b/middleware/auth.js similarity index 73% rename from middleware/is-auth.js rename to middleware/auth.js index 272cb93..16f4004 100644 --- a/middleware/is-auth.js +++ b/middleware/auth.js @@ -5,19 +5,22 @@ const jwt = require('jsonwebtoken'); module.exports = (req, res, next) => { const authHeader = req.get('Authorization'); if (!authHeader) { - throw createError(401, 'Not authenticated'); + req.isAuth = false; + return next(); } const token = authHeader.split(' ')[1]; let decodedToken; try { decodedToken = jwt.verify(token, 'secret'); } catch (err) { - err.statusCode = 500; - throw err; + req.isAuth = false; + return next(); } if (!decodedToken) { - throw createError(401, 'Not authenticated'); + req.isAuth = false; + return next(); } req.userId = decodedToken.userId; + req.isAuth = true; next(); }; diff --git a/package-lock.json b/package-lock.json index 91ccefe..5b8d1c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,15 @@ "bcryptjs": "^2.4.3", "body-parser": "^1.20.2", "express": "^4.18.2", + "express-graphql": "^0.12.0", "express-validator": "^7.0.1", + "graphql": "^16.8.1", "jsonwebtoken": "^9.0.2", "mongoose": "^8.0.2", "multer": "^1.4.5-lts.1", "socket.io": "^4.7.2", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "validator": "^13.11.0" }, "devDependencies": { "nodemon": "^3.0.2" @@ -501,6 +504,63 @@ "node": ">= 0.10.0" } }, + "node_modules/express-graphql": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/express-graphql/-/express-graphql-0.12.0.tgz", + "integrity": "sha512-DwYaJQy0amdy3pgNtiTDuGGM2BLdj+YO2SgbKoLliCfuHv3VVTt7vNG/ZqK2hRYjtYHE2t2KB705EU94mE64zg==", + "deprecated": "This package is no longer maintained. We recommend using `graphql-http` instead. Please consult the migration document https://github.com/graphql/graphql-http#migrating-express-grpahql.", + "dependencies": { + "accepts": "^1.3.7", + "content-type": "^1.0.4", + "http-errors": "1.8.0", + "raw-body": "^2.4.1" + }, + "engines": { + "node": ">= 10.x" + }, + "peerDependencies": { + "graphql": "^14.7.0 || ^15.3.0" + } + }, + "node_modules/express-graphql/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-graphql/node_modules/http-errors": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.0.tgz", + "integrity": "sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-graphql/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-graphql/node_modules/toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "engines": { + "node": ">=0.6" + } + }, "node_modules/express-validator": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.0.1.tgz", @@ -654,6 +714,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphql": { + "version": "16.8.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", + "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", diff --git a/package.json b/package.json index 6a6bdc3..09dc55d 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,15 @@ "bcryptjs": "^2.4.3", "body-parser": "^1.20.2", "express": "^4.18.2", + "express-graphql": "^0.12.0", "express-validator": "^7.0.1", + "graphql": "^16.8.1", "jsonwebtoken": "^9.0.2", "mongoose": "^8.0.2", "multer": "^1.4.5-lts.1", "socket.io": "^4.7.2", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "validator": "^13.11.0" }, "devDependencies": { "nodemon": "^3.0.2" diff --git a/router/auth.js b/router/auth.js deleted file mode 100644 index bfa7677..0000000 --- a/router/auth.js +++ /dev/null @@ -1,29 +0,0 @@ -const express = require('express'); -const { check } = require('express-validator'); - -const User = require('../models/user'); -const authController = require('../controllers/auth'); - -const router = express.Router(); - -router.put( - '/signup', - check('email') - .isEmail() - .withMessage('Please enter a valid email.') - .custom((value, { req }) => { - return User.findOne({ email: value }).then((userDoc) => { - if (userDoc) { - return Promise.reject('E-mail address allready exists.'); - } - }); - }) - .normalizeEmail(), - check('password').trim().isLength({ min: 5 }), - check('name').trim().notEmpty(), - authController.signup -); - -router.post('/login', authController.login); - -module.exports = router; diff --git a/router/feed.js b/router/feed.js deleted file mode 100644 index f053c32..0000000 --- a/router/feed.js +++ /dev/null @@ -1,44 +0,0 @@ -const express = require('express'); -const { check } = require('express-validator'); - -const feedController = require('../controllers/feed'); -const isAuth = require('../middleware/is-auth'); - -const router = express.Router(); - -// GET /feed/posts -router.get('/posts', isAuth, feedController.getPosts); - -router.post( - '/post', - isAuth, - check('title').trim().isLength({ min: 5 }), - check('content').trim().isLength({ min: 5 }), - feedController.createPost -); - -router.get('/post/:postId', isAuth, feedController.getPost); - -router.put( - '/post/:postId', - isAuth, - check('title').trim().isLength({ min: 5 }), - check('content').trim().isLength({ min: 5 }), - feedController.updatePost -); - -router.delete('/post/:postId', isAuth, feedController.deletePost); - -router.get('/status', isAuth, feedController.getStatus); - -router.put( - '/status', - isAuth, - check('updatedStatus') - .trim() - .isLength({ min: 1, max: 20 }) - .withMessage("Can't be empty or langer than 20 characters."), - feedController.updateStatus -); - -module.exports = router; diff --git a/socket.js b/socket.js deleted file mode 100644 index 2ca2fb1..0000000 --- a/socket.js +++ /dev/null @@ -1,21 +0,0 @@ -let io; - -module.exports = { - init: (httpServer) => { - io = require('socket.io')(httpServer, { - cors: { - origin: 'http://localhost:3000', - methods: ['GET', 'POST'], - allowedHeaders: ['Content-Type', 'Authorization'], - credentials: true, - }, - }); - return io; - }, - getIO: () => { - if (!io) { - throw new Error('Socket io not initialized'); - } - return io; - }, -}; diff --git a/utils/file.js b/utils/file.js new file mode 100644 index 0000000..4304b8c --- /dev/null +++ b/utils/file.js @@ -0,0 +1,9 @@ +const path = require('path'); +const fs = require('fs'); + +const clearImage = (filePath) => { + filePath = path.join(__dirname, '..', filePath); + fs.unlink(filePath, (err) => console.log(err)); +}; + +exports.clearImage = clearImage;