feat: add media service

This commit is contained in:
Sergey Krylov 2026-04-03 06:30:04 +03:00
parent 36c058f885
commit b2e02e0ae2
13 changed files with 165 additions and 32 deletions

View File

@ -41,6 +41,7 @@
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"multer": "^2.1.1",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
@ -55,6 +56,7 @@
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/multer": "^2.1.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"eslint": "^9.18.0", "eslint": "^9.18.0",

View File

@ -4,6 +4,7 @@ import { PassportModule } from '@teacinema/passport'
import { AccountModule } from '../modules/account/account.module' import { AccountModule } from '../modules/account/account.module'
import { AuthModule } from '../modules/auth/auth.module' import { AuthModule } from '../modules/auth/auth.module'
import { MediaModule } from '../modules/media/media.module'
import { UsersModule } from '../modules/users/users.module' import { UsersModule } from '../modules/users/users.module'
import { ObservabilityModule } from '../observability/observability.module' import { ObservabilityModule } from '../observability/observability.module'
@ -24,6 +25,7 @@ import { getPassportConfig } from './config'
ObservabilityModule, ObservabilityModule,
AuthModule, AuthModule,
AccountModule, AccountModule,
MediaModule,
UsersModule, UsersModule,
PassportModule.registerAsync({ PassportModule.registerAsync({
useFactory: getPassportConfig, useFactory: getPassportConfig,

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common'
import type { ClientGrpc } from '@nestjs/microservices' import type { ClientGrpc } from '@nestjs/microservices'
import { InjectGrpc } from '@teacinema/common' 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' import { AbstractGrpcClient } from '../../shared/grpc/abstract-grpc.client'

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common'
import type { ClientGrpc } from '@nestjs/microservices' import type { ClientGrpc } from '@nestjs/microservices'
import { InjectGrpc } from '@teacinema/common' 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' import { AbstractGrpcClient } from '../../shared/grpc/abstract-grpc.client'

View File

@ -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<MediaServiceClient> {
constructor(@InjectGrpc('MEDIA_PACKAGE') client: ClientGrpc) {
super(client, 'MediaService')
}
}

View File

@ -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 {}

View File

@ -1,14 +1,28 @@
import { import {
Body, Body,
Controller, Controller,
FileTypeValidator,
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Patch MaxFileSizeValidator,
ParseFilePipe,
Patch,
UploadedFile,
UseInterceptors
} from '@nestjs/common' } 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 { CurrentUser, Protected } from '../../shared/decorators'
import { MediaClientGrpc } from '../media/media.grpc'
import { import {
GetMeResponseDto, GetMeResponseDto,
@ -17,9 +31,15 @@ import {
} from './dto' } from './dto'
import { UsersClientGrpc } from './users.grpc' 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') @Controller('users')
export class UsersController { export class UsersController {
constructor(private readonly client: UsersClientGrpc) {} constructor(
private readonly users: UsersClientGrpc,
private readonly media: MediaClientGrpc
) {}
@ApiOperation({ @ApiOperation({
summary: 'Get Me', summary: 'Get Me',
@ -31,7 +51,7 @@ export class UsersController {
@Get('me') @Get('me')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
public async getMe(@CurrentUser() userId: string) { 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 return user
} }
@ -48,6 +68,62 @@ export class UsersController {
@CurrentUser() userId: string, @CurrentUser() userId: string,
@Body() dto: PatchUserRequestDto @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
})
} }
} }

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common'
import type { ClientGrpc } from '@nestjs/microservices' import type { ClientGrpc } from '@nestjs/microservices'
import { InjectGrpc } from '@teacinema/common' 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' import { AbstractGrpcClient } from '../../shared/grpc/abstract-grpc.client'

View File

@ -1,11 +1,13 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { GrpcModule } from '@teacinema/common' import { GrpcModule } from '@teacinema/common'
import { MediaModule } from '../media/media.module'
import { UsersController } from './users.controller' import { UsersController } from './users.controller'
import { UsersClientGrpc } from './users.grpc' import { UsersClientGrpc } from './users.grpc'
@Module({ @Module({
imports: [GrpcModule.register(['USERS_PACKAGE'])], imports: [GrpcModule.register(['USERS_PACKAGE']), MediaModule],
controllers: [UsersController], controllers: [UsersController],
providers: [UsersClientGrpc], providers: [UsersClientGrpc],
exports: [UsersClientGrpc] exports: [UsersClientGrpc]

View File

@ -1,5 +1,5 @@
import { applyDecorators, UseGuards } from '@nestjs/common' 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' import { AuthGuard, RolesGuard } from '../guards'

View File

@ -1,5 +1,5 @@
import { SetMetadata } from '@nestjs/common' 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' export const ROLES_KEY = 'required_roles'

View File

@ -6,7 +6,7 @@ import {
NotFoundException NotFoundException
} from '@nestjs/common' } from '@nestjs/common'
import { Reflector } from '@nestjs/core' 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 { Request } from 'express'
import { AccountClientGrpc } from '../../modules/account/account.grpc' import { AccountClientGrpc } from '../../modules/account/account.grpc'

View File

@ -329,9 +329,9 @@
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@borewit/text-codec@^0.2.1": "@borewit/text-codec@^0.2.1":
version "0.2.1" version "0.2.2"
resolved "https://registry.yarnpkg.com/@borewit/text-codec/-/text-codec-0.2.1.tgz#5d171538907a8cb395fdc2eb5e8f7947d96c7f2f" resolved "https://registry.yarnpkg.com/@borewit/text-codec/-/text-codec-0.2.2.tgz#75025f735c0983b3a871668804a57387e3649375"
integrity sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw== integrity sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==
"@bufbuild/protobuf@^2.0.0", "@bufbuild/protobuf@^2.10.2": "@bufbuild/protobuf@^2.0.0", "@bufbuild/protobuf@^2.10.2":
version "2.11.0" version "2.11.0"
@ -1028,12 +1028,12 @@
tslib "2.8.1" tslib "2.8.1"
"@nestjs/common@^11.1.16": "@nestjs/common@^11.1.16":
version "11.1.16" version "11.1.17"
resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.1.16.tgz#1f4061f66d7ae63e407f8d2be2aaeaf83097332b" resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.1.17.tgz#58a330f3f6748cf92b0a244a2a232cfddba1bfd2"
integrity sha512-JSIeW+USuMJkkcNbiOdcPkVCeI3TSnXstIVEPpp3HiaKnPRuSbUUKm9TY9o/XpIcPHWUOQItAtC5BiAwFdVITQ== integrity sha512-hLODw5Abp8OQgA+mUO4tHou4krKgDtUcM9j5Ihxncst9XeyxYBTt2bwZm4e4EQr5E352S4Fyy6V3iFx9ggxKAg==
dependencies: dependencies:
uid "2.0.2" uid "2.0.2"
file-type "21.3.0" file-type "21.3.2"
iterare "1.2.1" iterare "1.2.1"
load-esm "1.0.3" load-esm "1.0.3"
tslib "2.8.1" tslib "2.8.1"
@ -1094,9 +1094,9 @@
tslib "2.8.1" tslib "2.8.1"
"@nestjs/microservices@^11.1.16": "@nestjs/microservices@^11.1.16":
version "11.1.16" version "11.1.17"
resolved "https://registry.yarnpkg.com/@nestjs/microservices/-/microservices-11.1.16.tgz#6f445c44adcee88af983830e0667c93ccefe2869" resolved "https://registry.yarnpkg.com/@nestjs/microservices/-/microservices-11.1.17.tgz#f2b1aa25e24532377477a437583f1af002118602"
integrity sha512-eG/ArIq0UJyR3i/GTYuApA4OZylhuLGacVaVT9mMxQgT7ZTpp5CZgOwLNdcUUdOS6qypK3waG1m2AC54xzdf0Q== integrity sha512-rubBPEEXS9PwA3LwdNN/ytjEUmON+d8+4fbiUCIFGW8i+rR/g36vrpwuvoAzcGpxlu45/TpZPfHTb0nJciG9Yw==
dependencies: dependencies:
iterare "1.2.1" iterare "1.2.1"
tslib "2.8.1" tslib "2.8.1"
@ -1997,9 +1997,9 @@
"@sinonjs/commons" "^3.0.1" "@sinonjs/commons" "^3.0.1"
"@teacinema/common@^1.0.0": "@teacinema/common@^1.0.0":
version "1.1.3" version "1.1.6"
resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcommon/-/1.1.3/common-1.1.3.tgz#73c8e3642f37d9c6574f6a1e5b542dd6cbfdbf2f" resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcommon/-/1.1.6/common-1.1.6.tgz#4b43fc7c1f19cef0ba138a3a7988918f54f52ebb"
integrity sha512-Bk2JzG3P1K7YFoFywtSP3zHxNfUpy+NyboqChj+4GZ6JuoWg2V1wkrjMljeE90cPOaqCiNHxk08zX037waiKJg== integrity sha512-a0wU0EaJ92PdMlwFQ5pA0EZ9G1/f9Gca8CCN2qUy75q0HldNTGKKYUEyDHx4Y0z16N1e3/RO12gagebOrV6Nbg==
dependencies: dependencies:
"@nestjs/common" "^11.1.16" "@nestjs/common" "^11.1.16"
"@nestjs/config" "^4.0.3" "@nestjs/config" "^4.0.3"
@ -2007,9 +2007,9 @@
"@teacinema/contracts" "^1.1.3" "@teacinema/contracts" "^1.1.3"
"@teacinema/contracts@^1.0.0", "@teacinema/contracts@^1.1.3": "@teacinema/contracts@^1.0.0", "@teacinema/contracts@^1.1.3":
version "1.1.3" version "1.1.8"
resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.1.3/contracts-1.1.3.tgz#e08600a8dc09977dae35654d372e29e411029e33" resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.1.8/contracts-1.1.8.tgz#e96c23a957d2682503c157250672eb4c46bf4ef9"
integrity sha512-kwcSh2YIxmoBJQ56L+tQpGj12ySwNwf9KRNcUqhPffLPO60PR5aiKA2eN2SfKc23TKp0YoSkdC9LWeor0a9Ysg== integrity sha512-wSFTntA0IoQrC/iYz3rylJW6qK6KGWa25oQrl/n55aSOcMk1ACmSBt0aqAPML86SQWAsn7yikpr/foaTgJH3zg==
dependencies: dependencies:
"@nestjs/microservices" "^11.1.12" "@nestjs/microservices" "^11.1.12"
protoc "33.4.0" protoc "33.4.0"
@ -2186,7 +2186,7 @@
"@types/range-parser" "*" "@types/range-parser" "*"
"@types/send" "*" "@types/send" "*"
"@types/express@^5.0.0": "@types/express@*", "@types/express@^5.0.0":
version "5.0.6" version "5.0.6"
resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.6.tgz#2d724b2c990dcb8c8444063f3580a903f6d500cc" resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.6.tgz#2d724b2c990dcb8c8444063f3580a903f6d500cc"
integrity sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA== integrity sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==
@ -2244,6 +2244,13 @@
resolved "https://registry.yarnpkg.com/@types/methods/-/methods-1.1.4.tgz#d3b7ac30ac47c91054ea951ce9eed07b1051e547" resolved "https://registry.yarnpkg.com/@types/methods/-/methods-1.1.4.tgz#d3b7ac30ac47c91054ea951ce9eed07b1051e547"
integrity sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ== 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": "@types/mysql@2.15.27":
version "2.15.27" version "2.15.27"
resolved "https://registry.yarnpkg.com/@types/mysql/-/mysql-2.15.27.tgz#fb13b0e8614d39d42f40f381217ec3215915f1e9" 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" token-types "^6.1.1"
uint8array-extras "^1.4.0" 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: fill-range@^7.1.1:
version "7.1.1" version "7.1.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" 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" type-is "^1.6.18"
xtend "^4.0.2" 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: mute-stream@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-2.0.0.tgz#a5446fc0c512b71c83c44d908d5c7b7b4c493b2b" 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== integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
strtok3@^10.3.4: strtok3@^10.3.4:
version "10.3.4" version "10.3.5"
resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-10.3.4.tgz#793ebd0d59df276a085586134b73a406e60be9c1" resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-10.3.5.tgz#7213285da0dc3dec0fc8ce5df4b8b7a733f14360"
integrity sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg== integrity sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==
dependencies: dependencies:
"@tokenizer/token" "^0.3.0" "@tokenizer/token" "^0.3.0"