mirror of
https://github.com/hknsh/project-knedita.git
synced 2024-11-28 17:41:15 +00:00
Added Multer and Sharp for image upload and compression.
This commit is contained in:
parent
cb9cf02614
commit
4382b35fe3
22 changed files with 5864 additions and 537 deletions
25
.env.example
25
.env.example
|
@ -1,22 +1,33 @@
|
|||
# Environment
|
||||
NODE_ENV=development
|
||||
|
||||
# Postgres config
|
||||
POSTGRES_DB=<placeholder>
|
||||
POSTGRES_USER=<placeholder>
|
||||
POSTGRES_PASSWORD=<placeholder>
|
||||
# POSTGRES CONTAINER NAME
|
||||
DB_HOST=postgres
|
||||
|
||||
# Use this instead if you want to use locally
|
||||
# DB_HOST=localhost
|
||||
# Docker DB host
|
||||
# DB_HOST=postgres
|
||||
|
||||
DB_HOST=localhost
|
||||
|
||||
DATABASE_URL=postgresql://<placeholder>:<placeholder>@${DB_HOST}:5432/${POSTGRES_DB}?schema=<placeholder>
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=redis
|
||||
REDIS_HOST=127.0.0.1 # localhost (for docker use redis)
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=not_a_production_pass # Please change this
|
||||
REDIS_PASSWORD=<placehoder>
|
||||
|
||||
# Express
|
||||
SERVER_PORT=<placeholder>
|
||||
|
||||
# Security
|
||||
JWT_ACCESS_SECRET=<placeholder>
|
||||
JWT_ACCESS_SECRET=<placeholder>
|
||||
|
||||
# Localstack - The data can be fake (Change this to real data on production)
|
||||
DOCKER_HOST=unix:///var/run/docker.sock
|
||||
SERVICES=s3
|
||||
AWS_ACCESS_KEY_ID=<placeholder>
|
||||
AWS_SECRET_ACCESS_KEY=<placeholder>
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_DEFAULT_OUTPUT=json
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -6,4 +6,5 @@ prisma/*.db
|
|||
prisma/migrations/dev
|
||||
client
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
pnpm-lock.yaml
|
||||
package_backup.json
|
10
README.md
10
README.md
|
@ -12,11 +12,13 @@ An open-source social media.
|
|||
## To-do - Backend
|
||||
|
||||
- Create/update/delete Posts ✅
|
||||
- Add post attachments
|
||||
- Create/update/delete Users ✅
|
||||
- Password recuperation
|
||||
- Two step verification
|
||||
- Able to choose a profile picture
|
||||
- Probably gonna use LocalStack to mock Amazon S3
|
||||
- Able to choose a profile picture✅
|
||||
- Probably gonna use LocalStack to mock Amazon S3✅
|
||||
- Image compression ✅
|
||||
- Following/unfollowing features
|
||||
- Like posts
|
||||
- Probably pinned posts
|
||||
|
@ -25,10 +27,6 @@ An open-source social media.
|
|||
- Set display name ✅
|
||||
- Add rate limit ✅
|
||||
|
||||
## Known problems
|
||||
|
||||
- Tests taking too long
|
||||
|
||||
## License
|
||||
|
||||
[MIT](https://choosealicense.com/licenses/mit/)
|
||||
|
|
|
@ -11,6 +11,16 @@ services:
|
|||
- .env
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
restart: unless-stopped
|
||||
container_name: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
command: redis-server --save 20 1 --loglevel warning --requirepass not_a_production_pass
|
||||
volumes:
|
||||
- redis:/data
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
version: '3.8'
|
||||
|
||||
networks:
|
||||
localstack-net:
|
||||
name: localstack-net
|
||||
driver: bridge
|
||||
|
||||
services:
|
||||
api:
|
||||
container_name: api
|
||||
|
@ -12,6 +17,7 @@ services:
|
|||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
- localstack
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
|
@ -25,6 +31,21 @@ services:
|
|||
- .env
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
localstack:
|
||||
image: localstack/localstack
|
||||
container_name: localstack_main
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- localstack-net
|
||||
ports:
|
||||
- 4566:4566
|
||||
- 4572:4572
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- localstack:/data
|
||||
- "/var/run/docker.sock:/var/run/docker.sock"
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
|
@ -40,4 +61,5 @@ volumes:
|
|||
postgres:
|
||||
name: backend-db
|
||||
redis:
|
||||
driver: local
|
||||
driver: local
|
||||
localstack:
|
6006
package-lock.json
generated
6006
package-lock.json
generated
File diff suppressed because it is too large
Load diff
14
package.json
14
package.json
|
@ -15,6 +15,7 @@
|
|||
"prisma:generate": "npx prisma generate",
|
||||
"prisma:seed": "prisma db seed",
|
||||
"prisma:studio": "npx prisma studio",
|
||||
"prisma:deploy": "npx prisma migrate deploy",
|
||||
"prod:start": "pm2-runtime start dist/server.js",
|
||||
"test": "jest"
|
||||
},
|
||||
|
@ -38,14 +39,15 @@
|
|||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/jsonwebtoken": "^9.0.2",
|
||||
"@types/multer-s3": "^3.0.0",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@types/validator": "^13.7.17",
|
||||
"@typescript-eslint/eslint-plugin": "^5.60.0",
|
||||
"jest": "^29.5.0",
|
||||
"nodemon": "^2.0.22",
|
||||
"pm2": "^5.3.0",
|
||||
"prisma": "^4.16.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"pm2": "^4.2.3",
|
||||
"prisma": "^5.0.0",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
|
@ -53,7 +55,8 @@
|
|||
"typescript": "^5.1.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^4.16.0",
|
||||
"@prisma/client": "^5.0.0",
|
||||
"aws-sdk": "^2.1414.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"compression": "^1.7.4",
|
||||
"dotenv": "^16.3.1",
|
||||
|
@ -61,7 +64,10 @@
|
|||
"express-rate-limit": "^6.7.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer-s3": "^3.0.1",
|
||||
"rate-limit-redis": "^3.0.2",
|
||||
"sharp": "^0.32.3",
|
||||
"validator": "^13.9.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "pfpUrl" TEXT;
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `pfpUrl` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "pfpUrl",
|
||||
ADD COLUMN "profilePicture" TEXT;
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `profilePicture` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "profilePicture",
|
||||
ADD COLUMN "profileImage" TEXT;
|
|
@ -11,13 +11,14 @@ datasource db {
|
|||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
displayName String?
|
||||
username String @unique
|
||||
email String @unique
|
||||
password String
|
||||
posts Post[]
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
displayName String?
|
||||
username String @unique
|
||||
email String @unique
|
||||
password String
|
||||
posts Post[]
|
||||
profileImage String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Post {
|
||||
|
|
|
@ -3,12 +3,20 @@ import 'dotenv/config'
|
|||
import express from 'express'
|
||||
import router from './routes'
|
||||
import compression from 'compression'
|
||||
import limiter from './middlewares/rate-limit'
|
||||
|
||||
const app = express()
|
||||
|
||||
// TODO: Disable image resize when it's a post attachment
|
||||
// TODO: Add user-upload-picture tests
|
||||
// TODO: Apply http-errors lib on the controllers
|
||||
// TODO: Automatically apply the newest migration when starting up the docker
|
||||
// TODO: Refactor some parts of the code
|
||||
|
||||
app.use(express.json())
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
app.use(router)
|
||||
app.use(limiter)
|
||||
app.use(compression({ level: 9 }))
|
||||
|
||||
app.use((_req, res) => {
|
||||
|
|
23
src/config/clients/s3-client.ts
Normal file
23
src/config/clients/s3-client.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { S3Client } from '@aws-sdk/client-s3'
|
||||
|
||||
let s3: S3Client
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Using Localstack services instead of AWS.')
|
||||
s3 = new S3Client({
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '',
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? ''
|
||||
},
|
||||
endpoint: 'http://127.0.0.1:4566' // Uses localstack instead of aws, make sure to create the bucket first with public-read acl
|
||||
})
|
||||
} else {
|
||||
s3 = new S3Client({
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '',
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default s3
|
64
src/config/multer.ts
Normal file
64
src/config/multer.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
import multer from 'multer'
|
||||
import { Request } from 'express'
|
||||
import path from 'path'
|
||||
import s3 from './clients/s3-client'
|
||||
import multerS3 from 'multer-s3'
|
||||
|
||||
const tempFolder = path.resolve(__dirname, '..', '..', 'temp', 'uploads')
|
||||
|
||||
const storageTypes = {
|
||||
local: multer.diskStorage({
|
||||
destination: (req: Request, file: Express.Multer.File, callback) => {
|
||||
callback(null, tempFolder)
|
||||
},
|
||||
|
||||
filename: (req: Request, file: Express.Multer.File, callback) => {
|
||||
/* eslint-disable */
|
||||
const folder = req.body.isProfilePicture ? 'profile_images' : 'media'
|
||||
const fileName: string = `${folder}/${req.user!.id}.webp`
|
||||
|
||||
callback(null, fileName)
|
||||
}
|
||||
}),
|
||||
|
||||
s3: multerS3({
|
||||
s3,
|
||||
bucket: process.env.AWS_BUCKET ?? '',
|
||||
contentType: multerS3.AUTO_CONTENT_TYPE,
|
||||
acl: 'public-read',
|
||||
key: (req: Request, file: Express.Multer.File, callback) => {
|
||||
let folder
|
||||
|
||||
if (req.body.isProfilePicture === 'true') {
|
||||
folder = 'profile_images'
|
||||
} else {
|
||||
folder = 'media'
|
||||
}
|
||||
|
||||
const fileName: string = `${folder}/${req.user!.id}.jpg`
|
||||
callback(null, fileName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const multerConfig = {
|
||||
dest: tempFolder,
|
||||
storage: storageTypes.s3,
|
||||
limits: {
|
||||
fileSize: 15 * 1024 * 1024 // 1mb
|
||||
},
|
||||
fileFilter: (req: Request, file: Express.Multer.File, callback: multer.FileFilterCallback) => {
|
||||
const allowedMimes = [
|
||||
'image/jpeg',
|
||||
'image/png'
|
||||
]
|
||||
|
||||
if (allowedMimes.includes(file.mimetype)) {
|
||||
callback(null, true)
|
||||
} else {
|
||||
callback(new Error('Filetype not allowed'))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default multerConfig
|
|
@ -8,18 +8,20 @@ import userDeleteController from './users/user-delete'
|
|||
import userInfoController from './users/user-info'
|
||||
import userSignupController from './users/user-signup'
|
||||
import userUpdateController from './users/user-update'
|
||||
import userUploadPictureController from './users/user-upload-picture'
|
||||
|
||||
// Middlewares
|
||||
import ensureAuthenticated from '../middlewares/ensure-authenticated'
|
||||
import limiter from '../middlewares/rate-limit'
|
||||
import uploadFile from '../middlewares/upload-image'
|
||||
|
||||
const usersRouter = Router()
|
||||
|
||||
// Users related
|
||||
usersRouter.post('/auth', userAuthController)
|
||||
usersRouter.post('/delete', ensureAuthenticated, userDeleteController)
|
||||
usersRouter.get('/info', limiter, userInfoController)
|
||||
usersRouter.get('/info', userInfoController)
|
||||
usersRouter.post('/signup', userSignupController)
|
||||
usersRouter.put('/update', ensureAuthenticated, userUpdateController)
|
||||
usersRouter.put('/profile-picture/upload', ensureAuthenticated, uploadFile, userUploadPictureController)
|
||||
|
||||
export default usersRouter
|
||||
|
|
32
src/controllers/users/user-upload-picture.ts
Normal file
32
src/controllers/users/user-upload-picture.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import userUploadPictureService from '../../services/users/upload-picture'
|
||||
import { Request, Response } from 'express'
|
||||
import { badRequest } from '../../lib/http-errors'
|
||||
|
||||
let url
|
||||
|
||||
async function userUploadPictureController (req: Request, res: Response): Promise<void> {
|
||||
if (req.file === undefined) {
|
||||
return badRequest(res, 'Expected a JPG or PNG file')
|
||||
}
|
||||
|
||||
const userId = req.user?.id ?? ''
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
/* eslint-disable */
|
||||
// @ts-expect-error property `key` doesn't exists in @types/express
|
||||
url = `http://${process.env.AWS_BUCKET ?? ''}.s3.localhost.localstack.cloud:4566/${req.file.key}`
|
||||
} else {
|
||||
// @ts-expect-error property `location` doesn't exists in @types/express
|
||||
url = req.file.location
|
||||
}
|
||||
|
||||
const result = await userUploadPictureService(userId, url)
|
||||
|
||||
if (result instanceof Error) {
|
||||
return badRequest(res, result.message)
|
||||
}
|
||||
|
||||
res.json(result)
|
||||
}
|
||||
|
||||
export default userUploadPictureController
|
35
src/lib/compress-image.ts
Normal file
35
src/lib/compress-image.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import sharp from 'sharp'
|
||||
import s3 from '../config/clients/s3-client'
|
||||
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'
|
||||
|
||||
export default async function compressImage (imageName: string): Promise<Error | Object > {
|
||||
// Get file from s3
|
||||
const { Body } = await s3.send(new GetObjectCommand({
|
||||
Bucket: process.env.AWS_BUCKET ?? '',
|
||||
Key: imageName
|
||||
}))
|
||||
|
||||
const imageBuffer = await Body?.transformToByteArray()
|
||||
|
||||
const compressedImageBuffer = await sharp(imageBuffer)
|
||||
.resize(200, 200)
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer()
|
||||
|
||||
// Send file back
|
||||
const params = {
|
||||
Bucket: process.env.AWS_BUCKET ?? '',
|
||||
Key: imageName,
|
||||
Body: compressedImageBuffer,
|
||||
ContentType: 'image/jpeg',
|
||||
ContentDisposition: 'inline'
|
||||
}
|
||||
|
||||
const { ETag } = await s3.send(new PutObjectCommand(params))
|
||||
|
||||
if (ETag !== null) {
|
||||
return {}
|
||||
} else {
|
||||
return new Error('Error while compressing image')
|
||||
}
|
||||
}
|
21
src/lib/http-errors.ts
Normal file
21
src/lib/http-errors.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { Response } from 'express'
|
||||
|
||||
const sendErrorResponse = (res: Response, status: number, message: string): void => {
|
||||
res.status(status).json({ error: message })
|
||||
}
|
||||
|
||||
export const badRequest = (res: Response, message = 'Bad Request'): void => {
|
||||
sendErrorResponse(res, 400, message)
|
||||
}
|
||||
|
||||
export const unauthorized = (res: Response, message = 'Unauthorized'): void => {
|
||||
sendErrorResponse(res, 401, message)
|
||||
}
|
||||
|
||||
export const forbidden = (res: Response, message = 'Forbidden'): void => {
|
||||
sendErrorResponse(res, 403, message)
|
||||
}
|
||||
|
||||
export const internalServerError = (res: Response, message = 'Internal Server Error'): void => {
|
||||
sendErrorResponse(res, 500, message)
|
||||
}
|
|
@ -8,9 +8,18 @@ const redisPort = process.env.REDIS_PORT ?? ''
|
|||
|
||||
const client = new RedisClient(`redis://:${redisPassword}@${redisHost}:${redisPort}/0`)
|
||||
|
||||
let maxConnections
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Detected Development environment, disabling rate limiter.')
|
||||
maxConnections = 0
|
||||
} else {
|
||||
maxConnections = 5
|
||||
}
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 60 seconds
|
||||
max: 5,
|
||||
max: maxConnections,
|
||||
message: { error: 'Too many requests' },
|
||||
legacyHeaders: false,
|
||||
|
||||
|
|
37
src/middlewares/upload-image.ts
Normal file
37
src/middlewares/upload-image.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { Response, Request, NextFunction } from 'express'
|
||||
import multer from 'multer'
|
||||
import multerConfig from '../config/multer'
|
||||
import compressImage from '../lib/compress-image'
|
||||
|
||||
function uploadImage (req: Request, res: Response, next: NextFunction) {
|
||||
const upload = multer(multerConfig).single('image')
|
||||
|
||||
upload(req, res, async (cb: multer.MulterError | Error | any) => {
|
||||
if (req.user == null) {
|
||||
return res.status(400).json({
|
||||
error: 'You must be logged in to upload a profile picture'
|
||||
})
|
||||
}
|
||||
|
||||
if (cb instanceof multer.MulterError || cb instanceof Error) {
|
||||
return res.status(400).json({
|
||||
error: cb.message
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error property `key` does not exists in types
|
||||
const compressed = await compressImage(req.file?.key)
|
||||
|
||||
if (compressed instanceof Error) {
|
||||
return res.status(500).json({
|
||||
error: compressed.message
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
}
|
||||
|
||||
export default uploadImage
|
36
src/services/users/upload-picture.ts
Normal file
36
src/services/users/upload-picture.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import prisma from '../../db'
|
||||
|
||||
async function userUploadPictureService (authorId: string, url: string): Promise<Object | Error> {
|
||||
const user = await prisma.user.findFirst({ where: { id: authorId } })
|
||||
|
||||
if (user == null) {
|
||||
return new Error('User does not exists')
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: authorId
|
||||
},
|
||||
data: {
|
||||
profileImage: url
|
||||
},
|
||||
select: {
|
||||
profileImage: true,
|
||||
displayName: true,
|
||||
username: true,
|
||||
createdAt: true,
|
||||
posts: {
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
updatedAt: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return updatedUser
|
||||
}
|
||||
|
||||
export default userUploadPictureService
|
|
@ -6,6 +6,7 @@ async function userInfoService (username: string): Promise<Object> {
|
|||
username
|
||||
},
|
||||
select: {
|
||||
profileImage: true,
|
||||
displayName: true,
|
||||
username: true,
|
||||
createdAt: true,
|
||||
|
|
Loading…
Reference in a new issue