Added Multer and Sharp for image upload and compression.

This commit is contained in:
Hackntosh 2023-07-17 17:47:05 -03:00
parent cb9cf02614
commit 4382b35fe3
22 changed files with 5864 additions and 537 deletions

View file

@ -1,22 +1,33 @@
# Environment
NODE_ENV=development
# Postgres config # Postgres config
POSTGRES_DB=<placeholder> POSTGRES_DB=<placeholder>
POSTGRES_USER=<placeholder> POSTGRES_USER=<placeholder>
POSTGRES_PASSWORD=<placeholder> POSTGRES_PASSWORD=<placeholder>
# POSTGRES CONTAINER NAME
DB_HOST=postgres
# Use this instead if you want to use locally # Docker DB host
# DB_HOST=localhost # DB_HOST=postgres
DB_HOST=localhost
DATABASE_URL=postgresql://<placeholder>:<placeholder>@${DB_HOST}:5432/${POSTGRES_DB}?schema=<placeholder> DATABASE_URL=postgresql://<placeholder>:<placeholder>@${DB_HOST}:5432/${POSTGRES_DB}?schema=<placeholder>
# Redis # Redis
REDIS_HOST=redis REDIS_HOST=127.0.0.1 # localhost (for docker use redis)
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD=not_a_production_pass # Please change this REDIS_PASSWORD=<placehoder>
# Express # Express
SERVER_PORT=<placeholder> SERVER_PORT=<placeholder>
# Security # 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

1
.gitignore vendored
View file

@ -7,3 +7,4 @@ prisma/migrations/dev
client client
dist dist
pnpm-lock.yaml pnpm-lock.yaml
package_backup.json

View file

@ -12,11 +12,13 @@ An open-source social media.
## To-do - Backend ## To-do - Backend
- Create/update/delete Posts ✅ - Create/update/delete Posts ✅
- Add post attachments
- Create/update/delete Users ✅ - Create/update/delete Users ✅
- Password recuperation - Password recuperation
- Two step verification - Two step verification
- Able to choose a profile picture - Able to choose a profile picture✅
- Probably gonna use LocalStack to mock Amazon S3 - Probably gonna use LocalStack to mock Amazon S3✅
- Image compression ✅
- Following/unfollowing features - Following/unfollowing features
- Like posts - Like posts
- Probably pinned posts - Probably pinned posts
@ -25,10 +27,6 @@ An open-source social media.
- Set display name ✅ - Set display name ✅
- Add rate limit ✅ - Add rate limit ✅
## Known problems
- Tests taking too long
## License ## License
[MIT](https://choosealicense.com/licenses/mit/) [MIT](https://choosealicense.com/licenses/mit/)

View file

@ -12,6 +12,16 @@ services:
volumes: volumes:
- postgres:/var/lib/postgresql/data - 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: volumes:
postgres: postgres:
name: backend-db name: backend-db

View file

@ -1,5 +1,10 @@
version: '3.8' version: '3.8'
networks:
localstack-net:
name: localstack-net
driver: bridge
services: services:
api: api:
container_name: api container_name: api
@ -12,6 +17,7 @@ services:
depends_on: depends_on:
- postgres - postgres
- redis - redis
- localstack
env_file: env_file:
- .env - .env
@ -26,6 +32,21 @@ services:
volumes: volumes:
- postgres:/var/lib/postgresql/data - 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: redis:
image: redis:alpine image: redis:alpine
restart: unless-stopped restart: unless-stopped
@ -41,3 +62,4 @@ volumes:
name: backend-db name: backend-db
redis: redis:
driver: local driver: local
localstack:

6006
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,7 @@
"prisma:generate": "npx prisma generate", "prisma:generate": "npx prisma generate",
"prisma:seed": "prisma db seed", "prisma:seed": "prisma db seed",
"prisma:studio": "npx prisma studio", "prisma:studio": "npx prisma studio",
"prisma:deploy": "npx prisma migrate deploy",
"prod:start": "pm2-runtime start dist/server.js", "prod:start": "pm2-runtime start dist/server.js",
"test": "jest" "test": "jest"
}, },
@ -38,14 +39,15 @@
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/jest": "^29.5.2", "@types/jest": "^29.5.2",
"@types/jsonwebtoken": "^9.0.2", "@types/jsonwebtoken": "^9.0.2",
"@types/multer-s3": "^3.0.0",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@types/supertest": "^2.0.12", "@types/supertest": "^2.0.12",
"@types/validator": "^13.7.17", "@types/validator": "^13.7.17",
"@typescript-eslint/eslint-plugin": "^5.60.0", "@typescript-eslint/eslint-plugin": "^5.60.0",
"jest": "^29.5.0", "jest": "^29.5.0",
"nodemon": "^2.0.22", "nodemon": "^3.0.1",
"pm2": "^5.3.0", "pm2": "^4.2.3",
"prisma": "^4.16.0", "prisma": "^5.0.0",
"supertest": "^6.3.3", "supertest": "^6.3.3",
"ts-jest": "^29.1.0", "ts-jest": "^29.1.0",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
@ -53,7 +55,8 @@
"typescript": "^5.1.3" "typescript": "^5.1.3"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^4.16.0", "@prisma/client": "^5.0.0",
"aws-sdk": "^2.1414.0",
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
@ -61,7 +64,10 @@
"express-rate-limit": "^6.7.1", "express-rate-limit": "^6.7.1",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer-s3": "^3.0.1",
"rate-limit-redis": "^3.0.2", "rate-limit-redis": "^3.0.2",
"sharp": "^0.32.3",
"validator": "^13.9.0" "validator": "^13.9.0"
} }
} }

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "pfpUrl" TEXT;

View file

@ -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;

View file

@ -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;

View file

@ -11,13 +11,14 @@ datasource db {
} }
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
displayName String? displayName String?
username String @unique username String @unique
email String @unique email String @unique
password String password String
posts Post[] posts Post[]
createdAt DateTime @default(now()) profileImage String?
createdAt DateTime @default(now())
} }
model Post { model Post {

View file

@ -3,12 +3,20 @@ import 'dotenv/config'
import express from 'express' import express from 'express'
import router from './routes' import router from './routes'
import compression from 'compression' import compression from 'compression'
import limiter from './middlewares/rate-limit'
const app = express() 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.json())
app.use(express.urlencoded({ extended: true })) app.use(express.urlencoded({ extended: true }))
app.use(router) app.use(router)
app.use(limiter)
app.use(compression({ level: 9 })) app.use(compression({ level: 9 }))
app.use((_req, res) => { app.use((_req, res) => {

View 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
View 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

View file

@ -8,18 +8,20 @@ import userDeleteController from './users/user-delete'
import userInfoController from './users/user-info' import userInfoController from './users/user-info'
import userSignupController from './users/user-signup' import userSignupController from './users/user-signup'
import userUpdateController from './users/user-update' import userUpdateController from './users/user-update'
import userUploadPictureController from './users/user-upload-picture'
// Middlewares // Middlewares
import ensureAuthenticated from '../middlewares/ensure-authenticated' import ensureAuthenticated from '../middlewares/ensure-authenticated'
import limiter from '../middlewares/rate-limit' import uploadFile from '../middlewares/upload-image'
const usersRouter = Router() const usersRouter = Router()
// Users related // Users related
usersRouter.post('/auth', userAuthController) usersRouter.post('/auth', userAuthController)
usersRouter.post('/delete', ensureAuthenticated, userDeleteController) usersRouter.post('/delete', ensureAuthenticated, userDeleteController)
usersRouter.get('/info', limiter, userInfoController) usersRouter.get('/info', userInfoController)
usersRouter.post('/signup', userSignupController) usersRouter.post('/signup', userSignupController)
usersRouter.put('/update', ensureAuthenticated, userUpdateController) usersRouter.put('/update', ensureAuthenticated, userUpdateController)
usersRouter.put('/profile-picture/upload', ensureAuthenticated, uploadFile, userUploadPictureController)
export default usersRouter export default usersRouter

View 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
View 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
View 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)
}

View file

@ -8,9 +8,18 @@ const redisPort = process.env.REDIS_PORT ?? ''
const client = new RedisClient(`redis://:${redisPassword}@${redisHost}:${redisPort}/0`) 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({ const limiter = rateLimit({
windowMs: 1 * 60 * 1000, // 60 seconds windowMs: 1 * 60 * 1000, // 60 seconds
max: 5, max: maxConnections,
message: { error: 'Too many requests' }, message: { error: 'Too many requests' },
legacyHeaders: false, legacyHeaders: false,

View 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

View 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

View file

@ -6,6 +6,7 @@ async function userInfoService (username: string): Promise<Object> {
username username
}, },
select: { select: {
profileImage: true,
displayName: true, displayName: true,
username: true, username: true,
createdAt: true, createdAt: true,