feat: migrate from express to fastify

This commit is contained in:
Divlo
2021-10-24 04:18:18 +02:00
parent 714cc643ba
commit b77e602358
281 changed files with 19768 additions and 22895 deletions

View File

@ -0,0 +1,95 @@
import {
expiresIn,
generateAccessToken,
generateRefreshToken,
ResponseJWT
} from './jwtToken.js'
import prisma from '../database/prisma.js'
import { ProviderOAuth } from '../../models/OAuth.js'
import { UserRequest } from '../../models/User.js'
interface ProviderData {
name: string
id: number | string
}
type ResponseCallbackAddStrategy =
| 'success'
| 'This account is already used by someone else'
| 'You are already using this account'
export class OAuthStrategy {
constructor(public provider: ProviderOAuth) {}
async callbackAddStrategy(
providerData: ProviderData,
userRequest: UserRequest
): Promise<ResponseCallbackAddStrategy> {
const OAuthUser = await prisma.oAuth.findFirst({
where: { providerId: providerData.id.toString(), provider: this.provider }
})
let message: ResponseCallbackAddStrategy = 'success'
if (OAuthUser == null) {
await prisma.oAuth.create({
data: {
provider: this.provider,
providerId: providerData.id.toString(),
userId: userRequest.current.id
}
})
} else if (OAuthUser.userId !== userRequest.current.id) {
message = 'This account is already used by someone else'
} else {
message = 'You are already using this account'
}
return message
}
async callbackSignin(providerData: ProviderData): Promise<ResponseJWT> {
const OAuthUser = await prisma.oAuth.findFirst({
where: { providerId: providerData.id.toString(), provider: this.provider }
})
let userId: number = OAuthUser?.userId ?? 0
if (OAuthUser == null) {
let name = providerData.name
let isAlreadyUsedName = true
let countId: string | number = providerData.id
while (isAlreadyUsedName) {
const foundUsers = await prisma.user.count({ where: { name } })
isAlreadyUsedName = foundUsers > 0
if (isAlreadyUsedName) {
name = `${name}-${countId}`
countId = Math.random() * Date.now()
}
}
const user = await prisma.user.create({ data: { name } })
await prisma.userSetting.create({
data: {
userId: user.id
}
})
userId = user.id
await prisma.oAuth.create({
data: {
provider: this.provider,
providerId: providerData.id.toString(),
userId
}
})
}
const accessToken = generateAccessToken({
currentStrategy: this.provider,
id: userId
})
const refreshToken = await generateRefreshToken({
currentStrategy: this.provider,
id: userId
})
return {
accessToken,
refreshToken,
expiresIn,
type: 'Bearer'
}
}
}

View File

@ -0,0 +1,137 @@
import { userExample } from '../../../models/User.js'
import { userSettingsExample } from '../../../models/UserSettings.js'
import { prismaMock } from '../../../__test__/setup.js'
import { OAuthStrategy } from '../OAuthStrategy.js'
const oauthStrategy = new OAuthStrategy('discord')
describe('/utils/OAuthStrategy - callbackSignin', () => {
it('should signup the user', async () => {
const name = 'Martin'
const id = '12345'
prismaMock.oAuth.findFirst.mockResolvedValue(null)
prismaMock.user.count.mockResolvedValue(0)
prismaMock.user.create.mockResolvedValue({
...userExample,
name
})
prismaMock.userSetting.create.mockResolvedValue(userSettingsExample)
prismaMock.oAuth.create.mockResolvedValue({
id: 1,
userId: userExample.id,
provider: 'discord',
providerId: id,
updatedAt: new Date(),
createdAt: new Date()
})
await oauthStrategy.callbackSignin({ id, name })
expect(prismaMock.oAuth.findFirst).toHaveBeenCalledWith({
where: {
provider: 'discord',
providerId: id
}
})
expect(prismaMock.user.count).toHaveBeenCalledWith({
where: { name }
})
expect(prismaMock.user.create).toHaveBeenCalledWith({
data: { name }
})
expect(prismaMock.userSetting.create).toHaveBeenCalledWith({
data: {
userId: userExample.id
}
})
expect(prismaMock.oAuth.create).toHaveBeenCalledWith({
data: {
userId: userExample.id,
provider: 'discord',
providerId: id
}
})
})
})
describe('/utils/OAuthStrategy - callbackAddStrategy', () => {
it('should add the strategy to the user', async () => {
const name = userExample.name
const id = '12345'
prismaMock.oAuth.findFirst.mockResolvedValue(null)
prismaMock.oAuth.create.mockResolvedValue({
id: 1,
userId: userExample.id,
provider: 'discord',
providerId: id,
updatedAt: new Date(),
createdAt: new Date()
})
const result = await oauthStrategy.callbackAddStrategy(
{ id, name },
{ accessToken: '123', current: userExample, currentStrategy: 'local' }
)
expect(prismaMock.oAuth.findFirst).toHaveBeenCalledWith({
where: {
provider: 'discord',
providerId: id
}
})
expect(prismaMock.oAuth.create).toHaveBeenCalledWith({
data: {
userId: userExample.id,
provider: 'discord',
providerId: id
}
})
expect(result).toEqual('success')
})
it('should not add the strategy if the account of the provider is already used', async () => {
const name = userExample.name
const id = '12345'
prismaMock.oAuth.findFirst.mockResolvedValue({
id: 1,
userId: 2,
provider: 'discord',
providerId: id,
updatedAt: new Date(),
createdAt: new Date()
})
const result = await oauthStrategy.callbackAddStrategy(
{ id, name },
{ accessToken: '123', current: userExample, currentStrategy: 'local' }
)
expect(prismaMock.oAuth.findFirst).toHaveBeenCalledWith({
where: {
provider: 'discord',
providerId: id
}
})
expect(prismaMock.oAuth.create).not.toHaveBeenCalled()
expect(result).toEqual('This account is already used by someone else')
})
it('should not add the strategy if the user is already connected with it', async () => {
const name = userExample.name
const id = '12345'
prismaMock.oAuth.findFirst.mockResolvedValue({
id: 1,
userId: userExample.id,
provider: 'discord',
providerId: id,
updatedAt: new Date(),
createdAt: new Date()
})
const result = await oauthStrategy.callbackAddStrategy(
{ id, name },
{ accessToken: '123', current: userExample, currentStrategy: 'local' }
)
expect(prismaMock.oAuth.findFirst).toHaveBeenCalledWith({
where: {
provider: 'discord',
providerId: id
}
})
expect(prismaMock.oAuth.create).not.toHaveBeenCalled()
expect(result).toEqual('You are already using this account')
})
})

View File

@ -0,0 +1,20 @@
import { buildQueryURL } from '../buildQueryURL.js'
test('controllers/users/utils/buildQueryUrl', () => {
expect(
buildQueryURL('http://localhost:8080', {
test: 'query'
})
).toEqual('http://localhost:8080/?test=query')
expect(
buildQueryURL('http://localhost:8080/', {
test: 'query'
})
).toEqual('http://localhost:8080/?test=query')
expect(
buildQueryURL('http://localhost:3000', {
test: 'query',
code: 'abc'
})
).toEqual('http://localhost:3000/?test=query&code=abc')
})

View File

@ -1,7 +0,0 @@
import { capitalize } from '../capitalize'
test('/tools/utils/capitalize', () => {
expect(capitalize('hello world')).toBe('Hello world')
expect('Test').toBe('Test')
expect('TEST').toBe('TEST')
})

View File

@ -1,124 +0,0 @@
import fsMock from 'mock-fs'
import * as fsWithCallbacks from 'fs'
import {
deleteAllFilesInDirectory,
deleteFile,
deleteMessages
} from '../deleteFiles'
import { messagesFilePath, usersLogoPath } from '../../configurations/constants'
import Message from '../../../models/Message'
import Guild from '../../../models/Guild'
import Member from '../../../models/Member'
import Channel from '../../../models/Channel'
import User from '../../../models/User'
const fs = fsWithCallbacks.promises
describe('/tools/utils/deleteFiles - deleteAllFilesInDirectory', () => {
it('delete all the files expect the directories', async () => {
fsMock({
'/files': {
'default.png': '',
'user-logo.png': '',
'user-logo.jpg': '',
directory: {
file: ''
}
}
})
await deleteAllFilesInDirectory('/files')
const directoryContent = await fs.readdir('/files')
expect(directoryContent.length).toEqual(1)
expect(directoryContent[0]).toEqual('directory')
})
it('delete all the files with all the directories recursively', async () => {
fsMock({
'/files': {
'default.png': '',
'user-logo.png': '',
'user-logo.jpg': '',
directory: {
file: ''
}
}
})
await deleteAllFilesInDirectory('/files', true)
const filesDirectoryContent = await fs.readdir('/files')
const directoryContent = await fs.readdir('/files/directory')
expect(filesDirectoryContent.length).toEqual(1)
expect(directoryContent.length).toEqual(0)
})
})
describe('/tools/utils/deleteFiles - deleteFile', () => {
it('should delete the file', async () => {
fsMock({
[usersLogoPath.filePath]: {
'logo.png': ''
}
})
await deleteFile({
basePath: usersLogoPath,
valueSavedInDatabase: `${usersLogoPath.name}/logo.png`
})
const directoryContent = await fs.readdir(usersLogoPath.filePath)
expect(directoryContent.length).toEqual(0)
})
it('should not delete the default file', async () => {
fsMock({
[usersLogoPath.filePath]: {
'logo.png': '',
'default.png': ''
}
})
await deleteFile({
basePath: usersLogoPath,
valueSavedInDatabase: `${usersLogoPath.name}/default.png`
})
const directoryContent = await fs.readdir(usersLogoPath.filePath)
expect(directoryContent.length).toEqual(2)
})
})
describe('/tools/utils/deleteFiles - deleteMessages', () => {
it('should delete every messages and files', async () => {
fsMock({
[messagesFilePath.filePath]: {
'logo.png': '',
'random-file.mp3': '',
'file-without-message': ''
}
})
const user = await User.create({ name: 'John' })
const guild = await Guild.create({ name: 'testing' })
const channel = await Channel.create({
name: 'general',
isDefault: true,
guildId: guild.id
})
const member = await Member.create({
userId: user.id,
guildId: guild.id,
isOwner: true,
lastVisitedChannelId: channel.id
})
const messagesToCreate = [
{ value: `${messagesFilePath.name}/logo.png`, type: 'file' },
{ value: `${messagesFilePath.name}/random-file.mp3`, type: 'file' }
]
const messages = messagesToCreate.map(async (message) => {
return await Message.create({
value: message.value,
type: message.type,
memberId: member.id,
channelId: channel.id
})
})
await deleteMessages(await Promise.all(messages))
const directoryContent = await fs.readdir(messagesFilePath.filePath)
expect(directoryContent.length).toEqual(1)
})
})

View File

@ -1,9 +0,0 @@
import { deleteObjectAttributes } from '../deleteObjectAttributes'
test('/tools/utils/deleteObjectAttributes', () => {
const object = { attribute1: 'value1', attribute2: 'value2' }
const hiddenObjectAttributes = ['attribute2']
const result = deleteObjectAttributes(object, hiddenObjectAttributes)
expect(result.attribute1).toEqual('value1')
expect(result.attribute2).not.toBeDefined()
})

View File

@ -1,8 +0,0 @@
import { parseIntOrDefaultValue } from '../parseIntOrDefaultValue'
test('/tools/utils/parseIntOrDefaultValue', () => {
expect(parseIntOrDefaultValue('12', 10)).toEqual(12)
expect(parseIntOrDefaultValue('shshsksk2', 10)).toEqual(10)
expect(parseIntOrDefaultValue('', 10)).toEqual(10)
expect(parseIntOrDefaultValue(' ', 10)).toEqual(10)
})

View File

@ -1,30 +0,0 @@
import {
randomCharacter,
randomInteger,
randomString,
alphabet
} from '../random'
describe('/tools/utils/random', () => {
test('randomInteger', () => {
const min = 1
const max = 100
const result = randomInteger(min, max)
const isInteger = result % 1 === 0
expect(isInteger).toBeTruthy()
expect(result).toBeGreaterThanOrEqual(min)
expect(result).toBeLessThanOrEqual(max)
})
test('randomCharacter', () => {
const result = randomCharacter()
expect(result.length).toEqual(1)
expect(alphabet.split('').includes(result))
})
test('randomString', () => {
const length = 7
const result = randomString(length)
expect(result.length).toEqual(length)
})
})

View File

@ -1,106 +0,0 @@
import fsMock from 'mock-fs'
import * as fsWithCallbacks from 'fs'
import { UploadedFile } from 'express-fileupload'
import { uploadImage } from '../uploadImage'
import { PayloadTooLargeError } from '../../errors/PayloadTooLargeError'
import { tempPath, usersLogoPath } from '../../configurations/constants'
import { BadRequestError } from '../../errors/BadRequestError'
const fs = fsWithCallbacks.promises
const imagesPath = usersLogoPath.filePath
const getImage = (
props: { truncated?: boolean, mimetype?: string } = {}
): UploadedFile => {
const { truncated = false, mimetype = 'image/png' } = props
return {
name: 'logo',
mv: jest.fn(),
encoding: 'utf-8',
mimetype,
data: Buffer.from([]),
tempFilePath: '/temp/logo.png',
truncated,
size: 1024,
md5: '12345abcd'
}
}
describe('/tools/utils/uploadImage', () => {
it('should succeeds and save the image', async () => {
fsMock({
[tempPath]: {
'logo.png': ''
},
[imagesPath]: {
'logo.png': ''
}
})
const image = getImage()
const result = await uploadImage({
image,
propertyName: 'logo',
oldImage: '/images/logo.png',
imagesPath
})
expect(result).not.toBeNull()
expect(image.mv).toHaveBeenCalled()
const directoryContent = await fs.readdir(tempPath)
expect(directoryContent.length).toEqual(0)
})
it('should returns null with undefined image file(s)', async () => {
const result = await uploadImage({
image: [getImage(), getImage()],
propertyName: 'logo',
oldImage: '/images/logo.png',
imagesPath
})
const result2 = await uploadImage({
image: undefined,
propertyName: 'logo',
oldImage: '/images/logo.png',
imagesPath
})
expect(result).toBeNull()
expect(result2).toBeNull()
})
it('should fails if the file is over the size limit', async () => {
fsMock({
[tempPath]: {
'logo.png': ''
}
})
await expect(
uploadImage({
image: getImage({ truncated: true }),
propertyName: 'logo',
oldImage: '/images/logo.png',
imagesPath
})
).rejects.toThrow(PayloadTooLargeError)
const directoryContent = await fs.readdir(tempPath)
expect(directoryContent.length).toEqual(0)
})
it('should fails if the file is not an image', async () => {
fsMock({
[tempPath]: {
'logo.png': ''
}
})
await expect(
uploadImage({
image: getImage({ mimetype: 'text/html' }),
propertyName: 'logo',
oldImage: '/images/logo.png',
imagesPath
})
).rejects.toThrow(BadRequestError)
const directoryContent = await fs.readdir(tempPath)
expect(directoryContent.length).toEqual(0)
})
})

View File

@ -0,0 +1,16 @@
import { URL } from 'node:url'
export interface ObjectAny {
[key: string]: any
}
export const buildQueryURL = (
baseURL: string,
queryObject: ObjectAny
): string => {
const url = new URL(baseURL)
Object.entries(queryObject).forEach(([query, value]) => {
url.searchParams.append(query, value)
})
return url.href
}

View File

@ -1,4 +0,0 @@
/** converts the first character of the string to capital (uppercase) letter */
export function capitalize (s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1)
}

View File

@ -1,57 +0,0 @@
import * as fsWithCallbacks from 'fs'
import path from 'path'
import {
guildsIconPath,
messagesFilePath,
usersLogoPath
} from '../configurations/constants'
import Message from '../../models/Message'
const fs = fsWithCallbacks.promises
export const deleteAllFilesInDirectory = async (
directoryPath: string,
isRecursive: boolean = false
): Promise<void> => {
const files = await fs.readdir(directoryPath)
for (const file of files) {
const filePath = path.resolve(directoryPath, file)
const stats = await fs.stat(filePath)
if (stats.isFile()) {
await fs.unlink(filePath)
} else if (isRecursive && stats.isDirectory()) {
await deleteAllFilesInDirectory(filePath, isRecursive)
}
}
}
type BasePath =
| typeof guildsIconPath
| typeof usersLogoPath
| typeof messagesFilePath
export const deleteFile = async (options: {
basePath: BasePath
/** @example '/uploads/users/logo.png' */
valueSavedInDatabase: string
}): Promise<void> => {
const { basePath, valueSavedInDatabase: value } = options
if (value !== `${basePath.name}/default.png`) {
const filePath = value.split('/')
const filename = filePath[filePath.length - 1]
await fs.unlink(path.join(basePath.filePath, filename))
}
}
export const deleteMessages = async (messages: Message[]): Promise<void> => {
for (const message of messages) {
if (message.type === 'file') {
await deleteFile({
basePath: messagesFilePath,
valueSavedInDatabase: message.value
})
}
await message.destroy()
}
}

View File

@ -1,12 +0,0 @@
import { ObjectAny } from '../../typings/utils'
export const deleteObjectAttributes = (
object: ObjectAny,
attributesToDelete: readonly string[]
): ObjectAny => {
const map = new Map(Object.entries(object))
for (const attribute of attributesToDelete) {
map.delete(attribute)
}
return Object.fromEntries(map)
}

View File

@ -0,0 +1,44 @@
import { Type } from '@sinclair/typebox'
import jwt from 'jsonwebtoken'
import ms from 'ms'
import prisma from '../database/prisma.js'
import { UserJWT } from '../../models/User.js'
import {
JWT_ACCESS_EXPIRES_IN,
JWT_ACCESS_SECRET,
JWT_REFRESH_SECRET
} from '../configurations/index.js'
export interface ResponseJWT {
accessToken: string
refreshToken?: string
expiresIn: number
type: 'Bearer'
}
export const jwtSchema = {
accessToken: Type.String(),
refreshToken: Type.String(),
expiresIn: Type.Integer({
description:
'expiresIn is how long, in milliseconds, until the returned accessToken expires'
}),
type: Type.Literal('Bearer')
}
export const expiresIn = ms(JWT_ACCESS_EXPIRES_IN)
export const generateAccessToken = (user: UserJWT): string => {
return jwt.sign(user, JWT_ACCESS_SECRET, {
expiresIn: JWT_ACCESS_EXPIRES_IN
})
}
export const generateRefreshToken = async (user: UserJWT): Promise<string> => {
const refreshToken = jwt.sign(user, JWT_REFRESH_SECRET)
await prisma.refreshToken.create({
data: { token: refreshToken, userId: user.id }
})
return refreshToken
}

View File

@ -1,11 +0,0 @@
/** returns the defaultValue provided, if parseInt(value) return NaN */
export function parseIntOrDefaultValue (
value: string | undefined,
defaultValue: number
): number {
const valueInteger = parseInt(value as string, 10)
if (value != null && !isNaN(valueInteger)) {
return valueInteger
}
return defaultValue
}

View File

@ -1,16 +0,0 @@
export const alphabet =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
export function randomInteger (min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
export function randomCharacter (): string {
return alphabet.charAt(randomInteger(0, alphabet.length - 1))
}
export function randomString (length: number): string {
return Array.from({ length })
.map(randomCharacter)
.join('')
}

View File

@ -1,58 +0,0 @@
import { UploadedFile } from 'express-fileupload'
import * as fsWithCallbacks from 'fs'
import path from 'path'
import { v4 as uuidv4 } from 'uuid'
import {
commonErrorsMessages,
supportedImageMimetype,
tempPath
} from '../configurations/constants'
import { deleteAllFilesInDirectory } from './deleteFiles'
import { BadRequestError } from '../errors/BadRequestError'
import { PayloadTooLargeError } from '../errors/PayloadTooLargeError'
const fs = fsWithCallbacks.promises
interface UploadImageOptions {
image: UploadedFile | UploadedFile[] | undefined
propertyName: string
oldImage: string
imagesPath: string
}
/**
* @description Handle upload of an image
* @returns the complete image name if success otherwise null
*/
export const uploadImage = async (
options: UploadImageOptions
): Promise<string | null> => {
const { image, propertyName, oldImage, imagesPath } = options
if (image != null && !Array.isArray(image)) {
if (image.truncated) {
await deleteAllFilesInDirectory(tempPath)
throw new PayloadTooLargeError(
commonErrorsMessages.image.tooLarge(propertyName)
)
}
if (!supportedImageMimetype.includes(image.mimetype)) {
await deleteAllFilesInDirectory(tempPath)
throw new BadRequestError(
commonErrorsMessages.image.validType(propertyName)
)
}
const splitedMimetype = image.mimetype.split('/')
const imageExtension = splitedMimetype[1]
const completeImageName = `${uuidv4()}.${imageExtension}`
const oldImagePath = oldImage.split('/')
const oldImageName = oldImagePath[oldImagePath.length - 1]
if (!oldImageName.startsWith('default')) {
await fs.unlink(path.join(imagesPath, oldImageName))
}
await image.mv(path.join(imagesPath, completeImageName))
await deleteAllFilesInDirectory(tempPath)
return completeImageName
}
return null
}