diff --git a/package.json b/package.json index 5119191..0100be0 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "class-validator": "^0.14.3", "cookie-parser": "^1.4.7", "cross-env": "^10.1.0", + "multer": "^2.1.1", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" @@ -55,6 +56,7 @@ "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/multer": "^2.1.0", "@types/node": "^22.10.7", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", diff --git a/src/core/app.module.ts b/src/core/app.module.ts index a3db56f..1b2fcb6 100644 --- a/src/core/app.module.ts +++ b/src/core/app.module.ts @@ -4,6 +4,7 @@ import { PassportModule } from '@teacinema/passport' import { AccountModule } from '../modules/account/account.module' import { AuthModule } from '../modules/auth/auth.module' +import { MediaModule } from '../modules/media/media.module' import { UsersModule } from '../modules/users/users.module' import { ObservabilityModule } from '../observability/observability.module' @@ -24,6 +25,7 @@ import { getPassportConfig } from './config' ObservabilityModule, AuthModule, AccountModule, + MediaModule, UsersModule, PassportModule.registerAsync({ useFactory: getPassportConfig, diff --git a/src/modules/account/account.grpc.ts b/src/modules/account/account.grpc.ts index 0cc0374..221fc7f 100644 --- a/src/modules/account/account.grpc.ts +++ b/src/modules/account/account.grpc.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common' import type { ClientGrpc } from '@nestjs/microservices' import { InjectGrpc } from '@teacinema/common' -import { AccountServiceClient } from '@teacinema/contracts/gen/account' +import { AccountServiceClient } from '@teacinema/contracts/gen/ts/account' import { AbstractGrpcClient } from '../../shared/grpc/abstract-grpc.client' diff --git a/src/modules/auth/auth.grpc.ts b/src/modules/auth/auth.grpc.ts index 65319e1..54acdc7 100644 --- a/src/modules/auth/auth.grpc.ts +++ b/src/modules/auth/auth.grpc.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common' import type { ClientGrpc } from '@nestjs/microservices' import { InjectGrpc } from '@teacinema/common' -import { AuthServiceClient } from '@teacinema/contracts/gen/auth' +import { AuthServiceClient } from '@teacinema/contracts/gen/ts/auth' import { AbstractGrpcClient } from '../../shared/grpc/abstract-grpc.client' diff --git a/src/modules/media/media.grpc.ts b/src/modules/media/media.grpc.ts new file mode 100644 index 0000000..c774dd6 --- /dev/null +++ b/src/modules/media/media.grpc.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common' +import type { ClientGrpc } from '@nestjs/microservices' +import { InjectGrpc } from '@teacinema/common' +import { MediaServiceClient } from '@teacinema/contracts/gen/ts/media' + +import { AbstractGrpcClient } from '../../shared/grpc/abstract-grpc.client' + +@Injectable() +export class MediaClientGrpc extends AbstractGrpcClient { + constructor(@InjectGrpc('MEDIA_PACKAGE') client: ClientGrpc) { + super(client, 'MediaService') + } +} diff --git a/src/modules/media/media.module.ts b/src/modules/media/media.module.ts new file mode 100644 index 0000000..8b912ea --- /dev/null +++ b/src/modules/media/media.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { GrpcModule } from '@teacinema/common' + +import { MediaClientGrpc } from './media.grpc' + +@Module({ + imports: [GrpcModule.register(['MEDIA_PACKAGE'])], + providers: [MediaClientGrpc], + exports: [MediaClientGrpc] +}) +export class MediaModule {} diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 15ded32..b71788b 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -1,14 +1,28 @@ import { Body, Controller, + FileTypeValidator, Get, HttpCode, HttpStatus, - Patch + MaxFileSizeValidator, + ParseFilePipe, + Patch, + UploadedFile, + UseInterceptors } from '@nestjs/common' -import { ApiBearerAuth, ApiOkResponse, ApiOperation } from '@nestjs/swagger' +import { FileInterceptor } from '@nestjs/platform-express' +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOkResponse, + ApiOperation +} from '@nestjs/swagger' +import { randomBytes } from 'node:crypto' import { CurrentUser, Protected } from '../../shared/decorators' +import { MediaClientGrpc } from '../media/media.grpc' import { GetMeResponseDto, @@ -17,9 +31,15 @@ import { } from './dto' import { UsersClientGrpc } from './users.grpc' +const AVATAR_MAX_SIZE = 10_485_760 // 10 MB +const AVATAR_ALLOWED_MIME_TYPES = /image\/(jpg|jpeg|gif|png)/ + @Controller('users') export class UsersController { - constructor(private readonly client: UsersClientGrpc) {} + constructor( + private readonly users: UsersClientGrpc, + private readonly media: MediaClientGrpc + ) {} @ApiOperation({ summary: 'Get Me', @@ -31,7 +51,7 @@ export class UsersController { @Get('me') @HttpCode(HttpStatus.OK) public async getMe(@CurrentUser() userId: string) { - const { user } = await this.client.call('getMe', { id: userId }) + const { user } = await this.users.call('getMe', { id: userId }) return user } @@ -48,6 +68,62 @@ export class UsersController { @CurrentUser() userId: string, @Body() dto: PatchUserRequestDto ) { - return this.client.call('patchUser', { userId, ...dto }) + return this.users.call('patchUser', { userId, ...dto }) + } + + @ApiOperation({ + summary: 'Update user avatar', + description: 'Update user avatar' + }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + description: 'Avatar file', + schema: { + type: 'object', + properties: { file: { type: 'string', format: 'binary' } }, + required: ['file'] + } + }) + @ApiBearerAuth() + @ApiOkResponse({ type: PatchUserResponseDto }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { file: { type: 'string', format: 'binary' } }, + required: ['file'] + } + }) + @UseInterceptors( + FileInterceptor('file', { limits: { fileSize: AVATAR_MAX_SIZE } }) + ) + @Protected() + @Patch('me/avatar') + @HttpCode(HttpStatus.OK) + public async changeAvatar( + @CurrentUser() userId: string, + @UploadedFile( + new ParseFilePipe({ + validators: [ + new MaxFileSizeValidator({ maxSize: AVATAR_MAX_SIZE }), + new FileTypeValidator({ fileType: AVATAR_ALLOWED_MIME_TYPES }) + ] + }) + ) + file: Express.Multer.File + ) { + const response = await this.media.call('upload', { + fileName: randomBytes(16).toString('hex'), + folder: 'users', + contentType: file.mimetype, + data: new Uint8Array(file.buffer), + resizeWidth: 512, + resizeHeight: 512 + }) + + return this.users.call('patchUser', { + userId, + avatar: response.key + }) } } diff --git a/src/modules/users/users.grpc.ts b/src/modules/users/users.grpc.ts index 6f93401..c057f95 100644 --- a/src/modules/users/users.grpc.ts +++ b/src/modules/users/users.grpc.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common' import type { ClientGrpc } from '@nestjs/microservices' import { InjectGrpc } from '@teacinema/common' -import { UsersServiceClient } from '@teacinema/contracts/gen/users' +import { UsersServiceClient } from '@teacinema/contracts/gen/ts/users' import { AbstractGrpcClient } from '../../shared/grpc/abstract-grpc.client' diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index 222652f..cc4aaf2 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common' import { GrpcModule } from '@teacinema/common' +import { MediaModule } from '../media/media.module' + import { UsersController } from './users.controller' import { UsersClientGrpc } from './users.grpc' @Module({ - imports: [GrpcModule.register(['USERS_PACKAGE'])], + imports: [GrpcModule.register(['USERS_PACKAGE']), MediaModule], controllers: [UsersController], providers: [UsersClientGrpc], exports: [UsersClientGrpc] diff --git a/src/shared/decorators/protected.decorator.ts b/src/shared/decorators/protected.decorator.ts index 0406041..944eba7 100644 --- a/src/shared/decorators/protected.decorator.ts +++ b/src/shared/decorators/protected.decorator.ts @@ -1,5 +1,5 @@ import { applyDecorators, UseGuards } from '@nestjs/common' -import { Role } from '@teacinema/contracts/gen/account' +import { Role } from '@teacinema/contracts/gen/ts/account' import { AuthGuard, RolesGuard } from '../guards' diff --git a/src/shared/decorators/roles.decorator.ts b/src/shared/decorators/roles.decorator.ts index 5397e8f..bfa8007 100644 --- a/src/shared/decorators/roles.decorator.ts +++ b/src/shared/decorators/roles.decorator.ts @@ -1,5 +1,5 @@ import { SetMetadata } from '@nestjs/common' -import { Role } from '@teacinema/contracts/gen/account' +import { Role } from '@teacinema/contracts/gen/ts/account' export const ROLES_KEY = 'required_roles' diff --git a/src/shared/guards/roles.guard.ts b/src/shared/guards/roles.guard.ts index f890855..c22296c 100644 --- a/src/shared/guards/roles.guard.ts +++ b/src/shared/guards/roles.guard.ts @@ -6,7 +6,7 @@ import { NotFoundException } from '@nestjs/common' import { Reflector } from '@nestjs/core' -import { Role } from '@teacinema/contracts/gen/account' +import { Role } from '@teacinema/contracts/gen/ts/account' import { Request } from 'express' import { AccountClientGrpc } from '../../modules/account/account.grpc' diff --git a/yarn.lock b/yarn.lock index 0254f2f..97b8cdf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -329,9 +329,9 @@ integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== "@borewit/text-codec@^0.2.1": - version "0.2.1" - resolved "https://registry.yarnpkg.com/@borewit/text-codec/-/text-codec-0.2.1.tgz#5d171538907a8cb395fdc2eb5e8f7947d96c7f2f" - integrity sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw== + version "0.2.2" + resolved "https://registry.yarnpkg.com/@borewit/text-codec/-/text-codec-0.2.2.tgz#75025f735c0983b3a871668804a57387e3649375" + integrity sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ== "@bufbuild/protobuf@^2.0.0", "@bufbuild/protobuf@^2.10.2": version "2.11.0" @@ -1028,12 +1028,12 @@ tslib "2.8.1" "@nestjs/common@^11.1.16": - version "11.1.16" - resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.1.16.tgz#1f4061f66d7ae63e407f8d2be2aaeaf83097332b" - integrity sha512-JSIeW+USuMJkkcNbiOdcPkVCeI3TSnXstIVEPpp3HiaKnPRuSbUUKm9TY9o/XpIcPHWUOQItAtC5BiAwFdVITQ== + version "11.1.17" + resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.1.17.tgz#58a330f3f6748cf92b0a244a2a232cfddba1bfd2" + integrity sha512-hLODw5Abp8OQgA+mUO4tHou4krKgDtUcM9j5Ihxncst9XeyxYBTt2bwZm4e4EQr5E352S4Fyy6V3iFx9ggxKAg== dependencies: uid "2.0.2" - file-type "21.3.0" + file-type "21.3.2" iterare "1.2.1" load-esm "1.0.3" tslib "2.8.1" @@ -1094,9 +1094,9 @@ tslib "2.8.1" "@nestjs/microservices@^11.1.16": - version "11.1.16" - resolved "https://registry.yarnpkg.com/@nestjs/microservices/-/microservices-11.1.16.tgz#6f445c44adcee88af983830e0667c93ccefe2869" - integrity sha512-eG/ArIq0UJyR3i/GTYuApA4OZylhuLGacVaVT9mMxQgT7ZTpp5CZgOwLNdcUUdOS6qypK3waG1m2AC54xzdf0Q== + version "11.1.17" + resolved "https://registry.yarnpkg.com/@nestjs/microservices/-/microservices-11.1.17.tgz#f2b1aa25e24532377477a437583f1af002118602" + integrity sha512-rubBPEEXS9PwA3LwdNN/ytjEUmON+d8+4fbiUCIFGW8i+rR/g36vrpwuvoAzcGpxlu45/TpZPfHTb0nJciG9Yw== dependencies: iterare "1.2.1" tslib "2.8.1" @@ -1997,9 +1997,9 @@ "@sinonjs/commons" "^3.0.1" "@teacinema/common@^1.0.0": - version "1.1.3" - resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcommon/-/1.1.3/common-1.1.3.tgz#73c8e3642f37d9c6574f6a1e5b542dd6cbfdbf2f" - integrity sha512-Bk2JzG3P1K7YFoFywtSP3zHxNfUpy+NyboqChj+4GZ6JuoWg2V1wkrjMljeE90cPOaqCiNHxk08zX037waiKJg== + version "1.1.6" + resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcommon/-/1.1.6/common-1.1.6.tgz#4b43fc7c1f19cef0ba138a3a7988918f54f52ebb" + integrity sha512-a0wU0EaJ92PdMlwFQ5pA0EZ9G1/f9Gca8CCN2qUy75q0HldNTGKKYUEyDHx4Y0z16N1e3/RO12gagebOrV6Nbg== dependencies: "@nestjs/common" "^11.1.16" "@nestjs/config" "^4.0.3" @@ -2007,9 +2007,9 @@ "@teacinema/contracts" "^1.1.3" "@teacinema/contracts@^1.0.0", "@teacinema/contracts@^1.1.3": - version "1.1.3" - resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.1.3/contracts-1.1.3.tgz#e08600a8dc09977dae35654d372e29e411029e33" - integrity sha512-kwcSh2YIxmoBJQ56L+tQpGj12ySwNwf9KRNcUqhPffLPO60PR5aiKA2eN2SfKc23TKp0YoSkdC9LWeor0a9Ysg== + version "1.1.8" + resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.1.8/contracts-1.1.8.tgz#e96c23a957d2682503c157250672eb4c46bf4ef9" + integrity sha512-wSFTntA0IoQrC/iYz3rylJW6qK6KGWa25oQrl/n55aSOcMk1ACmSBt0aqAPML86SQWAsn7yikpr/foaTgJH3zg== dependencies: "@nestjs/microservices" "^11.1.12" protoc "33.4.0" @@ -2186,7 +2186,7 @@ "@types/range-parser" "*" "@types/send" "*" -"@types/express@^5.0.0": +"@types/express@*", "@types/express@^5.0.0": version "5.0.6" resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.6.tgz#2d724b2c990dcb8c8444063f3580a903f6d500cc" integrity sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA== @@ -2244,6 +2244,13 @@ resolved "https://registry.yarnpkg.com/@types/methods/-/methods-1.1.4.tgz#d3b7ac30ac47c91054ea951ce9eed07b1051e547" integrity sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ== +"@types/multer@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/multer/-/multer-2.1.0.tgz#c6ae866b9a3d9310b477d021a78b33feda092358" + integrity sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA== + dependencies: + "@types/express" "*" + "@types/mysql@2.15.27": version "2.15.27" resolved "https://registry.yarnpkg.com/@types/mysql/-/mysql-2.15.27.tgz#fb13b0e8614d39d42f40f381217ec3215915f1e9" @@ -3846,6 +3853,16 @@ file-type@21.3.0: token-types "^6.1.1" uint8array-extras "^1.4.0" +file-type@21.3.2: + version "21.3.2" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-21.3.2.tgz#b28bb3a19ea4b03f59af31546b9d95ee8a3623eb" + integrity sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w== + dependencies: + "@tokenizer/inflate" "^0.4.1" + strtok3 "^10.3.4" + token-types "^6.1.1" + uint8array-extras "^1.4.0" + fill-range@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" @@ -5093,6 +5110,16 @@ multer@2.0.2: type-is "^1.6.18" xtend "^4.0.2" +multer@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/multer/-/multer-2.1.1.tgz#122d819244fbdfee1efddd9147426691014385b7" + integrity sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A== + dependencies: + append-field "^1.0.0" + busboy "^1.6.0" + concat-stream "^2.0.0" + type-is "^1.6.18" + mute-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-2.0.0.tgz#a5446fc0c512b71c83c44d908d5c7b7b4c493b2b" @@ -5883,9 +5910,9 @@ strip-json-comments@^3.1.1: integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== strtok3@^10.3.4: - version "10.3.4" - resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-10.3.4.tgz#793ebd0d59df276a085586134b73a406e60be9c1" - integrity sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg== + version "10.3.5" + resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-10.3.5.tgz#7213285da0dc3dec0fc8ce5df4b8b7a733f14360" + integrity sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA== dependencies: "@tokenizer/token" "^0.3.0"