add s3, add change user avatar, profile
This commit is contained in:
parent
5bf182e627
commit
c63592448a
@ -23,6 +23,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.12.2",
|
||||
"@aws-sdk/client-s3": "^3.842.0",
|
||||
"@nestjs-modules/mailer": "^2.0.2",
|
||||
"@nestjs/apollo": "^13.1.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
@ -46,6 +47,7 @@
|
||||
"express-session": "^1.18.1",
|
||||
"geoip-lite": "^1.4.10",
|
||||
"graphql": "^16.11.0",
|
||||
"graphql-upload": "14",
|
||||
"hi-base32": "^0.5.1",
|
||||
"i18n-iso-countries": "^7.14.0",
|
||||
"ioredis": "^5.6.1",
|
||||
@ -55,7 +57,8 @@
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.34.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
|
||||
@ -2,11 +2,13 @@ import { getGraphQLConfig } from '@/src/core/config/graphql.config'
|
||||
import { AccountModule } from '@/src/module/auth/account/account.module'
|
||||
import { DeactivateModule } from '@/src/module/auth/deactivate/deactivate.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 { TotpModule } from '@/src/module/auth/totp/totp.module'
|
||||
import { VerificationModule } from '@/src/module/auth/verification/verification.module'
|
||||
import { CronModule } from '@/src/module/cron/cron.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 { ApolloDriver } from '@nestjs/apollo'
|
||||
import { Module } from '@nestjs/common'
|
||||
@ -31,12 +33,14 @@ import { RedisModule } from './redis/redis.module'
|
||||
RedisModule,
|
||||
MailModule,
|
||||
CronModule,
|
||||
StorageModule,
|
||||
AccountModule,
|
||||
SessionModule,
|
||||
VerificationModule,
|
||||
PasswordRecoveryModule,
|
||||
TotpModule,
|
||||
DeactivateModule,
|
||||
ProfileModule,
|
||||
],
|
||||
})
|
||||
export class CoreModule {}
|
||||
|
||||
@ -16,6 +16,12 @@ input ChangePasswordInput {
|
||||
oldPassword: String!
|
||||
}
|
||||
|
||||
input ChangeProfileInfoInput {
|
||||
bio: String!
|
||||
displayName: String!
|
||||
name: String!
|
||||
}
|
||||
|
||||
input CreateUserInput {
|
||||
email: String!
|
||||
name: String!
|
||||
@ -60,6 +66,8 @@ input LoginInput {
|
||||
type Mutation {
|
||||
changeEmail(data: ChangeEmailInput!): UserModel!
|
||||
changePassword(data: ChangePasswordInput!): UserModel!
|
||||
changeProfileAvatar(avatar: Upload!): Boolean!
|
||||
changeProfileInfo(data: ChangeProfileInfoInput!): UserModel!
|
||||
clearSessionCookie: Boolean!
|
||||
createUser(data: CreateUserInput!): UserModel!
|
||||
deactivateAccount(data: DeactivateAccountInput!): AuthModel!
|
||||
@ -67,6 +75,7 @@ type Mutation {
|
||||
enableTotp(data: EnableTotpInput!): Boolean!
|
||||
loginUser(data: LoginInput!): AuthModel!
|
||||
logoutUser: Boolean!
|
||||
removeProfileAvatar: Boolean!
|
||||
removeSession(id: String!): Boolean!
|
||||
resetPassword(data: ResetPasswordInput!): Boolean!
|
||||
setNewPassword(data: NewPasswordInput!): Boolean!
|
||||
@ -108,6 +117,9 @@ type TotpModel {
|
||||
secret: String!
|
||||
}
|
||||
|
||||
"""The `Upload` scalar type represents a file upload."""
|
||||
scalar Upload
|
||||
|
||||
type UserModel {
|
||||
avatar: String
|
||||
bio: String
|
||||
|
||||
@ -8,6 +8,7 @@ import RedisStore from 'connect-redis'
|
||||
import * as cookieParser from 'cookie-parser'
|
||||
import { CoreModule } from '@/src/core/core.module'
|
||||
import * as session from 'express-session'
|
||||
import * as graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(CoreModule)
|
||||
@ -16,6 +17,7 @@ async function bootstrap() {
|
||||
const redis = app.get(RedisService)
|
||||
|
||||
app.use(cookieParser(config.getOrThrow<string>('COOKIE_SECRET')))
|
||||
app.use(config.getOrThrow<string>('GRAPHQL_PREFIX'), graphqlUploadExpress())
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({
|
||||
transform: true,
|
||||
|
||||
@ -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
|
||||
}
|
||||
8
backend/src/module/auth/profile/profile.module.ts
Normal file
8
backend/src/module/auth/profile/profile.module.ts
Normal 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 {}
|
||||
39
backend/src/module/auth/profile/profile.resolver.ts
Normal file
39
backend/src/module/auth/profile/profile.resolver.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
75
backend/src/module/auth/profile/profile.service.ts
Normal file
75
backend/src/module/auth/profile/profile.service.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { PrismaService } from '@/src/core/prisma/prisma.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 { Cron, CronExpression } from '@nestjs/schedule'
|
||||
|
||||
@ -8,6 +9,7 @@ export class CronService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly storageService: StorageService,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -28,6 +30,9 @@ export class CronService {
|
||||
for (const user of deactivatedAccounts) {
|
||||
console.log('Deactivate user', user.name, user.email)
|
||||
await this.mailService.sendAccountDeletion(user.email)
|
||||
if (user.avatar) {
|
||||
await this.storageService.remove(user.avatar)
|
||||
}
|
||||
}
|
||||
|
||||
await this.prismaService.user.deleteMany({
|
||||
|
||||
9
backend/src/module/libs/storage/storage.module.ts
Normal file
9
backend/src/module/libs/storage/storage.module.ts
Normal 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 {}
|
||||
61
backend/src/module/libs/storage/storage.service.ts
Normal file
61
backend/src/module/libs/storage/storage.service.ts
Normal 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('Ошибка при удалении файла')
|
||||
}
|
||||
}
|
||||
}
|
||||
29
backend/src/shared/pipes/file-validation.pipe.ts
Normal file
29
backend/src/shared/pipes/file-validation.pipe.ts
Normal 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
|
||||
}
|
||||
}
|
||||
25
backend/src/shared/util/file.utils.ts
Normal file
25
backend/src/shared/util/file.utils.ts
Normal 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) })
|
||||
})
|
||||
}
|
||||
1329
backend/yarn.lock
1329
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user