feat: migrate from express to fastify
This commit is contained in:
@ -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')
|
||||
})
|
@ -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`
|
||||
}
|
||||
}
|
35
src/tools/configurations/index.ts
Normal file
35
src/tools/configurations/index.ts
Normal 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
|
@ -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
|
||||
}
|
38
src/tools/configurations/swaggerOptions.ts
Normal file
38
src/tools/configurations/swaggerOptions.ts
Normal 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
|
||||
}
|
@ -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']
|
||||
})
|
@ -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)
|
||||
})
|
||||
})
|
@ -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
|
||||
}
|
@ -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}` })
|
||||
}
|
||||
}
|
@ -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 }
|
||||
}
|
36
src/tools/database/pagination.ts
Normal file
36
src/tools/database/pagination.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
12
src/tools/database/prisma.ts
Normal file
12
src/tools/database/prisma.ts
Normal 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
|
@ -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 }
|
@ -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]
|
||||
})
|
||||
|
@ -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 }]
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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' }]
|
||||
}
|
||||
}
|
@ -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' }]
|
||||
}
|
||||
}
|
@ -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 }]
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -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' }]
|
||||
}
|
||||
}
|
@ -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' }]
|
||||
}
|
||||
}
|
@ -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)
|
||||
})
|
@ -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')
|
||||
})
|
@ -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))
|
||||
})
|
||||
})
|
@ -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
|
||||
})
|
||||
})
|
@ -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()
|
||||
}
|
@ -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' }]
|
||||
})
|
||||
}
|
@ -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()
|
||||
}
|
13
src/tools/plugins/__test__/socket-io.test.ts
Normal file
13
src/tools/plugins/__test__/socket-io.test.ts
Normal 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()
|
||||
})
|
||||
})
|
64
src/tools/plugins/authenticateUser.ts
Normal file
64
src/tools/plugins/authenticateUser.ts
Normal 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' }
|
||||
)
|
19
src/tools/plugins/socket-io.ts
Normal file
19
src/tools/plugins/socket-io.ts
Normal 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' }
|
||||
)
|
@ -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' } }
|
||||
})
|
||||
})
|
||||
})
|
@ -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()
|
||||
})
|
||||
})
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
95
src/tools/utils/OAuthStrategy.ts
Normal file
95
src/tools/utils/OAuthStrategy.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
137
src/tools/utils/__test__/OAuthStrategy.test.ts
Normal file
137
src/tools/utils/__test__/OAuthStrategy.test.ts
Normal 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')
|
||||
})
|
||||
})
|
20
src/tools/utils/__test__/buildQueryURL.test.ts
Normal file
20
src/tools/utils/__test__/buildQueryURL.test.ts
Normal 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')
|
||||
})
|
@ -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')
|
||||
})
|
@ -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)
|
||||
})
|
||||
})
|
@ -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()
|
||||
})
|
@ -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)
|
||||
})
|
@ -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)
|
||||
})
|
||||
})
|
@ -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)
|
||||
})
|
||||
})
|
16
src/tools/utils/buildQueryURL.ts
Normal file
16
src/tools/utils/buildQueryURL.ts
Normal 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
|
||||
}
|
@ -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)
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
44
src/tools/utils/jwtToken.ts
Normal file
44
src/tools/utils/jwtToken.ts
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
@ -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('')
|
||||
}
|
@ -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
|
||||
}
|
@ -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()
|
||||
})
|
||||
})
|
@ -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')
|
||||
})
|
@ -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()
|
||||
})
|
||||
})
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
Reference in New Issue
Block a user