feat(services): add PUT /guilds/[guildId]/icon

This commit is contained in:
Divlo 2021-10-26 14:01:49 +00:00
parent 14eac3cadb
commit 56c613b5cf
No known key found for this signature in database
GPG Key ID: 6F24DA54DA3967CF
21 changed files with 233 additions and 79 deletions

1
.gitignore vendored
View File

@ -33,4 +33,3 @@ npm-debug.log*
# misc # misc
.DS_Store .DS_Store
uploads

View File

@ -9,12 +9,12 @@ generator client {
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique @db.VarChar(255) name String @unique @db.VarChar(30)
email String? @unique @db.VarChar(255) email String? @unique @db.VarChar(254)
password String? @db.Text password String? @db.Text
logo String? @db.Text logo String? @db.Text
status String? @db.VarChar(255) status String? @db.VarChar(50)
biography String? @db.Text biography String? @db.VarChar(160)
website String? @db.VarChar(255) website String? @db.VarChar(255)
isConfirmed Boolean @default(false) isConfirmed Boolean @default(false)
temporaryToken String? temporaryToken String?
@ -29,8 +29,8 @@ model User {
model UserSetting { model UserSetting {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
language String @default("en") @db.VarChar(255) language String @default("en") @db.VarChar(10)
theme String @default("dark") @db.VarChar(255) theme String @default("dark") @db.VarChar(10)
isPublicEmail Boolean @default(false) isPublicEmail Boolean @default(false)
isPublicGuilds Boolean @default(false) isPublicGuilds Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -51,7 +51,7 @@ model RefreshToken {
model OAuth { model OAuth {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
providerId String @db.Text providerId String @db.Text
provider String @db.VarChar(255) provider String @db.VarChar(20)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
userId Int @unique userId Int @unique
@ -72,7 +72,7 @@ model Member {
model Guild { model Guild {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @db.VarChar(255) name String @db.VarChar(30)
icon String? @db.Text icon String? @db.Text
description String? @db.Text description String? @db.Text
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -83,7 +83,7 @@ model Guild {
model Channel { model Channel {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @db.VarChar(255) name String @db.VarChar(20)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
guildId Int @unique guildId Int @unique
@ -94,8 +94,8 @@ model Channel {
model Message { model Message {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
value String @db.Text value String @db.Text
type String @default("text") @db.VarChar(255) type String @default("text") @db.VarChar(10)
mimetype String @default("text/plain") @db.VarChar(255) mimetype String @default("text/plain") @db.VarChar(127)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
memberId Int @unique memberId Int @unique

View File

@ -8,7 +8,7 @@ export const types = [Type.Literal('text')]
export const channelSchema = { export const channelSchema = {
id, id,
name: Type.String({ maxLength: 255 }), name: Type.String({ minLength: 1, maxLength: 20 }),
createdAt: date.createdAt, createdAt: date.createdAt,
updatedAt: date.updatedAt, updatedAt: date.updatedAt,
guildId: id guildId: id

View File

@ -5,9 +5,9 @@ import { date, id } from './utils.js'
export const guildSchema = { export const guildSchema = {
id, id,
name: Type.String({ minLength: 3, maxLength: 30 }), name: Type.String({ minLength: 1, maxLength: 30 }),
icon: Type.String({ format: 'uri-reference' }), icon: Type.Union([Type.String({ format: 'uri-reference' }), Type.Null()]),
description: Type.String({ maxLength: 160 }), description: Type.Union([Type.String({ maxLength: 160 }), Type.Null()]),
createdAt: date.createdAt, createdAt: date.createdAt,
updatedAt: date.updatedAt updatedAt: date.updatedAt
} }

View File

@ -6,10 +6,13 @@ export const types = [Type.Literal('text'), Type.Literal('file')]
export const messageSchema = { export const messageSchema = {
id, id,
value: Type.String(), value: Type.String({
minLength: 1,
maxLength: 20_000
}),
type: Type.Union(types, { default: 'text' }), type: Type.Union(types, { default: 'text' }),
mimetype: Type.String({ mimetype: Type.String({
maxLength: 255, maxLength: 127,
default: 'text/plain', default: 'text/plain',
format: 'mimetype' format: 'mimetype'
}), }),

View File

@ -19,11 +19,11 @@ export interface UserRequest {
export const userSchema = { export const userSchema = {
id, id,
name: Type.String({ minLength: 1, maxLength: 30 }), name: Type.String({ minLength: 1, maxLength: 30 }),
email: Type.String({ minLength: 1, maxLength: 255, format: 'email' }), email: Type.String({ minLength: 1, maxLength: 254, format: 'email' }),
password: Type.String(), password: Type.String(),
logo: Type.String({ format: 'uri-reference' }), logo: Type.String({ format: 'uri-reference' }),
status: Type.String({ maxLength: 255 }), status: Type.String({ maxLength: 50 }),
biography: Type.String(), biography: Type.String({ maxLength: 160 }),
website: Type.String({ maxLength: 255, format: 'uri-reference' }), website: Type.String({ maxLength: 255, format: 'uri-reference' }),
isConfirmed: Type.Boolean({ default: false }), isConfirmed: Type.Boolean({ default: false }),
temporaryToken: Type.String(), temporaryToken: Type.String(),
@ -32,18 +32,22 @@ export const userSchema = {
updatedAt: date.updatedAt updatedAt: date.updatedAt
} }
export const userPublicSchema = { export const userPublicWithoutSettingsSchema = {
id, id,
name: userSchema.name, name: userSchema.name,
email: Type.Optional(userSchema.email), email: Type.Union([userSchema.email, Type.Null()]),
logo: Type.Optional(userSchema.logo), logo: Type.Union([userSchema.logo, Type.Null()]),
status: Type.Optional(userSchema.status), status: Type.Union([userSchema.status, Type.Null()]),
biography: Type.Optional(userSchema.biography), biography: Type.Union([userSchema.biography, Type.Null()]),
website: Type.Optional(userSchema.website), website: Type.Union([userSchema.website, Type.Null()]),
isConfirmed: userSchema.isConfirmed, isConfirmed: userSchema.isConfirmed,
createdAt: date.createdAt, createdAt: date.createdAt,
updatedAt: date.updatedAt, updatedAt: date.updatedAt
settings: Type.Optional(Type.Object(userSettingsSchema)) }
export const userPublicSchema = {
...userPublicWithoutSettingsSchema,
settings: Type.Object(userSettingsSchema)
} }
export const userCurrentSchema = Type.Object({ export const userCurrentSchema = Type.Object({

View File

@ -0,0 +1,80 @@
import { Static, Type } from '@sinclair/typebox'
import { FastifyPluginAsync, FastifySchema } from 'fastify'
import authenticateUser from '../../../../tools/plugins/authenticateUser.js'
import { fastifyErrors } from '../../../../models/utils.js'
import fastifyMultipart from 'fastify-multipart'
import prisma from '../../../../tools/database/prisma.js'
import { uploadImage } from '../../../../tools/utils/uploadImage.js'
import { guildSchema } from '../../../../models/Guild.js'
const parametersSchema = Type.Object({
guildId: guildSchema.id
})
type Parameters = Static<typeof parametersSchema>
const putServiceSchema: FastifySchema = {
description: 'Edit the icon of the guild with its id',
tags: ['guilds'] as string[],
consumes: ['multipart/form-data'] as string[],
produces: ['application/json'] as string[],
security: [
{
bearerAuth: []
}
] as Array<{ [key: string]: [] }>,
params: parametersSchema,
response: {
200: Type.Object({
guild: Type.Object({
icon: Type.String()
})
}),
400: fastifyErrors[400],
401: fastifyErrors[401],
403: fastifyErrors[403],
404: fastifyErrors[404],
431: fastifyErrors[431],
500: fastifyErrors[500]
}
} as const
export const putGuildIconById: FastifyPluginAsync = async (fastify) => {
await fastify.register(authenticateUser)
await fastify.register(fastifyMultipart)
fastify.route<{
Params: Parameters
}>({
method: 'PUT',
url: '/guilds/:guildId/icon',
schema: putServiceSchema,
handler: async (request, reply) => {
if (request.user == null) {
throw fastify.httpErrors.forbidden()
}
const { guildId } = request.params
const guild = await prisma.guild.findUnique({ where: { id: guildId } })
if (guild == null) {
throw fastify.httpErrors.notFound()
}
const icon = await uploadImage({
fastify,
request,
folderInUploadsFolder: 'guilds'
})
await prisma.guild.update({
where: { id: guildId },
data: { icon }
})
reply.statusCode = 200
return {
guild: {
icon
}
}
}
})
}

View File

@ -1,7 +1,9 @@
import { FastifyPluginAsync } from 'fastify' import { FastifyPluginAsync } from 'fastify'
import { postGuilds } from './post.js' import { postGuilds } from './post.js'
import { putGuildIconById } from './[guildId]/icon/put.js'
export const guildsService: FastifyPluginAsync = async (fastify) => { export const guildsService: FastifyPluginAsync = async (fastify) => {
await fastify.register(postGuilds) await fastify.register(postGuilds)
await fastify.register(putGuildIconById)
} }

View File

@ -7,7 +7,8 @@ import authenticateUser from '../../tools/plugins/authenticateUser.js'
import { guildSchema } from '../../models/Guild.js' import { guildSchema } from '../../models/Guild.js'
import { channelSchema } from '../../models/Channel.js' import { channelSchema } from '../../models/Channel.js'
import { memberSchema } from '../../models/Member.js' import { memberSchema } from '../../models/Member.js'
import { userPublicSchema } from '../../models/User.js' import { userPublicWithoutSettingsSchema } from '../../models/User.js'
import { parseStringNullish } from '../../tools/utils/parseStringNullish.js'
const bodyPostServiceSchema = Type.Object({ const bodyPostServiceSchema = Type.Object({
name: guildSchema.name, name: guildSchema.name,
@ -33,7 +34,7 @@ const postServiceSchema: FastifySchema = {
members: Type.Array( members: Type.Array(
Type.Object({ Type.Object({
...memberSchema, ...memberSchema,
user: Type.Object(userPublicSchema) user: Type.Object(userPublicWithoutSettingsSchema)
}) })
) )
}) })
@ -59,7 +60,9 @@ export const postGuilds: FastifyPluginAsync = async (fastify) => {
throw fastify.httpErrors.forbidden() throw fastify.httpErrors.forbidden()
} }
const { name, description } = request.body const { name, description } = request.body
const guild = await prisma.guild.create({ data: { name, description } }) const guild = await prisma.guild.create({
data: { name, description: parseStringNullish(description) }
})
const channel = await prisma.channel.create({ const channel = await prisma.channel.create({
data: { name: 'general', guildId: guild.id } data: { name: 'general', guildId: guild.id }
}) })

View File

@ -10,7 +10,7 @@ const parametersGetUserSchema = Type.Object({
userId: userPublicSchema.id userId: userPublicSchema.id
}) })
export type ParametersGetUser = Static<typeof parametersGetUserSchema> type ParametersGetUser = Static<typeof parametersGetUserSchema>
const getServiceSchema: FastifySchema = { const getServiceSchema: FastifySchema = {
description: 'GET the public user informations with its id', description: 'GET the public user informations with its id',

View File

@ -1,19 +1,11 @@
import fs from 'node:fs'
import { URL } from 'node:url'
import { randomUUID } from 'node:crypto'
import { Type } from '@sinclair/typebox' import { Type } from '@sinclair/typebox'
import { FastifyPluginAsync, FastifySchema } from 'fastify' import { FastifyPluginAsync, FastifySchema } from 'fastify'
import authenticateUser from '../../../../tools/plugins/authenticateUser.js' import authenticateUser from '../../../../tools/plugins/authenticateUser.js'
import { fastifyErrors } from '../../../../models/utils.js' import { fastifyErrors } from '../../../../models/utils.js'
import fastifyMultipart, { Multipart } from 'fastify-multipart' import fastifyMultipart from 'fastify-multipart'
import {
maximumImageSize,
supportedImageMimetype,
ROOT_URL
} from '../../../../tools/configurations'
import prisma from '../../../../tools/database/prisma.js' import prisma from '../../../../tools/database/prisma.js'
import { uploadImage } from '../../../../tools/utils/uploadImage.js'
const putServiceSchema: FastifySchema = { const putServiceSchema: FastifySchema = {
description: 'Edit the current connected user logo', description: 'Edit the current connected user logo',
@ -52,36 +44,11 @@ export const putCurrentUserLogo: FastifyPluginAsync = async (fastify) => {
if (request.user == null) { if (request.user == null) {
throw fastify.httpErrors.forbidden() throw fastify.httpErrors.forbidden()
} }
let files: Multipart[] = [] const logo = await uploadImage({
try { fastify,
files = await request.saveRequestFiles({ request,
limits: { folderInUploadsFolder: 'users'
files: 1, })
fileSize: maximumImageSize * 1024 * 1024
}
})
} catch (error) {
throw fastify.httpErrors.requestHeaderFieldsTooLarge(
`body.logo should be less than ${maximumImageSize}mb.`
)
}
if (files.length !== 1) {
throw fastify.httpErrors.badRequest('You must upload at most one file.')
}
const image = files[0]
if (!supportedImageMimetype.includes(image.mimetype)) {
throw fastify.httpErrors.badRequest(
`The file must have a valid type (${supportedImageMimetype.join(
', '
)}).`
)
}
const splitedMimetype = image.mimetype.split('/')
const imageExtension = splitedMimetype[1]
const logoPath = `uploads/users/${randomUUID()}.${imageExtension}`
const logoURL = new URL(logoPath, ROOT_URL)
const logo = `/${logoPath}`
await fs.promises.copyFile(image.filepath, logoURL)
await prisma.user.update({ await prisma.user.update({
where: { id: request.user.current.id }, where: { id: request.user.current.id },
data: { logo } data: { logo }

View File

@ -10,6 +10,7 @@ import { userCurrentSchema, userSchema } from '../../../models/User.js'
import { sendEmail } from '../../../tools/email/sendEmail.js' import { sendEmail } from '../../../tools/email/sendEmail.js'
import { HOST, PORT } from '../../../tools/configurations/index.js' import { HOST, PORT } from '../../../tools/configurations/index.js'
import { Language, Theme } from '../../../models/UserSettings.js' import { Language, Theme } from '../../../models/UserSettings.js'
import { parseStringNullish } from '../../../tools/utils/parseStringNullish.js'
const bodyPutServiceSchema = Type.Object({ const bodyPutServiceSchema = Type.Object({
name: Type.Optional(userSchema.name), name: Type.Optional(userSchema.name),
@ -117,9 +118,12 @@ export const putCurrentUser: FastifyPluginAsync = async (fastify) => {
where: { id: request.user.current.id }, where: { id: request.user.current.id },
data: { data: {
name: name ?? request.user.current.name, name: name ?? request.user.current.name,
status: status ?? request.user.current.status, status: parseStringNullish(request.user.current.status, status),
biography: biography ?? request.user.current.biography, biography: parseStringNullish(
website: website ?? request.user.current.website request.user.current.biography,
biography
),
website: parseStringNullish(request.user.current.website, website)
} }
}) })
reply.statusCode = 200 reply.statusCode = 200

View File

@ -5,7 +5,7 @@ import { OAuthStrategy } from '../OAuthStrategy.js'
const oauthStrategy = new OAuthStrategy('discord') const oauthStrategy = new OAuthStrategy('discord')
describe('/utils/OAuthStrategy - callbackSignin', () => { describe('/tools/utils/OAuthStrategy - callbackSignin', () => {
it('should signup the user', async () => { it('should signup the user', async () => {
const name = 'Martin' const name = 'Martin'
const id = '12345' const id = '12345'
@ -52,7 +52,7 @@ describe('/utils/OAuthStrategy - callbackSignin', () => {
}) })
}) })
describe('/utils/OAuthStrategy - callbackAddStrategy', () => { describe('/tools/utils/OAuthStrategy - callbackAddStrategy', () => {
it('should add the strategy to the user', async () => { it('should add the strategy to the user', async () => {
const name = userExample.name const name = userExample.name
const id = '12345' const id = '12345'

View File

@ -1,6 +1,6 @@
import { buildQueryURL } from '../buildQueryURL.js' import { buildQueryURL } from '../buildQueryURL.js'
test('controllers/users/utils/buildQueryUrl', () => { test('/tools/utils/buildQueryUrl', () => {
expect( expect(
buildQueryURL('http://localhost:8080', { buildQueryURL('http://localhost:8080', {
test: 'query' test: 'query'

View File

@ -0,0 +1,17 @@
import { parseStringNullish } from '../parseStringNullish'
const defaultString = 'defaultString'
describe('/tools/utils/parseStringNullish', () => {
it('returns `null` if `string.length === 0`', () => {
expect(parseStringNullish(defaultString, '')).toEqual(null)
})
it('returns `defaultString` if `string == null`', () => {
expect(parseStringNullish(defaultString)).toEqual(defaultString)
})
it('returns `string` if `string.length > 0`', () => {
expect(parseStringNullish(defaultString, 'string')).toEqual('string')
})
})

View File

@ -0,0 +1,21 @@
/**
* Parse a nullish string:
* - if `string.length === 0`, it returns `null`
* - if `string == null`, it returns `defaultString`
* - if `string.length > 0`, it returns `string`
* @param defaultString
* @param string
* @returns
*/
export const parseStringNullish = (
defaultString: string | null,
string?: string
): string | null => {
if (string != null) {
if (string.length > 0) {
return string
}
return null
}
return defaultString
}

View File

@ -0,0 +1,54 @@
import fs from 'node:fs'
import { URL } from 'node:url'
import { randomUUID } from 'node:crypto'
import { FastifyInstance, FastifyRequest } from 'fastify'
import { Multipart } from 'fastify-multipart'
import {
maximumImageSize,
supportedImageMimetype,
ROOT_URL
} from '../configurations'
export interface UploadImageOptions {
folderInUploadsFolder: 'guilds' | 'messages' | 'users'
request: FastifyRequest
fastify: FastifyInstance
}
export const uploadImage = async (
options: UploadImageOptions
): Promise<string> => {
const { fastify, request, folderInUploadsFolder } = options
let files: Multipart[] = []
try {
files = await request.saveRequestFiles({
limits: {
files: 1,
fileSize: maximumImageSize * 1024 * 1024
}
})
} catch (error) {
throw fastify.httpErrors.requestHeaderFieldsTooLarge(
`Image should be less than ${maximumImageSize}mb.`
)
}
if (files.length !== 1) {
throw fastify.httpErrors.badRequest('You must upload at most one file.')
}
const image = files[0]
if (!supportedImageMimetype.includes(image.mimetype)) {
throw fastify.httpErrors.badRequest(
`The file must have a valid type (${supportedImageMimetype.join(', ')}).`
)
}
const splitedMimetype = image.mimetype.split('/')
const imageExtension = splitedMimetype[1]
const imagePath = `uploads/${folderInUploadsFolder}/${randomUUID()}.${imageExtension}`
const imageURL = new URL(imagePath, ROOT_URL)
const imagePathToStoreInDatabase = `/${imagePath}`
await fs.promises.copyFile(image.filepath, imageURL)
return imagePathToStoreInDatabase
}

0
uploads/guilds/.gitkeep Normal file
View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

0
uploads/users/.gitkeep Normal file
View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB