add s3, add change user avatar, profile

This commit is contained in:
Sergey Krylov 2025-07-07 05:15:45 +03:00
parent 5bf182e627
commit c63592448a
14 changed files with 1622 additions and 3 deletions

View File

@ -23,6 +23,7 @@
}, },
"dependencies": { "dependencies": {
"@apollo/server": "^4.12.2", "@apollo/server": "^4.12.2",
"@aws-sdk/client-s3": "^3.842.0",
"@nestjs-modules/mailer": "^2.0.2", "@nestjs-modules/mailer": "^2.0.2",
"@nestjs/apollo": "^13.1.0", "@nestjs/apollo": "^13.1.0",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
@ -46,6 +47,7 @@
"express-session": "^1.18.1", "express-session": "^1.18.1",
"geoip-lite": "^1.4.10", "geoip-lite": "^1.4.10",
"graphql": "^16.11.0", "graphql": "^16.11.0",
"graphql-upload": "14",
"hi-base32": "^0.5.1", "hi-base32": "^0.5.1",
"i18n-iso-countries": "^7.14.0", "i18n-iso-countries": "^7.14.0",
"ioredis": "^5.6.1", "ioredis": "^5.6.1",
@ -55,7 +57,8 @@
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1",
"sharp": "^0.34.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",

View File

@ -2,11 +2,13 @@ import { getGraphQLConfig } from '@/src/core/config/graphql.config'
import { AccountModule } from '@/src/module/auth/account/account.module' import { AccountModule } from '@/src/module/auth/account/account.module'
import { DeactivateModule } from '@/src/module/auth/deactivate/deactivate.module' import { DeactivateModule } from '@/src/module/auth/deactivate/deactivate.module'
import { PasswordRecoveryModule } from '@/src/module/auth/password-recovery/password-recovery.module' import { PasswordRecoveryModule } from '@/src/module/auth/password-recovery/password-recovery.module'
import { ProfileModule } from '@/src/module/auth/profile/profile.module'
import { SessionModule } from '@/src/module/auth/session/session.module' import { SessionModule } from '@/src/module/auth/session/session.module'
import { TotpModule } from '@/src/module/auth/totp/totp.module' import { TotpModule } from '@/src/module/auth/totp/totp.module'
import { VerificationModule } from '@/src/module/auth/verification/verification.module' import { VerificationModule } from '@/src/module/auth/verification/verification.module'
import { CronModule } from '@/src/module/cron/cron.module' import { CronModule } from '@/src/module/cron/cron.module'
import { MailModule } from '@/src/module/libs/mail/mail.module' import { MailModule } from '@/src/module/libs/mail/mail.module'
import { StorageModule } from '@/src/module/libs/storage/storage.module'
import { IS_DEV } from '@/src/shared/util/is-dev.util' import { IS_DEV } from '@/src/shared/util/is-dev.util'
import { ApolloDriver } from '@nestjs/apollo' import { ApolloDriver } from '@nestjs/apollo'
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
@ -31,12 +33,14 @@ import { RedisModule } from './redis/redis.module'
RedisModule, RedisModule,
MailModule, MailModule,
CronModule, CronModule,
StorageModule,
AccountModule, AccountModule,
SessionModule, SessionModule,
VerificationModule, VerificationModule,
PasswordRecoveryModule, PasswordRecoveryModule,
TotpModule, TotpModule,
DeactivateModule, DeactivateModule,
ProfileModule,
], ],
}) })
export class CoreModule {} export class CoreModule {}

View File

@ -16,6 +16,12 @@ input ChangePasswordInput {
oldPassword: String! oldPassword: String!
} }
input ChangeProfileInfoInput {
bio: String!
displayName: String!
name: String!
}
input CreateUserInput { input CreateUserInput {
email: String! email: String!
name: String! name: String!
@ -60,6 +66,8 @@ input LoginInput {
type Mutation { type Mutation {
changeEmail(data: ChangeEmailInput!): UserModel! changeEmail(data: ChangeEmailInput!): UserModel!
changePassword(data: ChangePasswordInput!): UserModel! changePassword(data: ChangePasswordInput!): UserModel!
changeProfileAvatar(avatar: Upload!): Boolean!
changeProfileInfo(data: ChangeProfileInfoInput!): UserModel!
clearSessionCookie: Boolean! clearSessionCookie: Boolean!
createUser(data: CreateUserInput!): UserModel! createUser(data: CreateUserInput!): UserModel!
deactivateAccount(data: DeactivateAccountInput!): AuthModel! deactivateAccount(data: DeactivateAccountInput!): AuthModel!
@ -67,6 +75,7 @@ type Mutation {
enableTotp(data: EnableTotpInput!): Boolean! enableTotp(data: EnableTotpInput!): Boolean!
loginUser(data: LoginInput!): AuthModel! loginUser(data: LoginInput!): AuthModel!
logoutUser: Boolean! logoutUser: Boolean!
removeProfileAvatar: Boolean!
removeSession(id: String!): Boolean! removeSession(id: String!): Boolean!
resetPassword(data: ResetPasswordInput!): Boolean! resetPassword(data: ResetPasswordInput!): Boolean!
setNewPassword(data: NewPasswordInput!): Boolean! setNewPassword(data: NewPasswordInput!): Boolean!
@ -108,6 +117,9 @@ type TotpModel {
secret: String! secret: String!
} }
"""The `Upload` scalar type represents a file upload."""
scalar Upload
type UserModel { type UserModel {
avatar: String avatar: String
bio: String bio: String

View File

@ -8,6 +8,7 @@ import RedisStore from 'connect-redis'
import * as cookieParser from 'cookie-parser' import * as cookieParser from 'cookie-parser'
import { CoreModule } from '@/src/core/core.module' import { CoreModule } from '@/src/core/core.module'
import * as session from 'express-session' import * as session from 'express-session'
import * as graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(CoreModule) const app = await NestFactory.create(CoreModule)
@ -16,6 +17,7 @@ async function bootstrap() {
const redis = app.get(RedisService) const redis = app.get(RedisService)
app.use(cookieParser(config.getOrThrow<string>('COOKIE_SECRET'))) app.use(cookieParser(config.getOrThrow<string>('COOKIE_SECRET')))
app.use(config.getOrThrow<string>('GRAPHQL_PREFIX'), graphqlUploadExpress())
app.useGlobalPipes(new ValidationPipe({ app.useGlobalPipes(new ValidationPipe({
transform: true, transform: true,

View File

@ -0,0 +1,22 @@
import { Field, InputType } from '@nestjs/graphql'
import { IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator'
@InputType()
export class ChangeProfileInfoInput {
@Field(() => String)
@IsString()
@IsNotEmpty()
@Matches(/^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/)
name: string
@Field(() => String)
@IsString()
@IsNotEmpty()
displayName: string
@Field(() => String)
@IsString()
@IsNotEmpty()
@MaxLength(300)
bio: string
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { ProfileService } from './profile.service'
import { ProfileResolver } from './profile.resolver'
@Module({
providers: [ProfileResolver, ProfileService],
})
export class ProfileModule {}

View File

@ -0,0 +1,39 @@
import { UserModel } from '@/src/module/auth/account/models/user.model'
import { ChangeProfileInfoInput } from '@/src/module/auth/profile/inputs/change-profile-info.input'
import { Authorization } from '@/src/shared/decorators/auth.decorator'
import { Authorized } from '@/src/shared/decorators/authorized.decorator'
import { FileValidationPipe } from '@/src/shared/pipes/file-validation.pipe'
import { Args, Mutation, Resolver } from '@nestjs/graphql'
import { ProfileService } from './profile.service'
import { User } from '@/prisma/generated'
import * as Upload from 'graphql-upload/Upload.js'
import * as GraphQLUpload from 'graphql-upload/GraphQLUpload.js'
@Resolver('Profile')
export class ProfileResolver {
constructor(private readonly profileService: ProfileService) {}
@Authorization()
@Mutation(() => Boolean, { name: 'changeProfileAvatar' })
public async changeAvatar(
@Authorized() user: User,
@Args('avatar', { type: () => GraphQLUpload }, FileValidationPipe) file: Upload,
) {
return this.profileService.changeAvatar(user, file)
}
@Authorization()
@Mutation(() => Boolean, { name: 'removeProfileAvatar' })
public async removeAvatar(@Authorized() user: User) {
return this.profileService.removeAvatar(user)
}
@Authorization()
@Mutation(() => UserModel, { name: 'changeProfileInfo' })
public async changeInfo(
@Authorized() user: User,
@Args('data') input: ChangeProfileInfoInput,
) {
return this.profileService.changeInfo(user, input)
}
}

View File

@ -0,0 +1,75 @@
import { PrismaService } from '@/src/core/prisma/prisma.service'
import { ChangeProfileInfoInput } from '@/src/module/auth/profile/inputs/change-profile-info.input'
import { ConflictException, Injectable } from '@nestjs/common'
import sharp from 'sharp'
import { StorageService } from '../../libs/storage/storage.service'
import { User } from '@/prisma/generated'
import * as Upload from 'graphql-upload/Upload.js'
@Injectable()
export class ProfileService {
constructor(
private readonly prismaService: PrismaService,
private readonly storageService: StorageService,
) {
}
public async changeAvatar(user: User, file: Upload) {
if (user.avatar) {
await this.storageService.remove(user.avatar)
}
const chunks: Buffer[] = []
for await (const chunk of file.createReadStream()) {
chunks.push(chunk)
}
const buffer = Buffer.concat(chunks)
const fileName = `/channels/${user.name}.webp`
const processedBuffer = await sharp(buffer, { animated: file.filename.endsWith('.gif') })
.resize(512, 512)
.webp()
.toBuffer()
await this.storageService.upload(processedBuffer, fileName, 'image/webp')
await this.prismaService.user.update({
where: { id: user.id },
data: { avatar: fileName },
})
return true
}
public async removeAvatar(user: User) {
if (!user.avatar) {
return true
}
await this.storageService.remove(user.avatar)
await this.prismaService.user.update({
where: { id: user.id },
data: { avatar: null },
})
return true
}
public async changeInfo(user: User, input: ChangeProfileInfoInput) {
const { bio = user.bio, displayName = user.displayName, name = user.name } = input
const existingUser = await this.prismaService.user.findUnique({
where: { name },
})
if (existingUser && user.name !== name) {
throw new ConflictException('Пользователь с таким именем уже существует')
}
return this.prismaService.user.update({
where: { id: user.id },
data: { bio, displayName, name },
})
}
}

View File

@ -1,5 +1,6 @@
import { PrismaService } from '@/src/core/prisma/prisma.service' import { PrismaService } from '@/src/core/prisma/prisma.service'
import { MailService } from '@/src/module/libs/mail/mail.service' import { MailService } from '@/src/module/libs/mail/mail.service'
import { StorageService } from '@/src/module/libs/storage/storage.service'
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common'
import { Cron, CronExpression } from '@nestjs/schedule' import { Cron, CronExpression } from '@nestjs/schedule'
@ -8,6 +9,7 @@ export class CronService {
constructor( constructor(
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly mailService: MailService, private readonly mailService: MailService,
private readonly storageService: StorageService,
) { ) {
} }
@ -28,6 +30,9 @@ export class CronService {
for (const user of deactivatedAccounts) { for (const user of deactivatedAccounts) {
console.log('Deactivate user', user.name, user.email) console.log('Deactivate user', user.name, user.email)
await this.mailService.sendAccountDeletion(user.email) await this.mailService.sendAccountDeletion(user.email)
if (user.avatar) {
await this.storageService.remove(user.avatar)
}
} }
await this.prismaService.user.deleteMany({ await this.prismaService.user.deleteMany({

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common'
import { StorageService } from './storage.service'
@Global()
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View File

@ -0,0 +1,61 @@
import {
DeleteObjectCommand,
DeleteObjectCommandInput,
PutObjectCommand,
PutObjectCommandInput,
S3Client,
} from '@aws-sdk/client-s3'
import { BadRequestException, Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
@Injectable()
export class StorageService {
private readonly client: S3Client
private readonly bucket: string
constructor(
private readonly configService: ConfigService,
) {
this.client = new S3Client({
endpoint: this.configService.getOrThrow('S3_ENDPOINT'),
region: this.configService.getOrThrow('S3_REGION'),
credentials: {
accessKeyId: this.configService.getOrThrow('S3_ACCESS_KEY_ID'),
secretAccessKey: this.configService.getOrThrow('S3_SECRET_KEY_ID'),
},
})
this.bucket = this.configService.getOrThrow('S3_BUCKET_NAME')
}
public async upload(buffer: Buffer, key: string, mimetype: string) {
const command: PutObjectCommandInput = {
Bucket: this.bucket,
Key: String(key),
Body: buffer,
ContentType: mimetype,
}
try {
await this.client.send(new PutObjectCommand(command))
}
catch (e) {
throw new BadRequestException('Ошибка при загрузке файла')
}
}
public async remove(key: string) {
const command: DeleteObjectCommandInput = {
Bucket: this.bucket,
Key: String(key),
}
try {
await this.client.send(new DeleteObjectCommand(command))
}
catch (e) {
console.log('e', e);
throw new BadRequestException('Ошибка при удалении файла')
}
}
}

View File

@ -0,0 +1,29 @@
import { validateFileFormat, validateFileSize } from '@/src/shared/util/file.utils'
import { type ReadStream } from 'node:fs'
import { ArgumentMetadata, BadRequestException, Injectable, PipeTransform } from '@nestjs/common'
import * as FileUpload from 'graphql-upload/Upload.js'
@Injectable()
export class FileValidationPipe implements PipeTransform {
async transform(value: FileUpload, metadata: ArgumentMetadata): FileUpload {
if (!value.filename) {
throw new BadRequestException('Файл не загружен')
}
const { filename, createReadStream } = value
const fileStream = createReadStream() as ReadStream
const allowedFormats = ['jpg', 'jpeg', 'png', 'webp', 'gif']
const allowedFileSizeInByte = 5 * 1024 * 1024
const isFileFormatValidate = validateFileFormat(filename, allowedFormats)
if (!isFileFormatValidate) {
throw new BadRequestException('Неподдерживаемый формат файла')
}
const isFileSizeValid = await validateFileSize(fileStream, allowedFileSizeInByte)
if (!isFileSizeValid) {
throw new BadRequestException('Превышен максимальный размер файла')
}
return value
}
}

View File

@ -0,0 +1,25 @@
import { ReadStream } from 'fs'
export function validateFileFormat(
fileName: string,
allowedFormats: string[],
) {
const fileParts = fileName.split('.')
const extension = fileParts[fileParts.length - 1]
return allowedFormats.includes(extension)
}
export async function validateFileSize(
fileStream: ReadStream,
allowedFileSizeInBytes: number,
) {
return new Promise((res, rej) => {
let fileSizeInBytes = 0
fileStream
.on('data', (data: Buffer) => { fileSizeInBytes = data.byteLength })
.on('end', () => { res(fileSizeInBytes <= allowedFileSizeInBytes) })
.on('error', (error) => { rej(error) })
})
}

File diff suppressed because it is too large Load Diff