From 56c613b5cf5f5c50b6f53453964e24ceb4890fe5 Mon Sep 17 00:00:00 2001 From: Divlo Date: Tue, 26 Oct 2021 14:01:49 +0000 Subject: [PATCH] feat(services): add PUT /guilds/[guildId]/icon --- .gitignore | 1 - prisma/schema.prisma | 22 ++--- src/models/Channel.ts | 2 +- src/models/Guild.ts | 6 +- src/models/Message.ts | 7 +- src/models/User.ts | 26 +++--- src/services/guilds/[guildId]/icon/put.ts | 80 ++++++++++++++++++ src/services/guilds/index.ts | 2 + src/services/guilds/post.ts | 9 +- src/services/users/[userId]/get.ts | 2 +- src/services/users/current/logo/put.ts | 47 ++-------- src/services/users/current/put.ts | 10 ++- .../utils/__test__/OAuthStrategy.test.ts | 4 +- .../utils/__test__/buildQueryURL.test.ts | 2 +- .../utils/__test__/parseStringNullish.test.ts | 17 ++++ src/tools/utils/parseStringNullish.ts | 21 +++++ src/tools/utils/uploadImage.ts | 54 ++++++++++++ uploads/guilds/.gitkeep | 0 uploads/guilds/default.png | Bin 2434 -> 0 bytes uploads/users/.gitkeep | 0 uploads/users/default.png | Bin 9359 -> 0 bytes 21 files changed, 233 insertions(+), 79 deletions(-) create mode 100644 src/services/guilds/[guildId]/icon/put.ts create mode 100644 src/tools/utils/__test__/parseStringNullish.test.ts create mode 100644 src/tools/utils/parseStringNullish.ts create mode 100644 src/tools/utils/uploadImage.ts create mode 100644 uploads/guilds/.gitkeep delete mode 100644 uploads/guilds/default.png create mode 100644 uploads/users/.gitkeep delete mode 100644 uploads/users/default.png diff --git a/.gitignore b/.gitignore index af45a45..fe3192f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,3 @@ npm-debug.log* # misc .DS_Store -uploads diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e9408b7..203308a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,12 +9,12 @@ generator client { model User { id Int @id @default(autoincrement()) - name String @unique @db.VarChar(255) - email String? @unique @db.VarChar(255) + name String @unique @db.VarChar(30) + email String? @unique @db.VarChar(254) password String? @db.Text logo String? @db.Text - status String? @db.VarChar(255) - biography String? @db.Text + status String? @db.VarChar(50) + biography String? @db.VarChar(160) website String? @db.VarChar(255) isConfirmed Boolean @default(false) temporaryToken String? @@ -29,8 +29,8 @@ model User { model UserSetting { id Int @id @default(autoincrement()) - language String @default("en") @db.VarChar(255) - theme String @default("dark") @db.VarChar(255) + language String @default("en") @db.VarChar(10) + theme String @default("dark") @db.VarChar(10) isPublicEmail Boolean @default(false) isPublicGuilds Boolean @default(false) createdAt DateTime @default(now()) @@ -51,7 +51,7 @@ model RefreshToken { model OAuth { id Int @id @default(autoincrement()) providerId String @db.Text - provider String @db.VarChar(255) + provider String @db.VarChar(20) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt userId Int @unique @@ -72,7 +72,7 @@ model Member { model Guild { id Int @id @default(autoincrement()) - name String @db.VarChar(255) + name String @db.VarChar(30) icon String? @db.Text description String? @db.Text createdAt DateTime @default(now()) @@ -83,7 +83,7 @@ model Guild { model Channel { id Int @id @default(autoincrement()) - name String @db.VarChar(255) + name String @db.VarChar(20) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt guildId Int @unique @@ -94,8 +94,8 @@ model Channel { model Message { id Int @id @default(autoincrement()) value String @db.Text - type String @default("text") @db.VarChar(255) - mimetype String @default("text/plain") @db.VarChar(255) + type String @default("text") @db.VarChar(10) + mimetype String @default("text/plain") @db.VarChar(127) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt memberId Int @unique diff --git a/src/models/Channel.ts b/src/models/Channel.ts index ae00345..385e283 100644 --- a/src/models/Channel.ts +++ b/src/models/Channel.ts @@ -8,7 +8,7 @@ export const types = [Type.Literal('text')] export const channelSchema = { id, - name: Type.String({ maxLength: 255 }), + name: Type.String({ minLength: 1, maxLength: 20 }), createdAt: date.createdAt, updatedAt: date.updatedAt, guildId: id diff --git a/src/models/Guild.ts b/src/models/Guild.ts index 362863a..1f6caac 100644 --- a/src/models/Guild.ts +++ b/src/models/Guild.ts @@ -5,9 +5,9 @@ import { date, id } from './utils.js' export const guildSchema = { id, - name: Type.String({ minLength: 3, maxLength: 30 }), - icon: Type.String({ format: 'uri-reference' }), - description: Type.String({ maxLength: 160 }), + name: Type.String({ minLength: 1, maxLength: 30 }), + icon: Type.Union([Type.String({ format: 'uri-reference' }), Type.Null()]), + description: Type.Union([Type.String({ maxLength: 160 }), Type.Null()]), createdAt: date.createdAt, updatedAt: date.updatedAt } diff --git a/src/models/Message.ts b/src/models/Message.ts index 9197a52..fbe6d59 100644 --- a/src/models/Message.ts +++ b/src/models/Message.ts @@ -6,10 +6,13 @@ export const types = [Type.Literal('text'), Type.Literal('file')] export const messageSchema = { id, - value: Type.String(), + value: Type.String({ + minLength: 1, + maxLength: 20_000 + }), type: Type.Union(types, { default: 'text' }), mimetype: Type.String({ - maxLength: 255, + maxLength: 127, default: 'text/plain', format: 'mimetype' }), diff --git a/src/models/User.ts b/src/models/User.ts index 91effd8..05b322d 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -19,11 +19,11 @@ export interface UserRequest { export const userSchema = { id, 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(), logo: Type.String({ format: 'uri-reference' }), - status: Type.String({ maxLength: 255 }), - biography: Type.String(), + status: Type.String({ maxLength: 50 }), + biography: Type.String({ maxLength: 160 }), website: Type.String({ maxLength: 255, format: 'uri-reference' }), isConfirmed: Type.Boolean({ default: false }), temporaryToken: Type.String(), @@ -32,18 +32,22 @@ export const userSchema = { updatedAt: date.updatedAt } -export const userPublicSchema = { +export const userPublicWithoutSettingsSchema = { id, name: userSchema.name, - email: Type.Optional(userSchema.email), - logo: Type.Optional(userSchema.logo), - status: Type.Optional(userSchema.status), - biography: Type.Optional(userSchema.biography), - website: Type.Optional(userSchema.website), + email: Type.Union([userSchema.email, Type.Null()]), + logo: Type.Union([userSchema.logo, Type.Null()]), + status: Type.Union([userSchema.status, Type.Null()]), + biography: Type.Union([userSchema.biography, Type.Null()]), + website: Type.Union([userSchema.website, Type.Null()]), isConfirmed: userSchema.isConfirmed, createdAt: date.createdAt, - updatedAt: date.updatedAt, - settings: Type.Optional(Type.Object(userSettingsSchema)) + updatedAt: date.updatedAt +} + +export const userPublicSchema = { + ...userPublicWithoutSettingsSchema, + settings: Type.Object(userSettingsSchema) } export const userCurrentSchema = Type.Object({ diff --git a/src/services/guilds/[guildId]/icon/put.ts b/src/services/guilds/[guildId]/icon/put.ts new file mode 100644 index 0000000..480214a --- /dev/null +++ b/src/services/guilds/[guildId]/icon/put.ts @@ -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 + +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 + } + } + } + }) +} diff --git a/src/services/guilds/index.ts b/src/services/guilds/index.ts index 034d5e1..5fff260 100644 --- a/src/services/guilds/index.ts +++ b/src/services/guilds/index.ts @@ -1,7 +1,9 @@ import { FastifyPluginAsync } from 'fastify' import { postGuilds } from './post.js' +import { putGuildIconById } from './[guildId]/icon/put.js' export const guildsService: FastifyPluginAsync = async (fastify) => { await fastify.register(postGuilds) + await fastify.register(putGuildIconById) } diff --git a/src/services/guilds/post.ts b/src/services/guilds/post.ts index ef7a6fc..a3804ec 100644 --- a/src/services/guilds/post.ts +++ b/src/services/guilds/post.ts @@ -7,7 +7,8 @@ import authenticateUser from '../../tools/plugins/authenticateUser.js' import { guildSchema } from '../../models/Guild.js' import { channelSchema } from '../../models/Channel.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({ name: guildSchema.name, @@ -33,7 +34,7 @@ const postServiceSchema: FastifySchema = { members: Type.Array( Type.Object({ ...memberSchema, - user: Type.Object(userPublicSchema) + user: Type.Object(userPublicWithoutSettingsSchema) }) ) }) @@ -59,7 +60,9 @@ export const postGuilds: FastifyPluginAsync = async (fastify) => { throw fastify.httpErrors.forbidden() } 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({ data: { name: 'general', guildId: guild.id } }) diff --git a/src/services/users/[userId]/get.ts b/src/services/users/[userId]/get.ts index 18c3f05..0e6acb1 100644 --- a/src/services/users/[userId]/get.ts +++ b/src/services/users/[userId]/get.ts @@ -10,7 +10,7 @@ const parametersGetUserSchema = Type.Object({ userId: userPublicSchema.id }) -export type ParametersGetUser = Static +type ParametersGetUser = Static const getServiceSchema: FastifySchema = { description: 'GET the public user informations with its id', diff --git a/src/services/users/current/logo/put.ts b/src/services/users/current/logo/put.ts index 63a951e..f0369e5 100644 --- a/src/services/users/current/logo/put.ts +++ b/src/services/users/current/logo/put.ts @@ -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 { FastifyPluginAsync, FastifySchema } from 'fastify' import authenticateUser from '../../../../tools/plugins/authenticateUser.js' import { fastifyErrors } from '../../../../models/utils.js' -import fastifyMultipart, { Multipart } from 'fastify-multipart' -import { - maximumImageSize, - supportedImageMimetype, - ROOT_URL -} from '../../../../tools/configurations' +import fastifyMultipart from 'fastify-multipart' import prisma from '../../../../tools/database/prisma.js' +import { uploadImage } from '../../../../tools/utils/uploadImage.js' const putServiceSchema: FastifySchema = { description: 'Edit the current connected user logo', @@ -52,36 +44,11 @@ export const putCurrentUserLogo: FastifyPluginAsync = async (fastify) => { if (request.user == null) { throw fastify.httpErrors.forbidden() } - let files: Multipart[] = [] - try { - files = await request.saveRequestFiles({ - limits: { - 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) + const logo = await uploadImage({ + fastify, + request, + folderInUploadsFolder: 'users' + }) await prisma.user.update({ where: { id: request.user.current.id }, data: { logo } diff --git a/src/services/users/current/put.ts b/src/services/users/current/put.ts index 68d375c..af915d0 100644 --- a/src/services/users/current/put.ts +++ b/src/services/users/current/put.ts @@ -10,6 +10,7 @@ import { userCurrentSchema, userSchema } from '../../../models/User.js' import { sendEmail } from '../../../tools/email/sendEmail.js' import { HOST, PORT } from '../../../tools/configurations/index.js' import { Language, Theme } from '../../../models/UserSettings.js' +import { parseStringNullish } from '../../../tools/utils/parseStringNullish.js' const bodyPutServiceSchema = Type.Object({ name: Type.Optional(userSchema.name), @@ -117,9 +118,12 @@ export const putCurrentUser: FastifyPluginAsync = async (fastify) => { where: { id: request.user.current.id }, data: { name: name ?? request.user.current.name, - status: status ?? request.user.current.status, - biography: biography ?? request.user.current.biography, - website: website ?? request.user.current.website + status: parseStringNullish(request.user.current.status, status), + biography: parseStringNullish( + request.user.current.biography, + biography + ), + website: parseStringNullish(request.user.current.website, website) } }) reply.statusCode = 200 diff --git a/src/tools/utils/__test__/OAuthStrategy.test.ts b/src/tools/utils/__test__/OAuthStrategy.test.ts index 1b63df4..d2e8a60 100644 --- a/src/tools/utils/__test__/OAuthStrategy.test.ts +++ b/src/tools/utils/__test__/OAuthStrategy.test.ts @@ -5,7 +5,7 @@ import { OAuthStrategy } from '../OAuthStrategy.js' const oauthStrategy = new OAuthStrategy('discord') -describe('/utils/OAuthStrategy - callbackSignin', () => { +describe('/tools/utils/OAuthStrategy - callbackSignin', () => { it('should signup the user', async () => { const name = 'Martin' 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 () => { const name = userExample.name const id = '12345' diff --git a/src/tools/utils/__test__/buildQueryURL.test.ts b/src/tools/utils/__test__/buildQueryURL.test.ts index 79611e2..9f554b9 100644 --- a/src/tools/utils/__test__/buildQueryURL.test.ts +++ b/src/tools/utils/__test__/buildQueryURL.test.ts @@ -1,6 +1,6 @@ import { buildQueryURL } from '../buildQueryURL.js' -test('controllers/users/utils/buildQueryUrl', () => { +test('/tools/utils/buildQueryUrl', () => { expect( buildQueryURL('http://localhost:8080', { test: 'query' diff --git a/src/tools/utils/__test__/parseStringNullish.test.ts b/src/tools/utils/__test__/parseStringNullish.test.ts new file mode 100644 index 0000000..65801d4 --- /dev/null +++ b/src/tools/utils/__test__/parseStringNullish.test.ts @@ -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') + }) +}) diff --git a/src/tools/utils/parseStringNullish.ts b/src/tools/utils/parseStringNullish.ts new file mode 100644 index 0000000..b49e895 --- /dev/null +++ b/src/tools/utils/parseStringNullish.ts @@ -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 +} diff --git a/src/tools/utils/uploadImage.ts b/src/tools/utils/uploadImage.ts new file mode 100644 index 0000000..91a4cc0 --- /dev/null +++ b/src/tools/utils/uploadImage.ts @@ -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 => { + 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 +} diff --git a/uploads/guilds/.gitkeep b/uploads/guilds/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/uploads/guilds/default.png b/uploads/guilds/default.png deleted file mode 100644 index 826659b31bfcf06b0ed108fd20e07808e3590b91..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2434 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

V@)^dGu$bF^$RQJ!H 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