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 826659b..0000000 Binary files a/uploads/guilds/default.png and /dev/null differ diff --git a/uploads/users/.gitkeep b/uploads/users/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/uploads/users/default.png b/uploads/users/default.png deleted file mode 100644 index d3d6fbe..0000000 Binary files a/uploads/users/default.png and /dev/null differ