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

@ -1,33 +0,0 @@
import {
commonErrorsMessages,
maximumFileSize,
maximumImageSize,
supportedImageMimetype
} from '../constants'
test('/tools/config/constants', () => {
expect(commonErrorsMessages.image.tooLarge('logo')).toEqual(
`The logo must have a valid image, less than ${maximumImageSize}mb`
)
expect(commonErrorsMessages.image.validType('logo')).toEqual(
`The logo must have a valid type (${supportedImageMimetype.join(', ')})`
)
expect(commonErrorsMessages.tooLargeFile('file')).toEqual(
`The file should be less than ${maximumFileSize}mb`
)
expect(commonErrorsMessages.charactersLength('name', {})).toEqual(
'Name should not be empty'
)
expect(commonErrorsMessages.charactersLength('name', { min: 3 })).toEqual(
'Name should be at least 3 characters'
)
expect(commonErrorsMessages.charactersLength('name', { max: 3 })).toEqual(
'Name must be no longer than 3 characters'
)
expect(
commonErrorsMessages.charactersLength('name', { min: 3, max: 5 })
).toEqual('Name must be between 3 and 5 characters')
expect(() => {
commonErrorsMessages.charactersLength('name', { min: 12, max: 5 })
}).toThrowError('min should be less than max')
})

View File

@ -1,100 +0,0 @@
import { Options as FileUploadOptions } from 'express-fileupload'
import path from 'path'
import { capitalize } from '../utils/capitalize'
export const srcPath = path.join(__dirname, '..', '..')
export const rootPath = path.join(srcPath, '..')
export const tempPath = path.join(rootPath, 'temp')
export const uploadsPath = path.join(rootPath, 'uploads')
export const guildsIconPath = {
name: '/uploads/guilds',
filePath: path.join(uploadsPath, 'guilds')
} as const
export const usersLogoPath = {
name: '/uploads/users',
filePath: path.join(uploadsPath, 'users')
} as const
export const messagesFilePath = {
name: '/uploads/messages',
filePath: path.join(uploadsPath, 'messages')
} as const
export const emailPath = path.join(rootPath, 'email')
export const emailTemplatePath = path.join(emailPath, 'email-template.ejs')
export const emailLocalesPath = path.join(emailPath, 'locales')
export const authorizedRedirectDomains = [
...(process.env.NODE_ENV === 'development' ? ['http://localhost:3000/'] : []),
'https://thream.divlo.fr/'
] as const
export const supportedImageMimetype = [
'image/png',
'image/jpg',
'image/jpeg',
'image/gif'
]
/** in megabytes */
export const maximumImageSize = 10
export const maximumFileSize = 100
const basicFileUploadOptions: FileUploadOptions = {
useTempFiles: true,
tempFileDir: tempPath,
safeFileNames: true,
preserveExtension: Number(),
parseNested: true,
debug: process.env.NODE_ENV === 'development'
}
export const imageFileUploadOptions: FileUploadOptions = {
...basicFileUploadOptions,
limits: { fileSize: maximumImageSize * 1024 * 1024 }
}
export const fileUploadOptions: FileUploadOptions = {
...basicFileUploadOptions,
limits: { fileSize: maximumFileSize * 1024 * 1024 }
}
export const commonErrorsMessages = {
image: {
tooLarge: (name: string) =>
`The ${name} must have a valid image, less than ${maximumImageSize}mb`,
validType: (name: string) =>
`The ${name} must have a valid type (${supportedImageMimetype.join(
', '
)})`
},
tooLargeFile: (name: string) =>
`The ${name} should be less than ${maximumFileSize}mb`,
charactersLength: (
name: string,
{
min,
max
}: {
min?: number
max?: number
}
) => {
const capitalizedName = capitalize(name)
if (min != null && max != null) {
if (min >= max) {
throw new Error('min should be less than max')
}
return `${capitalizedName} must be between ${min} and ${max} characters`
}
if (max != null) {
return `${capitalizedName} must be no longer than ${max} characters`
}
if (min != null) {
return `${capitalizedName} should be at least ${min} characters`
}
return `${capitalizedName} should not be empty`
}
}

View File

@ -0,0 +1,35 @@
import { URL, pathToFileURL } from 'node:url'
import path from 'node:path'
import dotenv from 'dotenv'
dotenv.config()
export const PORT = parseInt(process.env.PORT ?? '8080', 10)
export const HOST = process.env.HOST ?? '0.0.0.0'
export const JWT_ACCESS_SECRET =
process.env.JWT_ACCESS_SECRET ?? 'accessTokenSecret'
export const JWT_REFRESH_SECRET =
process.env.JWT_REFRESH_SECRET ?? 'refreshTokenSecret'
export const JWT_ACCESS_EXPIRES_IN =
process.env.JWT_ACCESS_EXPIRES_IN ?? '15 minutes'
const importMetaURL = pathToFileURL(path.join(__dirname, 'app.js'))
export const SRC_URL = new URL('../../', importMetaURL)
export const ROOT_URL = new URL('../', SRC_URL)
export const EMAIL_URL = new URL('./email/', ROOT_URL)
export const EMAIL_TEMPLATE_URL = new URL('./email-template.ejs', EMAIL_URL)
export const EMAIL_LOCALES_URL = new URL('./locales/', EMAIL_URL)
export const UPLOADS_URL = new URL('./uploads/', ROOT_URL)
export const supportedImageMimetype = [
'image/png',
'image/jpg',
'image/jpeg',
'image/gif'
]
/** in megabytes */
export const maximumImageSize = 10
export const maximumFileSize = 100

View File

@ -1,29 +0,0 @@
import jwt from 'jsonwebtoken'
import ms from 'ms'
import RefreshToken from '../../models/RefreshToken'
import { UserJWT } from '../../models/User'
export interface ResponseJWT {
accessToken: string
refreshToken?: string
expiresIn: number
type: 'Bearer'
}
export const expiresInString = process.env.JWT_ACCESS_EXPIRES_IN
/** expiresIn is how long, in milliseconds, until the returned accessToken expires */
export const expiresIn = ms(expiresInString)
export const generateAccessToken = (user: UserJWT): string => {
return jwt.sign(user, process.env.JWT_ACCESS_SECRET, {
expiresIn: expiresInString
})
}
export const generateRefreshToken = async (user: UserJWT): Promise<string> => {
const refreshToken = jwt.sign(user, process.env.JWT_REFRESH_SECRET)
await RefreshToken.create({ token: refreshToken, userId: user.id })
return refreshToken
}

View File

@ -0,0 +1,38 @@
import dotenv from 'dotenv'
import readPackageJSON from 'read-pkg'
import { FastifyDynamicSwaggerOptions } from 'fastify-swagger'
dotenv.config()
const packageJSON = readPackageJSON.sync()
export const swaggerOptions: FastifyDynamicSwaggerOptions = {
routePrefix: '/documentation',
openapi: {
info: {
title: 'Thream',
description: packageJSON.description,
version: packageJSON.version
},
tags: [
{ name: 'users' },
{ name: 'guilds' },
{ name: 'channels' },
{ name: 'invitations' },
{ name: 'messages' },
{ name: 'members' }
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT'
}
}
}
},
exposeRoute: true,
staticCSP: true,
hideUntagged: true
}

View File

@ -1,25 +0,0 @@
import swaggerJsDoc from 'swagger-jsdoc'
// Extended: https://swagger.io/specification/#infoObject
export const swaggerSpecification = swaggerJsDoc({
swaggerDefinition: {
openapi: '3.0.0',
info: {
title: "Thream's API",
description:
'Stay close with your friends and communities, talk, chat, collaborate, share, and have fun.',
version: process.env.npm_package_version
},
basePath: '/',
host: process.env.API_BASE_URL,
tags: [
{ name: 'users' },
{ name: 'guilds' },
{ name: 'channels' },
{ name: 'invitations' },
{ name: 'messages' },
{ name: 'members' }
]
},
apis: ['./src/services/**/__docs__/**/*.yaml']
})

View File

@ -1,110 +0,0 @@
import { Sequelize } from 'sequelize-typescript'
import sqlite3 from 'sqlite3'
import { open, Database } from 'sqlite'
import { paginateModel } from '../paginateModel'
import { BadRequestError } from '../../errors/BadRequestError'
import PostTest from './utils/PostTest'
import { createPosts } from './utils/createPosts'
let sqlite: Database | undefined
let sequelize: Sequelize | undefined
describe('/tools/database/paginateModel', () => {
beforeAll(async () => {
sqlite = await open({
filename: ':memory:',
driver: sqlite3.Database
})
sequelize = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
logging: false,
models: [PostTest]
})
})
beforeEach(async () => {
await sequelize?.sync({ force: true })
})
afterAll(async () => {
await sqlite?.close()
await sequelize?.close()
})
it('fetch a certain amount of rows', async () => {
const numberOfPosts = 21
await createPosts(numberOfPosts)
const result = await paginateModel({ Model: PostTest })
expect(result.hasMore).toBeTruthy()
expect(result.rows.length).toEqual(20)
expect(result.totalItems).toEqual(numberOfPosts)
})
it('fetch less than 20 itemsPerPage', async () => {
const numberOfPosts = 15
await createPosts(numberOfPosts)
const result = await paginateModel({ Model: PostTest })
expect(result.hasMore).toBeFalsy()
expect(result.rows.length).toEqual(numberOfPosts)
expect(result.totalItems).toEqual(numberOfPosts)
})
it('fetch more than 20 itemsPerPage', async () => {
const numberOfPosts = 30
const itemsPerPage = '25'
await createPosts(numberOfPosts)
const result = await paginateModel({
Model: PostTest,
queryOptions: { itemsPerPage }
})
expect(result.hasMore).toBeTruthy()
expect(result.rows.length).toEqual(parseInt(itemsPerPage))
expect(result.totalItems).toEqual(numberOfPosts)
expect(result.itemsPerPage).toEqual(Number(itemsPerPage))
})
it('throws "BadRequestError" if "itemsPerPage" is more than 100', async () => {
const numberOfPosts = 10
const itemsPerPage = '101'
await createPosts(numberOfPosts)
await expect(
paginateModel({ Model: PostTest, queryOptions: { itemsPerPage } })
).rejects.toThrow(BadRequestError)
})
it('goes to the next page', async () => {
let page = 1
const numberOfPosts = 100
const itemsPerPage = '30'
const itemsPerPageInt = parseInt(itemsPerPage)
await createPosts(numberOfPosts)
const result1 = await paginateModel({
Model: PostTest,
queryOptions: { itemsPerPage, page: page.toString() },
findOptions: {
order: [['id', 'ASC']]
}
})
page += 1
expect(result1.hasMore).toBeTruthy()
expect(result1.rows[itemsPerPageInt - 1].title).toEqual(
`title-${itemsPerPage}`
)
expect(result1.totalItems).toEqual(numberOfPosts)
const result2 = await paginateModel({
Model: PostTest,
queryOptions: { itemsPerPage, page: page.toString() },
findOptions: {
order: [['id', 'ASC']]
}
})
expect(result2.page).toEqual(page)
expect(result2.hasMore).toBeTruthy()
expect(result2.rows[itemsPerPageInt - 1].title).toEqual(
`title-${itemsPerPageInt * 2}`
)
expect(result2.totalItems).toEqual(numberOfPosts)
})
})

View File

@ -1,10 +0,0 @@
import { Table, Model, Column, DataType } from 'sequelize-typescript'
@Table
export default class PostTest extends Model {
@Column({
type: DataType.STRING,
allowNull: false
})
title!: string
}

View File

@ -1,9 +0,0 @@
import PostTest from './PostTest'
export const createPosts = async (
numberOfPostsToCreate: number
): Promise<void> => {
for (let index = 1; index <= numberOfPostsToCreate; index++) {
await PostTest.create({ title: `title-${index}` })
}
}

View File

@ -1,46 +0,0 @@
import { FindOptions, Model } from 'sequelize/types'
import { BadRequestError } from '../errors/BadRequestError'
import { parseIntOrDefaultValue } from '../utils/parseIntOrDefaultValue'
interface PaginateModelOptions<M extends Model> {
findOptions?: FindOptions
queryOptions?: {
page?: string
itemsPerPage?: string
}
Model: typeof Model & (new () => M)
}
/** Allows to make a pagination system on a Sequelize model instance */
export const paginateModel = async <M extends Model<any, any>>(
options: PaginateModelOptions<M>
): Promise<{
totalItems: number
hasMore: boolean
page: number
itemsPerPage: number
rows: M[]
}> => {
const {
findOptions = {
order: [['createdAt', 'DESC']]
},
queryOptions,
Model
} = options
const page = parseIntOrDefaultValue(queryOptions?.page, 1)
const itemsPerPage = parseIntOrDefaultValue(queryOptions?.itemsPerPage, 20)
if (itemsPerPage > 100) {
throw new BadRequestError('"itemsPerPage" should be less than 100')
}
const offset = (page - 1) * itemsPerPage
const result = await Model.findAndCountAll<M>({
limit: itemsPerPage,
offset,
...findOptions
})
const { count, rows } = result
const hasMore = page * itemsPerPage < count
return { page, itemsPerPage, totalItems: count, hasMore, rows }
}

View File

@ -0,0 +1,36 @@
import { Prisma } from '@prisma/client'
import { Static, Type } from '@sinclair/typebox'
export const queryPaginationSchema = Type.Object({
/** Maximum number of items to return */
limit: Type.Integer({ default: 20, minimum: 1, maximum: 100 }),
/** The before and after are mutually exclusive, only one may be passed at a time. */
before: Type.Optional(
Type.Integer({ minimum: 1, description: 'Get items before this id' })
),
after: Type.Optional(
Type.Integer({ minimum: 1, description: 'Get items after this id' })
)
})
export type QueryPaginationSchemaType = Static<typeof queryPaginationSchema>
export const getPaginationOptions = (
query: QueryPaginationSchemaType
): Prisma.SelectSubset<unknown, unknown> => {
return {
take: query.before != null ? query.limit * -1 : query.limit,
skip: query.after != null || query.before != null ? 1 : undefined,
...(query.after != null && {
cursor: {
id: query.after
}
}),
...(query.before != null && {
cursor: {
id: query.before
}
})
}
}

View File

@ -0,0 +1,12 @@
import * as Prisma from '@prisma/client'
const { PrismaClient } = Prisma
const prisma = new PrismaClient({
log:
process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['error']
})
export default prisma

View File

@ -1,29 +0,0 @@
import path from 'path'
import { Sequelize } from 'sequelize-typescript'
const sequelize = new Sequelize({
host: process.env.DATABASE_HOST,
database: process.env.DATABASE_NAME,
dialect: process.env.DATABASE_DIALECT,
storage: process.env.DATABASE_DIALECT === 'sqlite' ? ':memory:' : undefined,
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
port: parseInt(process.env.DATABASE_PORT ?? '3306', 10),
models: [path.join(__dirname, '..', '..', 'models')],
retry: {
max: 10,
match: [
/ConnectionError/,
/SequelizeConnectionError/,
/SequelizeConnectionRefusedError/,
/SequelizeHostNotFoundError/,
/SequelizeHostNotReachableError/,
/SequelizeInvalidConnectionError/,
/SequelizeConnectionTimedOutError/,
/SequelizeConnectionAcquireTimeoutError/,
/Connection terminated unexpectedly/
]
}
})
export { sequelize }

View File

@ -1,12 +1,14 @@
import fs from 'node:fs'
import { URL, fileURLToPath } from 'node:url'
import ejs from 'ejs'
import * as fsWithCallbacks from 'fs'
import path from 'path'
import { Language, Theme } from '../../models/UserSetting'
import { emailLocalesPath, emailTemplatePath } from '../configurations/constants'
import { emailTransporter, EMAIL_INFO } from './emailTransporter'
const fs = fsWithCallbacks.promises
import { Language, Theme } from '../../models/UserSettings.js'
import {
EMAIL_LOCALES_URL,
EMAIL_TEMPLATE_URL
} from '../configurations/index.js'
import { emailTransporter, EMAIL_INFO } from './emailTransporter.js'
interface EmailTranslation {
subject: string
@ -53,11 +55,14 @@ const getEmailTranslation = async (
type: EmailType
): Promise<EmailTranslation> => {
const filename = `${type}.json`
let emailTranslationPath = path.join(emailLocalesPath, language, filename)
if (!fsWithCallbacks.existsSync(emailTranslationPath)) {
emailTranslationPath = path.join(emailLocalesPath, 'en', filename)
let emailTranslationURL = new URL(
`./${language}/${filename}`,
EMAIL_LOCALES_URL
)
if (!fs.existsSync(emailTranslationURL)) {
emailTranslationURL = new URL(`./en/${filename}`, EMAIL_LOCALES_URL)
}
const translationString = await fs.readFile(emailTranslationPath, {
const translationString = await fs.promises.readFile(emailTranslationURL, {
encoding: 'utf-8'
})
return JSON.parse(translationString)
@ -66,7 +71,7 @@ const getEmailTranslation = async (
export const sendEmail = async (options: SendEmailOptions): Promise<void> => {
const { email, type, url, theme = 'dark', language = 'en' } = options
const emailTranslation = await getEmailTranslation(language, type)
const emailHTML = await ejs.renderFile(emailTemplatePath, {
const emailHTML = await ejs.renderFile(fileURLToPath(EMAIL_TEMPLATE_URL), {
text: { ...emailTranslation.renderOptions, url },
theme: themeColors[theme]
})

View File

@ -1,15 +0,0 @@
import { CustomError } from './CustomError'
import { ErrorsMessageArray } from '../../typings/utils'
export class BadRequestError extends CustomError {
public statusCode = 400
constructor (public message: string) {
super(message)
Object.setPrototypeOf(this, BadRequestError.prototype)
}
serializeErrors (): ErrorsMessageArray {
return [{ message: this.message }]
}
}

View File

@ -1,12 +0,0 @@
import { ErrorsMessageArray } from '../../typings/utils'
export abstract class CustomError extends Error {
abstract statusCode: number
constructor (message: string) {
super(message)
Object.setPrototypeOf(this, CustomError.prototype)
}
abstract serializeErrors (): ErrorsMessageArray
}

View File

@ -1,15 +0,0 @@
import { CustomError } from './CustomError'
import { ErrorsMessageArray } from '../../typings/utils'
export class ForbiddenError extends CustomError {
public statusCode = 403
constructor () {
super('Forbidden')
Object.setPrototypeOf(this, ForbiddenError.prototype)
}
serializeErrors (): ErrorsMessageArray {
return [{ message: 'Forbidden' }]
}
}

View File

@ -1,15 +0,0 @@
import { CustomError } from './CustomError'
import { ErrorsMessageArray } from '../../typings/utils'
export class NotFoundError extends CustomError {
public statusCode = 404
constructor () {
super('Not Found')
Object.setPrototypeOf(this, NotFoundError.prototype)
}
serializeErrors (): ErrorsMessageArray {
return [{ message: 'Not Found' }]
}
}

View File

@ -1,23 +0,0 @@
import { CustomError } from './CustomError'
import { ErrorsMessageArray } from '../../typings/utils'
export class PayloadTooLargeError extends CustomError {
public statusCode = 413
constructor (public customMessage?: string) {
super('Payload Too Large')
Object.setPrototypeOf(this, PayloadTooLargeError.prototype)
}
serializeErrors (): ErrorsMessageArray {
if (this.customMessage == null) {
return [
{
message: 'Payload Too Large: The request entity is larger than limits defined by server'
}
]
}
return [{ message: this.customMessage }]
}
}

View File

@ -1,22 +0,0 @@
import { ValidationError } from 'express-validator'
import { CustomError } from './CustomError'
import { ErrorsMessageArray } from '../../typings/utils'
export class RequestValidationError extends CustomError {
public statusCode = 400
constructor (public errors: ValidationError[]) {
super('Invalid request')
Object.setPrototypeOf(this, RequestValidationError.prototype)
}
serializeErrors (): ErrorsMessageArray {
return this.errors.map(error => {
return {
message: error.msg,
field: error.param
}
})
}
}

View File

@ -1,15 +0,0 @@
import { CustomError } from './CustomError'
import { ErrorsMessageArray } from '../../typings/utils'
export class TooManyRequestsError extends CustomError {
public statusCode = 429
constructor () {
super('Too Many Requests')
Object.setPrototypeOf(this, TooManyRequestsError.prototype)
}
serializeErrors (): ErrorsMessageArray {
return [{ message: 'Too Many Requests' }]
}
}

View File

@ -1,16 +0,0 @@
import { CustomError } from './CustomError'
import { ErrorsMessageArray } from '../../typings/utils'
export class UnauthorizedError extends CustomError {
public statusCode = 401
constructor () {
super('Unauthorized')
Object.setPrototypeOf(this, UnauthorizedError.prototype)
}
serializeErrors (): ErrorsMessageArray {
return [{ message: 'Unauthorized: Token is missing or invalid Bearer' }]
}
}

View File

@ -1,17 +0,0 @@
import { PayloadTooLargeError } from '../PayloadTooLargeError'
test('/tools/errors/PayloadTooLargeError', () => {
const message = 'Payload Too Large'
const empty = new PayloadTooLargeError()
const custom = new PayloadTooLargeError(message)
const emptySerializeErrors = empty.serializeErrors()
const customSerializeErrors = custom.serializeErrors()
expect(empty.statusCode).toEqual(413)
expect(emptySerializeErrors.length).toEqual(1)
expect(emptySerializeErrors[0].message).toEqual(
'Payload Too Large: The request entity is larger than limits defined by server'
)
expect(custom.statusCode).toEqual(413)
expect(customSerializeErrors.length).toEqual(1)
expect(customSerializeErrors[0].message).toEqual(message)
})

View File

@ -1,9 +0,0 @@
import { TooManyRequestsError } from '../TooManyRequestsError'
test('/tools/errors/TooManyRequestsError', () => {
const tooManyRequestError = new TooManyRequestsError()
const errors = tooManyRequestError.serializeErrors()
expect(tooManyRequestError.statusCode).toEqual(429)
expect(errors.length).toEqual(1)
expect(errors[0].message).toEqual('Too Many Requests')
})

View File

@ -1,82 +0,0 @@
import {
getUserWithBearerToken,
errorsMessages,
authenticateUser
} from '../authenticateUser'
import { BadRequestError } from '../../errors/BadRequestError'
import { UnauthorizedError } from '../../errors/UnauthorizedError'
import { generateAccessToken } from '../../configurations/jwtToken'
import { ForbiddenError } from '../../errors/ForbiddenError'
import User from '../../../models/User'
const mockReq = (accessToken: string): any => {
const req: any = {}
req.user = null
req.get = jest.fn().mockReturnValue(`Bearer ${accessToken}`)
return req
}
describe('/tools/middlewares/authenticateUser', () => {
it('succeeds with valid token', async () => {
const user = await User.create({ name: 'user', isConfirmed: true })
const accessToken = generateAccessToken({
currentStrategy: 'local',
id: user.id
})
const result = await getUserWithBearerToken(`Bearer ${accessToken}`)
expect(result.current.name).toEqual(user.name)
expect(result.currentStrategy).toEqual('local')
expect(result.accessToken).toEqual(accessToken)
})
it('succeeds, get the Authorization header and set the correct value to the req.user', async () => {
const user = await User.create({ name: 'user', isConfirmed: true })
const accessToken = generateAccessToken({
currentStrategy: 'local',
id: user.id
})
const mockedReq = mockReq(accessToken)
const mockedNext = jest.fn()
await authenticateUser(mockedReq, {} as any, mockedNext)
expect(mockedReq.get).toHaveBeenCalledWith('Authorization')
expect(mockedReq.user).not.toBeNull()
expect(mockedReq.user.current.name).toEqual(user.name)
expect(mockedNext).toHaveBeenCalled()
})
it('fails with invalid bearer token', async () => {
await expect(getUserWithBearerToken()).rejects.toThrowError(
UnauthorizedError
)
})
it('fails with invalid bearer token format', async () => {
await expect(getUserWithBearerToken('Bearertoken')).rejects.toThrowError(
UnauthorizedError
)
})
it('fails with invalid token', async () => {
await expect(getUserWithBearerToken('Bearer token')).rejects.toThrowError(
ForbiddenError
)
})
it("fails if the user with that jwt token doesn't exist", async () => {
const accessToken = generateAccessToken({ currentStrategy: 'local', id: 2 })
await expect(
getUserWithBearerToken(`Bearer ${accessToken}`)
).rejects.toThrowError(ForbiddenError)
})
it('fails if the user is not confirmed and he is using local strategy', async () => {
const user = await User.create({ name: 'user', isConfirmed: false })
const accessToken = generateAccessToken({
currentStrategy: 'local',
id: user.id
})
await expect(
getUserWithBearerToken(`Bearer ${accessToken}`)
).rejects.toThrowError(new BadRequestError(errorsMessages.invalidAccount))
})
})

View File

@ -1,39 +0,0 @@
import { NotFoundError } from '../../errors/NotFoundError'
import { errorHandler } from '../errorHandler'
const mockRes = (): any => {
const res: any = {}
res.status = jest.fn().mockReturnValue(res)
res.json = jest.fn().mockReturnValue(res)
return res
}
describe('/tools/middlewares/errorHandler', () => {
it('should send 500 error if not custom error', () => {
const mockedRes = mockRes()
errorHandler(new Error('random error'), {} as any, mockedRes, () => {})
expect(mockedRes.json).toHaveBeenCalledWith({
errors: [{ message: 'Internal server error' }]
})
expect(mockedRes.status).toHaveBeenCalledWith(500)
})
it('should send 404 error if NotFoundError', () => {
const mockedRes = mockRes()
errorHandler(new NotFoundError(), {} as any, mockedRes, () => {})
expect(mockedRes.json).toHaveBeenCalledWith({
errors: [{ message: 'Not Found' }]
})
expect(mockedRes.status).toHaveBeenCalledWith(404)
})
it('should call console.error in developement', () => {
const consoleErrorOriginal = console.error
process.env.NODE_ENV = 'development'
console.error = jest.fn()
errorHandler(new NotFoundError(), {} as any, mockRes(), () => {})
expect(console.error).toHaveBeenCalled()
process.env.NODE_ENV = 'test'
console.error = consoleErrorOriginal
})
})

View File

@ -1,54 +0,0 @@
import { RequestHandler } from 'express'
import jwt from 'jsonwebtoken'
import User, { UserJWT, UserRequest } from '../../models/User'
import { BadRequestError } from '../errors/BadRequestError'
import { ForbiddenError } from '../errors/ForbiddenError'
import { UnauthorizedError } from '../errors/UnauthorizedError'
export const errorsMessages = {
invalidAccount:
'You should have a confirmed account, please check your email and follow the instructions to verify your account'
}
export const getUserWithBearerToken = async (
bearerToken?: string
): Promise<UserRequest> => {
if (bearerToken == null || typeof bearerToken !== 'string') {
throw new UnauthorizedError()
}
const tokenSplitted = bearerToken.split(' ')
if (tokenSplitted.length !== 2 || tokenSplitted[0] !== 'Bearer') {
throw new UnauthorizedError()
}
const token = tokenSplitted[1]
let payload: UserJWT
try {
payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET) as UserJWT
} catch {
throw new ForbiddenError()
}
const user = await User.findOne({ where: { id: payload.id } })
if (user == null) {
throw new ForbiddenError()
}
if (!user.isConfirmed && payload.currentStrategy === 'local') {
throw new BadRequestError(errorsMessages.invalidAccount)
}
return {
current: user,
currentStrategy: payload.currentStrategy,
accessToken: token
}
}
export const authenticateUser: RequestHandler = async (req, _res, next) => {
const authorizationHeader = req.get('Authorization')
req.user = await getUserWithBearerToken(authorizationHeader)
return next()
}

View File

@ -1,24 +0,0 @@
import { ErrorRequestHandler } from 'express'
import { CustomError } from '../errors/CustomError'
export const errorHandler: ErrorRequestHandler = (
error: Error,
_req,
res,
_next
) => {
if (process.env.NODE_ENV === 'development') {
console.error(error)
}
if (error instanceof CustomError) {
return res.status(error.statusCode).json({
errors: error.serializeErrors()
})
}
return res.status(500).json({
errors: [{ message: 'Internal server error' }]
})
}

View File

@ -1,12 +0,0 @@
import { RequestHandler } from 'express'
import { validationResult } from 'express-validator'
import { RequestValidationError } from '../errors/RequestValidationError'
export const validateRequest: RequestHandler = (req, _res, next) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
throw new RequestValidationError(errors.array())
}
return next()
}

View File

@ -0,0 +1,13 @@
import fastify from 'fastify'
import fastifySocketIo from '../socket-io.js'
describe('tools/plugins/socket-io', () => {
it('should close socket server on fastify close', async () => {
const PORT = 3030
const application = fastify()
await application.register(fastifySocketIo)
await application.listen(PORT)
expect(application.io).not.toBeNull()
await application.close()
})
})

View File

@ -0,0 +1,64 @@
import fastifyPlugin from 'fastify-plugin'
import httpErrors from 'http-errors'
import jwt from 'jsonwebtoken'
import prisma from '../database/prisma.js'
import { UserJWT, UserRequest } from '../../models/User.js'
import { JWT_ACCESS_SECRET } from '../configurations/index.js'
const { Unauthorized, Forbidden, BadRequest } = httpErrors
export const getUserWithBearerToken = async (
bearerToken?: string
): Promise<UserRequest> => {
if (bearerToken == null || typeof bearerToken !== 'string') {
throw new Unauthorized()
}
const tokenSplitted = bearerToken.split(' ')
if (tokenSplitted.length !== 2 || tokenSplitted[0] !== 'Bearer') {
throw new Unauthorized()
}
const token = tokenSplitted[1]
let payload: UserJWT
try {
payload = jwt.verify(token, JWT_ACCESS_SECRET) as UserJWT
} catch {
throw new Forbidden()
}
const user = await prisma.user.findUnique({ where: { id: payload.id } })
if (user == null) {
throw new Forbidden()
}
if (!user.isConfirmed && payload.currentStrategy === 'local') {
throw new BadRequest(
'You should have a confirmed account, please check your email and follow the instructions to verify your account'
)
}
return {
current: user,
currentStrategy: payload.currentStrategy,
accessToken: token
}
}
declare module 'fastify' {
export interface FastifyRequest {
user?: UserRequest
}
}
export default fastifyPlugin(
async (fastify) => {
fastify.decorateRequest('user', null)
fastify.addHook('onRequest', async (request) => {
const { authorization } = request.headers
request.user = await getUserWithBearerToken(authorization)
})
},
{ fastify: '3.x' }
)

View File

@ -0,0 +1,19 @@
import fastifyPlugin from 'fastify-plugin'
import { Server as SocketIoServer, ServerOptions } from 'socket.io'
declare module 'fastify' {
export interface FastifyInstance {
io: SocketIoServer
}
}
export default fastifyPlugin(
async (fastify, options: Partial<ServerOptions>) => {
const socket = new SocketIoServer(fastify.server, options)
fastify.decorate('io', socket)
fastify.addHook('onClose', async (fastify) => {
fastify.io.close()
})
},
{ fastify: '3.x' }
)

View File

@ -1,58 +0,0 @@
import express from 'express'
import { authorize } from '@thream/socketio-jwt'
import { Server as HttpServer } from 'http'
import enableDestroy from 'server-destroy'
import { io, Socket } from 'socket.io-client'
import { authenticateUserTest } from '../../../__test__/utils/authenticateUser'
import { emitToAuthorizedUsers } from '../emitEvents'
import { socket } from '../index'
describe('/tools/socket/emitEvents', () => {
let server: HttpServer | null = null
let socketClient: Socket | null = null
beforeEach(async (done) => {
jest.setTimeout(15_000)
const app = express()
server = app.listen(9000)
enableDestroy(server)
socket.init(server)
socket.io?.use(
authorize({
secret: process.env.JWT_ACCESS_SECRET
})
)
const userToken = await authenticateUserTest()
socketClient = io('http://localhost:9000', {
auth: {
token: `${userToken.type} ${userToken.accessToken}`
}
})
socketClient.on('connect', () => {
done()
})
})
afterEach(() => {
socket.io?.close()
try {
server?.destroy()
} catch {}
})
it('should emit the event to authenticated users - emitToAuthorizedUsers', async (done) => {
socketClient?.on('messages', (data: any) => {
expect(data.action).toEqual('create')
expect(data.item.id).toEqual(1)
expect(data.item.message).toEqual('awesome')
socketClient?.close()
done()
})
await emitToAuthorizedUsers({
event: 'messages',
isAuthorizedCallback: async () => true,
payload: { action: 'create', item: { id: 1, message: 'awesome' } }
})
})
})

View File

@ -1,17 +0,0 @@
import express from 'express'
import enableDestroy from 'server-destroy'
import { socket } from '../index'
describe('/tools/socket', () => {
it('should setup the socket.io server', () => {
expect(socket?.io).toBeNull()
const app = express()
const server = app.listen()
enableDestroy(server)
socket.init(server)
expect(socket?.io).toBeDefined()
server.destroy()
socket.io?.close()
})
})

View File

@ -1,54 +0,0 @@
import Member from '../../models/Member'
import { socket } from '.'
interface EmitEventOptions {
event: string
payload: {
action: 'create' | 'delete' | 'update'
item: object
}
}
interface EmitToMembersOptions extends EmitEventOptions {
guildId: number
}
interface EmitToAuthorizedUsersOptions extends EmitEventOptions {
/** tests whether the current connected userId is authorized to get the event, if the callback returns true, the server will emit the event to that user */
isAuthorizedCallback: (userId: number) => Promise<boolean>
}
/** emits socket.io event to every connected authorized users */
export const emitToAuthorizedUsers = async (
options: EmitToAuthorizedUsersOptions
): Promise<void> => {
const { event, payload, isAuthorizedCallback } = options
const clients = (await socket.io?.sockets.allSockets()) ?? new Set()
for (const clientId of clients) {
const client = socket.io?.sockets.sockets.get(clientId)
if (client != null) {
const userId = client.decodedToken.id
const isAuthorized = await isAuthorizedCallback(userId)
if (isAuthorized) {
client.emit(event, payload)
}
}
}
}
/** emits socket.io event to every connected members of the guild */
export const emitToMembers = async (
options: EmitToMembersOptions
): Promise<void> => {
const { event, payload, guildId } = options
await emitToAuthorizedUsers({
event,
payload,
isAuthorizedCallback: async (userId) => {
const member = await Member.count({
where: { userId, guildId }
})
return member > 0
}
})
}

View File

@ -1,22 +0,0 @@
import { Server as HttpServer } from 'http'
import { Server as HttpsServer } from 'https'
import { Server as SocketIoServer } from 'socket.io'
interface Socket {
io: null | SocketIoServer
init: (httpServer: HttpServer | HttpsServer) => void
}
export const socket: Socket = {
io: null,
init (httpServer) {
socket.io = new SocketIoServer(httpServer, {
cors: {
origin: '*',
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
preflightContinue: false,
optionsSuccessStatus: 204
}
})
}
}

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
}

View File

@ -1,54 +0,0 @@
import { Sequelize } from 'sequelize-typescript'
import sqlite3 from 'sqlite3'
import { open, Database } from 'sqlite'
import { alreadyUsedValidation } from '../alreadyUsedValidation'
import PostTest from '../../database/__test__/utils/PostTest'
import { createPosts } from '../../database/__test__/utils/createPosts'
let sqlite: Database | undefined
let sequelize: Sequelize | undefined
describe('/tools/validations/alreadyUsedValidation', () => {
beforeAll(async () => {
sqlite = await open({
filename: ':memory:',
driver: sqlite3.Database
})
sequelize = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
logging: false,
models: [PostTest]
})
})
beforeEach(async () => {
await sequelize?.sync({ force: true })
})
afterAll(async () => {
await sqlite?.close()
await sequelize?.close()
})
it("returns true if the post title doesn't exist yet", async () => {
const numberOfPosts = 3
await createPosts(numberOfPosts)
expect(
await alreadyUsedValidation(
PostTest,
'title',
`title-${numberOfPosts + 1}`
)
).toBeTruthy()
})
it('throws an error if the post title already exist', async () => {
const numberOfPosts = 3
await createPosts(numberOfPosts)
await expect(
alreadyUsedValidation(PostTest, 'title', 'title-1')
).rejects.toThrowError()
})
})

View File

@ -1,10 +0,0 @@
import { isValidRedirectURIValidation } from '../isValidRedirectURIValidation'
test('/tools/validations/isValidRedirectURIValidation', async () => {
expect(
await isValidRedirectURIValidation('https://thream.divlo.fr/')
).toBeTruthy()
await expect(async () => {
await isValidRedirectURIValidation('https://google.com/')
}).rejects.toThrowError('Untrusted URL redirection')
})

View File

@ -1,23 +0,0 @@
import { onlyPossibleValuesValidation } from '../onlyPossibleValuesValidation'
describe('/tools/validations/onlyPossibleValuesValidation', () => {
it('returns true if the value is one of the possible values', async () => {
expect(
await onlyPossibleValuesValidation(
['awesome', 'second possible value'],
'title',
'awesome'
)
).toBeTruthy()
})
it("throws an error if the value isn't in the possible values", async () => {
await expect(
onlyPossibleValuesValidation(
['awesome', 'second possible value'],
'title',
'random value'
)
).rejects.toThrowError()
})
})

View File

@ -1,19 +0,0 @@
import { SequelizeModelInstance } from '../../typings/utils'
import { capitalize } from '../utils/capitalize'
/** returns true if the field property doesn't exist yet on the Sequelize model instance otherwise throws an error */
export const alreadyUsedValidation = async (
Model: SequelizeModelInstance,
fieldName: string,
fieldValue: string
): Promise<boolean> => {
const foundInstance = await Model.findOne({
where: { [fieldName]: fieldValue }
})
if (foundInstance != null) {
return await Promise.reject(
new Error(`${capitalize(fieldName)} already used`)
)
}
return true
}

View File

@ -1,13 +0,0 @@
import { authorizedRedirectDomains } from '../configurations/constants'
export const isValidRedirectURIValidation = async (
redirectURI: string
): Promise<boolean> => {
const isValidRedirectURI = authorizedRedirectDomains.some(domain => {
return redirectURI.startsWith(domain)
})
if (!isValidRedirectURI) {
return await Promise.reject(new Error('Untrusted URL redirection'))
}
return true
}

View File

@ -1,19 +0,0 @@
import { capitalize } from '../utils/capitalize'
/** returns true if the field value is one of the possible values otherwise throws an error */
export const onlyPossibleValuesValidation = async (
possibleValues: readonly string[],
fieldName: string,
fieldValue: string
): Promise<boolean> => {
if (!possibleValues.includes(fieldValue)) {
return await Promise.reject(
new Error(
`${capitalize(
fieldName
)} should be one of these values : ${possibleValues.join(', ')}`
)
)
}
return true
}