Предыдущая статья: Интеграция внешнего сервера авторизации https://authorizer.dev в фулстек приложение на NestJS и Angular
В этой статье я подключу в проект внешний файловый сервер https://min.io и напишу дополнительные бэкенд и фронтенд модули для интеграции с ним.
1. Создаем Angular-библиотеку по работе с файлами
Создаем пустую Angular
-библиотеку для хранения компонент и сервисов для отправки файлов на сервер.
Команды
# Create Angular library ./node_modules/.bin/nx g @nx/angular:library --name=files-angular --buildable --publishable --directory=libs/core/files-angular --simpleName=true --strict=true --prefix= --standalone=true --selector= --changeDetection=OnPush --importPath=@nestjs-mod-fullstack/files-angular # Change file with test options rm -rf libs/core/files-angular/src/test-setup.ts cp apps/client/src/test-setup.ts libs/core/files-angular/src/test-setup.ts
Вывод консоли
$ ./node_modules/.bin/nx g @nx/angular:library --name=files-angular --buildable --publishable --directory=libs/core/files-angular --simpleName=true --strict=true --prefix= --standalone=true --selector= --changeDetection=OnPush --importPath=@nestjs-mod-fullstack/files-angular NX Generating @nx/angular:library CREATE libs/core/files-angular/project.json CREATE libs/core/files-angular/README.md CREATE libs/core/files-angular/ng-package.json CREATE libs/core/files-angular/package.json CREATE libs/core/files-angular/tsconfig.json CREATE libs/core/files-angular/tsconfig.lib.json CREATE libs/core/files-angular/tsconfig.lib.prod.json CREATE libs/core/files-angular/src/index.ts CREATE libs/core/files-angular/jest.config.ts CREATE libs/core/files-angular/src/test-setup.ts CREATE libs/core/files-angular/tsconfig.spec.json CREATE libs/core/files-angular/src/lib/files-angular/files-angular.component.css CREATE libs/core/files-angular/src/lib/files-angular/files-angular.component.html CREATE libs/core/files-angular/src/lib/files-angular/files-angular.component.spec.ts CREATE libs/core/files-angular/src/lib/files-angular/files-angular.component.ts CREATE libs/core/files-angular/.eslintrc.json UPDATE tsconfig.base.json NX 👀 View Details of files-angular Run "nx show project files-angular" to view details about this project.
2. Создаем NestJS-библиотеку по работе с файлами
Создаем пустую NestJS
-библиотеку.
Команды
./node_modules/.bin/nx g @nestjs-mod/schematics:library files --buildable --publishable --directory=libs/core/files --simpleName=true --projectNameAndRootFormat=as-provided --strict=true
Вывод консоли
$ ./node_modules/.bin/nx g @nestjs-mod/schematics:library files --buildable --publishable --directory=libs/core/files --simpleName=true --projectNameAndRootFormat=as-provided --strict=true NX Generating @nestjs-mod/schematics:library CREATE libs/core/files/tsconfig.json CREATE libs/core/files/src/index.ts CREATE libs/core/files/tsconfig.lib.json CREATE libs/core/files/README.md CREATE libs/core/files/package.json CREATE libs/core/files/project.json CREATE libs/core/files/.eslintrc.json CREATE libs/core/files/jest.config.ts CREATE libs/core/files/tsconfig.spec.json UPDATE tsconfig.base.json CREATE libs/core/files/src/lib/files.configuration.ts CREATE libs/core/files/src/lib/files.constants.ts CREATE libs/core/files/src/lib/files.environments.ts CREATE libs/core/files/src/lib/files.module.ts
3. Устанавливаем дополнительные библиотеки
Устанавливаем JS
-клиент и NestJS
-модуль для работы с файловым сервером minio
с фронтенда и бэкенда.
Команды
npm install --save minio nestjs-minio @nestjs-mod/minio
Вывод консоли
$ npm install --save minio nestjs-minio @nestjs-mod/minio added 29 packages, removed 2 packages, and audited 2916 packages in 22s 362 packages are looking for funding run `npm fund` for details 41 vulnerabilities (19 low, 7 moderate, 15 high) To address issues that do not require attention, run: npm audit fix To address all issues possible (including breaking changes), run: npm audit fix --force Some issues need review, and may require choosing a different dependency. Run `npm audit` for details.
4. Подключаем новые модули в бэкенд
apps/server/src/main.ts
import { DOCKER_COMPOSE_FILE, DockerCompose, DockerComposeAuthorizer, DockerComposeMinio, DockerComposePostgreSQL, } from '@nestjs-mod/docker-compose'; // ... import { MinioModule } from '@nestjs-mod/minio'; // ... import { ExecutionContext } from '@nestjs/common'; // ... bootstrapNestApplication({ modules: { // ... core: [ MinioModule.forRoot(), ], infrastructure: [ DockerComposeMinio.forRoot({ staticConfiguration: { image: 'bitnami/minio:2024.11.7' }, }), ]} );
5. Запускаем генерацию дополнительного кода по инфраструктуре
Команды
npm run docs:infrastructure
6. Добавляем весь необходимый код в модуль FilesModule (NestJS-библиотека)
Так как основная логика по подключению к файловому серверу и работа с ним происходит с помощью библиотеки @nestjs-mod/minio
, то в нашей новой библиотеке будет только контроллер который будет предоставлять фронтенд приложению необходимые методы и при этом проверять права пользователя.
Загрузка файла с фронтенда происходит с помощью временной ссылки напрямую в файловый сервер, временную ссылку создает наш бэкенд.
Удалять загруженные файлы могут только пользователи кто загрузил файл и администраторы сайта.
Идентификатор пользователя должен находится в Request
, в поле externalUserId
.
Создаем файл libs/core/files/src/lib/controllers/files.controller.ts
import { Controller, Get, Post, Query } from '@nestjs/common'; import { MinioConfiguration, MinioFilesService, PresignedUrlsRequest, PresignedUrls as PresignedUrlsResponse } from '@nestjs-mod/minio'; import { ApiExtraModels, ApiOkResponse, ApiProperty } from '@nestjs/swagger'; import { FilesError, FilesErrorEnum } from '../files.errors'; import { CurrentFilesRequest } from '../files.decorators'; import { FilesRequest } from '../types/files-request'; import { StatusResponse } from '@nestjs-mod-fullstack/common'; import { map } from 'rxjs'; import { FilesRole } from '../types/files-role'; export class GetPresignedUrlArgs implements PresignedUrlsRequest { @ApiProperty({ type: String }) ext!: string; } export class PresignedUrls implements PresignedUrlsResponse { @ApiProperty({ type: String }) downloadUrl!: string; @ApiProperty({ type: String }) uploadUrl!: string; } export class DeleteFileArgs { @ApiProperty({ type: String }) downloadUrl!: string; } @ApiExtraModels(FilesError) @Controller() export class FilesController { constructor(private readonly minioConfiguration: MinioConfiguration, private readonly minioFilesService: MinioFilesService) {} @Get('/files/get-presigned-url') @ApiOkResponse({ type: PresignedUrls }) getPresignedUrl(@Query() getPresignedUrlArgs: GetPresignedUrlArgs, @CurrentFilesRequest() filesRequest: FilesRequest) { const bucketName = Object.entries(this.minioConfiguration.buckets || {}) .filter(([, options]) => options.ext.includes(getPresignedUrlArgs.ext)) .map(([name]) => name)?.[0]; if (!bucketName) { throw new FilesError(`Uploading files with extension "{{ext}}" is not supported`, FilesErrorEnum.FORBIDDEN, { ext: getPresignedUrlArgs.ext }); } return this.minioFilesService.getPresignedUrls({ bucketName, expiry: 60, ext: getPresignedUrlArgs.ext, userId: filesRequest.externalUserId, }); } @Post('/files/delete-file') @ApiOkResponse({ type: StatusResponse }) deleteFile(@Query() deleteFileArgs: DeleteFileArgs, @CurrentFilesRequest() filesRequest: FilesRequest) { if (filesRequest.filesUser?.userRole === FilesRole.Admin || deleteFileArgs.downloadUrl.includes(`/${filesRequest.externalUserId}/`)) { return this.minioFilesService.deleteFile(deleteFileArgs.downloadUrl).pipe(map(() => ({ message: 'ok' }))); } throw new FilesError(`Only those who uploaded files can delete them`, FilesErrorEnum.FORBIDDEN); } }
Добавляем контроллер в FilesModule
, и подключаем MinioModule.forFeature
для доступа к сервисам внешнего модуля.
Обновляем файл libs/core/files/src/lib/files.module.ts
import { createNestModule, NestModuleCategory } from '@nestjs-mod/common'; import { MinioModule } from '@nestjs-mod/minio'; import { FilesController } from './controllers/files.controller'; import { FILES_FEATURE, FILES_MODULE } from './files.constants'; export const { FilesModule } = createNestModule({ moduleName: FILES_MODULE, moduleCategory: NestModuleCategory.feature, controllers: [FilesController], imports: [ MinioModule.forFeature({ featureModuleName: FILES_FEATURE, }), ], });
7. Добавляем модуль FilesModule в main.ts
Добавляем модуль FilesModule в импорты приложения, а также модифицируем внешний валидатор модуля сервера авторизации, в котором расширяем Request
-пользователя дополнительным обьектом filesUser
в котором будет храниться роль пользователя.
Обновляем файл apps/server/src/main.ts
//... import { FilesModule } from '@nestjs-mod-fullstack/files'; bootstrapNestApplication({ modules: { //... core: [ AuthorizerModule.forRootAsync({ //... configurationFactory: (webhookUsersService: WebhookUsersService) => { return { //... checkAccessValidator: async (authorizerUser?: AuthorizerUser, options?: CheckAccessOptions, ctx?: ExecutionContext) => { //... if (ctx && authorizerUser?.id) { const req: WebhookRequest & FilesRequest = getRequestFromExecutionContext(ctx); //... req.externalTenantId = webhookUser.externalTenantId; // files req.filesUser = { userRole: authorizerUser.roles?.includes('admin') ? FilesRole.Admin : FilesRole.User, }; } return result; }, }; }, }), FilesModule.forRoot(), //... ], //... }, //... });
8. Добавляем весь необходимый код в Angular-библиотеку по работе с файлами
Отправка файлов происходит на адрес полученный с нашего бэкенда.
Файл можно как загрузить так и удалить, удаление происходит с помощью запроса на наш бекенд.
Все необходимые методы для работы с нашим бэкендом и сервером авторизации создаем в сервисе FilesService
.
Создаем файл libs/core/files-angular/src/lib/services/files.service.ts
import { Inject, Injectable, InjectionToken } from '@angular/core'; import { FilesRestService } from '@nestjs-mod-fullstack/app-angular-rest-sdk'; import { PresignedUrls } from '@nestjs-mod-fullstack/app-rest-sdk'; import { Observable, from, map, mergeMap, of } from 'rxjs'; export const MINIO_URL = new InjectionToken<string>('MinioURL'); @Injectable({ providedIn: 'root' }) export class FilesService { constructor( @Inject(MINIO_URL) private readonly minioURL: string, private readonly filesRestService: FilesRestService ) {} getPresignedUrlAndUploadFile(file: null | undefined | string | File) { if (!file) { return of(''); } if (typeof file !== 'string') { return this.getPresignedUrl(file).pipe( mergeMap((presignedUrls) => this.uploadFile({ file, presignedUrls, }) ), map((presignedUrls) => presignedUrls.downloadUrl.replace(this.minioURL, '')) ); } return of(file.replace(this.minioURL, '')); } getPresignedUrl(file: File) { return from(this.filesRestService.filesControllerGetPresignedUrl(this.getFileExt(file))); } uploadFile({ file, presignedUrls }: { file: File; presignedUrls: PresignedUrls }) { return new Observable<PresignedUrls>((observer) => { const outPresignedUrls: PresignedUrls = { downloadUrl: this.minioURL + presignedUrls.downloadUrl, uploadUrl: this.minioURL + presignedUrls.uploadUrl, }; if (presignedUrls.uploadUrl) { const xhr = new XMLHttpRequest(); xhr.open('PUT', outPresignedUrls.uploadUrl); xhr.onreadystatechange = () => { if (xhr.readyState === 4) { if (xhr.status === 200) { observer.next(outPresignedUrls); observer.complete(); } else { observer.error(new Error('Error in upload file')); } } }; xhr.send(file); } else { observer.next(outPresignedUrls); observer.complete(); } }); } deleteFile(downloadUrl: string) { return from(this.filesRestService.filesControllerDeleteFile(downloadUrl)); } private getFileExt(file: File) { return file?.type?.split('/')?.[1].toLowerCase(); } }
Создаем компоненту для Formly
, которая добавит поддержку загрузки и отображения файлов с изображениями.
Создаем файл libs/core/files-angular/src/lib/formly/image-file.component.ts
import { ChangeDetectionStrategy, Component, Inject, OnInit } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { FieldType, FieldTypeConfig, FormlyModule } from '@ngx-formly/core'; import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzIconModule } from 'ng-zorro-antd/icon'; import { NzInputModule } from 'ng-zorro-antd/input'; import { NzModalModule } from 'ng-zorro-antd/modal'; import { NzUploadFile, NzUploadModule } from 'ng-zorro-antd/upload'; import { BehaviorSubject } from 'rxjs'; import { MINIO_URL } from '../services/files.service'; import { AsyncPipe } from '@angular/common'; @Component({ selector: 'image-file', imports: [ReactiveFormsModule, FormlyModule, NzInputModule, NzButtonModule, NzUploadModule, NzModalModule, NzIconModule, AsyncPipe], standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, template: ` <nz-upload [nzAccept]="'image/png, image/jpeg'" [nzListType]="'picture'" [nzFileList]="(fileList$ | async)!" (nzFileListChange)="onFileListChange($event)" [nzLimit]="1" [nzBeforeUpload]="beforeUpload"> <button nz-button type="button"> <span nz-icon [nzType]="(icon$ | async)!"></span> {{ title$ | async }} </button> </nz-upload> `, }) export class ImageFileComponent extends FieldType<FieldTypeConfig> implements OnInit { fileList$ = new BehaviorSubject<NzUploadFile[]>([]); title$ = new BehaviorSubject<string>(''); icon$ = new BehaviorSubject<string>(''); constructor( @Inject(MINIO_URL) private readonly minioURL: string ) { super(); } ngOnInit(): void { if (this.formControl.value) { this.switchToReloadMode(); this.fileList$.next([ { uid: this.formControl.value, name: this.formControl.value.split('/').at(-1), status: 'done', url: this.minioURL + this.formControl.value, }, ]); } else { this.switchToUploadMode(); } } onFileListChange(files: NzUploadFile[]) { if (files.length === 0) { this.formControl.setValue(null); this.fileList$.next([]); this.switchToUploadMode(); } } beforeUpload = (file: NzUploadFile): boolean => { this.formControl.setValue(file); this.switchToReloadMode(); this.fileList$.next([file]); return false; }; private switchToReloadMode() { this.icon$.next('reload'); this.title$.next('Change file'); } private switchToUploadMode() { this.icon$.next('upload'); this.title$.next('Select file...'); } }
Регистрируем компоненту в FormlyModule
.
Обновляем файл apps/client/src/app/app.config.ts
// .. export const appConfig = ({ authorizerURL, minioURL, }: { authorizerURL: string; minioURL: string; }): ApplicationConfig => { return { providers: [ // .. importProvidersFrom( // .. FormlyModule.forRoot({ types: [ { name: 'image-file', component: ImageFileComponent, extends: 'input', }, ], }), // .. ) // .. ]}
9. Добавляем форму и метод в сервисе для модификации профиля в Angular-модуль по авторизации
Создаем форму для отображения и редактирования профиля, в рамках данной статьи я добавляю только возможность смены пароля и загрузку изображения для пользователя сервера авторизации.
Создаем файл libs/core/auth-angular/src/lib/forms/auth-profile-form/auth-profile-form.component.ts
import { AsyncPipe, NgIf } from '@angular/common'; import { ChangeDetectionStrategy, Component, Inject, Input, OnInit, Optional } from '@angular/core'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { UpdateProfileInput } from '@authorizerdev/authorizer-js'; import { ImageFileComponent } from '@nestjs-mod-fullstack/files-angular'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core'; import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzFormModule } from 'ng-zorro-antd/form'; import { NzInputModule } from 'ng-zorro-antd/input'; import { NzMessageService } from 'ng-zorro-antd/message'; import { NZ_MODAL_DATA } from 'ng-zorro-antd/modal'; import { BehaviorSubject, catchError, of, tap } from 'rxjs'; import { AuthService } from '../../services/auth.service'; @UntilDestroy() @Component({ standalone: true, imports: [FormlyModule, NzFormModule, NzInputModule, NzButtonModule, FormsModule, ReactiveFormsModule, AsyncPipe, NgIf, RouterModule, ImageFileComponent], selector: 'auth-profile-form', template: `@if (formlyFields$ | async; as formlyFields) { <form nz-form [formGroup]="form" (ngSubmit)="submitForm()"> <formly-form [model]="formlyModel$ | async" [fields]="formlyFields" [form]="form"> </formly-form> @if (!hideButtons) { <nz-form-control> <div class="flex justify-between"> <div></div> <button nz-button nzType="primary" type="submit" [disabled]="!form.valid">Update</button> </div> </nz-form-control> } </form> } `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class AuthProfileFormComponent implements OnInit { @Input() hideButtons?: boolean; form = new UntypedFormGroup({}); formlyModel$ = new BehaviorSubject<object | null>(null); formlyFields$ = new BehaviorSubject<FormlyFieldConfig[] | null>(null); constructor( @Optional() @Inject(NZ_MODAL_DATA) private readonly nzModalData: AuthProfileFormComponent, private readonly authService: AuthService, private readonly nzMessageService: NzMessageService ) {} ngOnInit(): void { Object.assign(this, this.nzModalData); this.fillFromProfile(); } setFieldsAndModel(data: UpdateProfileInput = {}) { this.formlyFields$.next([ { key: 'picture', type: 'image-file', validation: { show: true, }, props: { label: `auth.profile-form.picture`, placeholder: 'picture', }, }, { key: 'old_password', type: 'input', validation: { show: true, }, props: { label: `auth.profile-form.old_password`, placeholder: 'old_password', type: 'password', }, }, { key: 'new_password', type: 'input', validation: { show: true, }, props: { label: `auth.profile-form.new_password`, placeholder: 'new_password', type: 'password', }, }, { key: 'confirm_new_password', type: 'input', validation: { show: true, }, props: { label: `auth.profile-form.confirm_new_password`, placeholder: 'confirm_new_password', type: 'password', }, }, ]); this.formlyModel$.next(this.toModel(data)); } submitForm(): void { if (this.form.valid) { const value = this.toJson(this.form.value); this.authService .updateProfile(value) .pipe( tap(() => { this.fillFromProfile(); this.nzMessageService.success('Updated'); }), // eslint-disable-next-line @typescript-eslint/no-explicit-any catchError((err: any) => { console.error(err); this.nzMessageService.error(err.message); return of(null); }), untilDestroyed(this) ) .subscribe(); } else { console.log(this.form.controls); this.nzMessageService.warning('Validation errors'); } } private fillFromProfile() { this.setFieldsAndModel({ picture: this.authService.profile$.value?.picture || '', }); } private toModel(data: UpdateProfileInput): object | null { return { old_password: data['old_password'], new_password: data['new_password'], confirm_new_password: data['confirm_new_password'], picture: data['picture'], }; } private toJson(data: UpdateProfileInput) { return { old_password: data['old_password'], new_password: data['new_password'], confirm_new_password: data['confirm_new_password'], picture: data['picture'], }; } }
Хотя мы и видем файловое поле в форме профиля, физически модуль авторизации и файловый модуль не связаны, но при сохранении данных по профилю нам нужно предварительно загрузить файл на внешний файловый сервер и ссылку на этот файл записать в поле изображения профиля.
Связь файлового модуля и формы редактирования профиля будем докидывать с помощью дополнительных обработчиков с верхнего уровня приложения используя DI
от Angular
.
Создаем файл libs/core/auth-angular/src/lib/services/auth.configuration.ts
import { InjectionToken } from '@angular/core'; import { UpdateProfileInput, User } from '@authorizerdev/authorizer-js'; import { Observable } from 'rxjs'; export type AfterUpdateProfileEvent = { old?: User; new?: User; }; export class AuthConfiguration { constructor(options?: AuthConfiguration) { Object.assign(this, options); } beforeUpdateProfile?(data: UpdateProfileInput): Observable<UpdateProfileInput>; afterUpdateProfile?(data: AfterUpdateProfileEvent): Observable<User | undefined>; } export const AUTH_CONFIGURATION_TOKEN = new InjectionToken<string>('AUTH_CONFIGURATION_TOKEN');
Добавляем новый метод для обновления профиля в AuthService
, который будет проверять наличие дополнительных обработчиков в конфиге.
Создаем файл libs/core/auth-angular/src/lib/services/auth.configuration.ts
import { Inject, Injectable, Optional } from '@angular/core'; import { AuthToken, LoginInput, SignupInput, UpdateProfileInput, User } from '@authorizerdev/authorizer-js'; import { mapGraphqlErrors } from '@nestjs-mod-fullstack/common-angular'; import { BehaviorSubject, catchError, from, map, mergeMap, of, tap } from 'rxjs'; import { AUTH_CONFIGURATION_TOKEN, AuthConfiguration } from './auth.configuration'; import { AuthorizerService } from './authorizer.service'; @Injectable({ providedIn: 'root' }) export class AuthService { profile$ = new BehaviorSubject<User | undefined>(undefined); tokens$ = new BehaviorSubject<AuthToken | undefined>(undefined); constructor( private readonly authorizerService: AuthorizerService, @Optional() @Inject(AUTH_CONFIGURATION_TOKEN) private readonly authConfiguration?: AuthConfiguration ) {} // .. updateProfile(data: UpdateProfileInput) { const oldProfile = this.profile$.value; return (this.authConfiguration?.beforeUpdateProfile ? this.authConfiguration.beforeUpdateProfile(data) : of(data)).pipe( mergeMap((data) => from( this.authorizerService.updateProfile({ ...data, }) ) ), mapGraphqlErrors(), mergeMap(() => this.authorizerService.getProfile()), mapGraphqlErrors(), tap((result) => this.setProfile(result)), mergeMap((updatedProfile) => this.authConfiguration?.afterUpdateProfile ? this.authConfiguration.afterUpdateProfile({ new: updatedProfile, old: oldProfile, }) : of({ new: updatedProfile, }) ) ); } // .. }
10. Описываем и подключаем интеграцию модуля авторизации и файлового модуля
Создаем файл apps/client/src/app/integrations/auth.configuration.ts
import { Provider } from '@angular/core'; import { UpdateProfileInput } from '@authorizerdev/authorizer-js'; import { AfterUpdateProfileEvent, AUTH_CONFIGURATION_TOKEN, AuthConfiguration } from '@nestjs-mod-fullstack/auth-angular'; import { FilesService } from '@nestjs-mod-fullstack/files-angular'; import { map, Observable, of } from 'rxjs'; export class AppAuthConfiguration implements AuthConfiguration { constructor(private readonly filesService: FilesService) {} beforeUpdateProfile(data: UpdateProfileInput): Observable<UpdateProfileInput> { if (data.picture) { return this.filesService.getPresignedUrlAndUploadFile(data.picture).pipe( map((picture) => { return { ...data, picture, }; }) ); } return of({ ...data, picture: '' }); } afterUpdateProfile(event: AfterUpdateProfileEvent) { if (event.old?.picture && event.new?.picture !== event.old.picture) { return this.filesService.deleteFile(event.old.picture).pipe(map(() => event.new)); } return of(event.new); } } export function provideAppAuthConfiguration(): Provider { return { provide: AUTH_CONFIGURATION_TOKEN, useClass: AppAuthConfiguration, deps: [FilesService], }; }
Подключаем интеграцию в конфиг Angular
-приложения.
Обновляем файл apps/client/src/app/app.config.ts
export const appConfig = ({ authorizerURL, minioURL, }: { authorizerURL: string; minioURL: string; }): ApplicationConfig => { return { providers: [ // .. provideAppAuthConfiguration(), // .. ] }
11. Обновляем файлы и добавляем новые для запуска docker-compose и kubernetes
Полностью описывать изменения во всех файлах я не буду, их можно посмотреть по коммиту с изменениями для текущего поста, ниже просто добавлю обновленный docker-compose-full.yml
и его файл с переменными окружения.
Обновляем файл .docker/docker-compose-full.yml
version: '3' networks: nestjs-mod-fullstack-network: driver: 'bridge' services: nestjs-mod-fullstack-postgre-sql: image: 'bitnami/postgresql:15.5.0' container_name: 'nestjs-mod-fullstack-postgre-sql' networks: - 'nestjs-mod-fullstack-network' healthcheck: test: - 'CMD-SHELL' - 'pg_isready -U postgres' interval: '5s' timeout: '5s' retries: 5 tty: true restart: 'always' environment: POSTGRESQL_USERNAME: '${SERVER_POSTGRE_SQL_POSTGRESQL_USERNAME}' POSTGRESQL_PASSWORD: '${SERVER_POSTGRE_SQL_POSTGRESQL_PASSWORD}' POSTGRESQL_DATABASE: '${SERVER_POSTGRE_SQL_POSTGRESQL_DATABASE}' volumes: - 'nestjs-mod-fullstack-postgre-sql-volume:/bitnami/postgresql' nestjs-mod-fullstack-authorizer: image: 'lakhansamani/authorizer:1.4.4' container_name: 'nestjs-mod-fullstack-authorizer' ports: - '8000:8080' networks: - 'nestjs-mod-fullstack-network' environment: DATABASE_URL: '${SERVER_AUTHORIZER_DATABASE_URL}' DATABASE_TYPE: '${SERVER_AUTHORIZER_DATABASE_TYPE}' DATABASE_NAME: '${SERVER_AUTHORIZER_DATABASE_NAME}' ADMIN_SECRET: '${SERVER_AUTHORIZER_ADMIN_SECRET}' PORT: '${SERVER_AUTHORIZER_PORT}' AUTHORIZER_URL: '${SERVER_AUTHORIZER_URL}' COOKIE_NAME: '${SERVER_AUTHORIZER_COOKIE_NAME}' SMTP_HOST: '${SERVER_AUTHORIZER_SMTP_HOST}' SMTP_PORT: '${SERVER_AUTHORIZER_SMTP_PORT}' SMTP_USERNAME: '${SERVER_AUTHORIZER_SMTP_USERNAME}' SMTP_PASSWORD: '${SERVER_AUTHORIZER_SMTP_PASSWORD}' SENDER_EMAIL: '${SERVER_AUTHORIZER_SENDER_EMAIL}' SENDER_NAME: '${SERVER_AUTHORIZER_SENDER_NAME}' DISABLE_PLAYGROUND: '${SERVER_AUTHORIZER_DISABLE_PLAYGROUND}' ACCESS_TOKEN_EXPIRY_TIME: '${SERVER_AUTHORIZER_ACCESS_TOKEN_EXPIRY_TIME}' DISABLE_STRONG_PASSWORD: '${SERVER_AUTHORIZER_DISABLE_STRONG_PASSWORD}' DISABLE_EMAIL_VERIFICATION: '${SERVER_AUTHORIZER_DISABLE_EMAIL_VERIFICATION}' ORGANIZATION_NAME: '${SERVER_AUTHORIZER_ORGANIZATION_NAME}' IS_SMS_SERVICE_ENABLED: '${SERVER_AUTHORIZER_IS_SMS_SERVICE_ENABLED}' IS_EMAIL_SERVICE_ENABLED: '${SERVER_AUTHORIZER_IS_SMS_SERVICE_ENABLED}' ENV: '${SERVER_AUTHORIZER_ENV}' RESET_PASSWORD_URL: '${SERVER_AUTHORIZER_RESET_PASSWORD_URL}' ROLES: '${SERVER_AUTHORIZER_ROLES}' DEFAULT_ROLES: '${SERVER_AUTHORIZER_DEFAULT_ROLES}' JWT_ROLE_CLAIM: '${SERVER_AUTHORIZER_JWT_ROLE_CLAIM}' ORGANIZATION_LOGO: '${SERVER_AUTHORIZER_ORGANIZATION_LOGO}' tty: true restart: 'always' depends_on: nestjs-mod-fullstack-postgre-sql: condition: service_healthy nestjs-mod-fullstack-postgre-sql-migrations: condition: service_completed_successfully nestjs-mod-fullstack-minio: image: 'bitnami/minio:2024.11.7' container_name: 'nestjs-mod-fullstack-minio' volumes: - 'nestjs-mod-fullstack-minio-volume:/bitnami/minio/data' ports: - '9000:9000' - '9001:9001' networks: - 'nestjs-mod-fullstack-network' environment: MINIO_ROOT_USER: '${SERVER_MINIO_MINIO_ROOT_USER}' MINIO_ROOT_PASSWORD: '${SERVER_MINIO_MINIO_ROOT_PASSWORD}' healthcheck: test: - 'CMD-SHELL' - 'mc' - 'ready' - 'local' interval: '5s' timeout: '5s' retries: 5 tty: true restart: 'always' nestjs-mod-fullstack-postgre-sql-migrations: image: 'ghcr.io/nestjs-mod/nestjs-mod-fullstack-migrations:${ROOT_VERSION}' container_name: 'nestjs-mod-fullstack-postgre-sql-migrations' networks: - 'nestjs-mod-fullstack-network' tty: true environment: NX_SKIP_NX_CACHE: 'true' SERVER_ROOT_DATABASE_URL: '${SERVER_ROOT_DATABASE_URL}' SERVER_APP_DATABASE_URL: '${SERVER_APP_DATABASE_URL}' SERVER_WEBHOOK_DATABASE_URL: '${SERVER_WEBHOOK_DATABASE_URL}' SERVER_AUTHORIZER_DATABASE_URL: '${SERVER_AUTHORIZER_DATABASE_URL}' depends_on: nestjs-mod-fullstack-postgre-sql: condition: 'service_healthy' working_dir: '/usr/src/app' volumes: - './../apps:/usr/src/app/apps' - './../libs:/usr/src/app/libs' nestjs-mod-fullstack-server: image: 'ghcr.io/nestjs-mod/nestjs-mod-fullstack-server:${SERVER_VERSION}' container_name: 'nestjs-mod-fullstack-server' networks: - 'nestjs-mod-fullstack-network' extra_hosts: - 'host.docker.internal:host-gateway' healthcheck: test: ['CMD-SHELL', 'npx -y wait-on --timeout= --interval=1000 --window --verbose --log http://localhost:${SERVER_PORT}/api/health'] interval: 30s timeout: 10s retries: 10 tty: true environment: NODE_TLS_REJECT_UNAUTHORIZED: '0' SERVER_PORT: '${SERVER_PORT}' SERVER_APP_DATABASE_URL: '${SERVER_APP_DATABASE_URL}' SERVER_WEBHOOK_DATABASE_URL: '${SERVER_WEBHOOK_DATABASE_URL}' SERVER_WEBHOOK_SUPER_ADMIN_EXTERNAL_USER_ID: '${SERVER_WEBHOOK_SUPER_ADMIN_EXTERNAL_USER_ID}' SERVER_AUTH_ADMIN_EMAIL: '${SERVER_AUTH_ADMIN_EMAIL}' SERVER_AUTH_ADMIN_USERNAME: '${SERVER_AUTH_ADMIN_USERNAME}' SERVER_AUTH_ADMIN_PASSWORD: '${SERVER_AUTH_ADMIN_PASSWORD}' SERVER_AUTHORIZER_URL: '${SERVER_AUTHORIZER_URL}' SERVER_AUTHORIZER_REDIRECT_URL: '${SERVER_AUTHORIZER_REDIRECT_URL}' SERVER_AUTHORIZER_AUTHORIZER_URL: '${SERVER_AUTHORIZER_AUTHORIZER_URL}' SERVER_AUTHORIZER_ADMIN_SECRET: '${SERVER_AUTHORIZER_ADMIN_SECRET}' SERVER_MINIO_SERVER_HOST: '${SERVER_MINIO_SERVER_HOST}' SERVER_MINIO_ACCESS_KEY: '${SERVER_MINIO_ACCESS_KEY}' SERVER_MINIO_SECRET_KEY: '${SERVER_MINIO_SECRET_KEY}' restart: 'always' depends_on: nestjs-mod-fullstack-postgre-sql: condition: service_healthy nestjs-mod-fullstack-postgre-sql-migrations: condition: service_completed_successfully nestjs-mod-fullstack-nginx: image: 'ghcr.io/nestjs-mod/nestjs-mod-fullstack-nginx:${CLIENT_VERSION}' container_name: 'nestjs-mod-fullstack-nginx' networks: - 'nestjs-mod-fullstack-network' healthcheck: test: ['CMD-SHELL', 'curl -so /dev/null http://localhost:${NGINX_PORT} || exit 1'] interval: 30s timeout: 10s retries: 10 environment: SERVER_PORT: '${SERVER_PORT}' NGINX_PORT: '${NGINX_PORT}' CLIENT_AUTHORIZER_URL: '${CLIENT_AUTHORIZER_URL}' CLIENT_MINIO_URL: '${CLIENT_MINIO_URL}' CLIENT_WEBHOOK_SUPER_ADMIN_EXTERNAL_USER_ID: '${CLIENT_WEBHOOK_SUPER_ADMIN_EXTERNAL_USER_ID}' restart: 'always' depends_on: nestjs-mod-fullstack-server: condition: service_healthy ports: - '${NGINX_PORT}:${NGINX_PORT}' nestjs-mod-fullstack-e2e-tests: image: 'ghcr.io/nestjs-mod/nestjs-mod-fullstack-e2e-tests:${ROOT_VERSION}' container_name: 'nestjs-mod-fullstack-e2e-tests' networks: - 'nestjs-mod-fullstack-network' environment: IS_DOCKER_COMPOSE: 'true' BASE_URL: 'http://nestjs-mod-fullstack-nginx:${NGINX_PORT}' SERVER_AUTHORIZER_URL: 'http://nestjs-mod-fullstack-authorizer:8080' SERVER_URL: 'http://nestjs-mod-fullstack-server:8080' SERVER_AUTH_ADMIN_EMAIL: '${SERVER_AUTH_ADMIN_EMAIL}' SERVER_AUTH_ADMIN_USERNAME: '${SERVER_AUTH_ADMIN_USERNAME}' SERVER_AUTH_ADMIN_PASSWORD: '${SERVER_AUTH_ADMIN_PASSWORD}' SERVER_AUTHORIZER_ADMIN_SECRET: '${SERVER_AUTHORIZER_ADMIN_SECRET}' depends_on: nestjs-mod-fullstack-nginx: condition: service_healthy working_dir: '/usr/src/app' volumes: - './../apps:/usr/src/app/apps' - './../libs:/usr/src/app/libs' nestjs-mod-fullstack-https-portal: image: steveltn/https-portal:1 container_name: 'nestjs-mod-fullstack-https-portal' networks: - 'nestjs-mod-fullstack-network' ports: - '80:80' - '443:443' links: - nestjs-mod-fullstack-nginx restart: always environment: STAGE: '${HTTPS_PORTAL_STAGE}' DOMAINS: '${SERVER_DOMAIN} -> http://nestjs-mod-fullstack-nginx:${NGINX_PORT}' depends_on: nestjs-mod-fullstack-nginx: condition: service_healthy volumes: - nestjs-mod-fullstack-https-portal-volume:/var/lib/https-portal volumes: nestjs-mod-fullstack-postgre-sql-volume: name: 'nestjs-mod-fullstack-postgre-sql-volume' nestjs-mod-fullstack-https-portal-volume: name: 'nestjs-mod-fullstack-https-portal-volume' nestjs-mod-fullstack-minio-volume: name: 'nestjs-mod-fullstack-minio-volume'
Обновляем файл .docker/docker-compose-full.env
SERVER_PORT=9090 NGINX_PORT=8080 SERVER_ROOT_DATABASE_URL=postgres://postgres:postgres_password@nestjs-mod-fullstack-postgre-sql:5432/postgres?schema=public SERVER_APP_DATABASE_URL=postgres://app:app_password@nestjs-mod-fullstack-postgre-sql:5432/app?schema=public SERVER_WEBHOOK_DATABASE_URL=postgres://webhook:webhook_password@nestjs-mod-fullstack-postgre-sql:5432/webhook?schema=public SERVER_POSTGRE_SQL_POSTGRESQL_USERNAME=postgres SERVER_POSTGRE_SQL_POSTGRESQL_PASSWORD=postgres_password SERVER_POSTGRE_SQL_POSTGRESQL_DATABASE=postgres SERVER_DOMAIN=example.com HTTPS_PORTAL_STAGE=local # local|stage|production CLIENT_AUTHORIZER_URL=http://localhost:8000 CLIENT_MINIO_URL=http://localhost:9000 SERVER_AUTHORIZER_REDIRECT_URL=http://localhost:8080 SERVER_AUTH_ADMIN_EMAIL=nestjs-mod-fullstack@site15.ru SERVER_AUTH_ADMIN_USERNAME=admin SERVER_AUTH_ADMIN_PASSWORD=SbxcbII7RUvCOe9TDXnKhfRrLJW5cGDA SERVER_URL=http://localhost:9090/api SERVER_AUTHORIZER_URL=http://localhost:8000 SERVER_AUTHORIZER_ADMIN_SECRET=VfKSfPPljhHBXCEohnitursmgDxfAyiD SERVER_AUTHORIZER_DATABASE_TYPE=postgres SERVER_AUTHORIZER_DATABASE_URL=postgres://Yk42KA4sOb:B7Ep2MwlRR6fAx0frXGWVTGP850qAxM6@nestjs-mod-fullstack-postgre-sql:5432/authorizer SERVER_AUTHORIZER_DATABASE_NAME=authorizer SERVER_AUTHORIZER_PORT=8080 SERVER_AUTHORIZER_AUTHORIZER_URL=http://nestjs-mod-fullstack-authorizer:8080 SERVER_AUTHORIZER_COOKIE_NAME=authorizer SERVER_AUTHORIZER_DISABLE_PLAYGROUND=true SERVER_AUTHORIZER_ACCESS_TOKEN_EXPIRY_TIME=30m SERVER_AUTHORIZER_DISABLE_STRONG_PASSWORD=true SERVER_AUTHORIZER_DISABLE_EMAIL_VERIFICATION=true SERVER_AUTHORIZER_ORGANIZATION_NAME=NestJSModFullstack SERVER_AUTHORIZER_IS_EMAIL_SERVICE_ENABLED=true SERVER_AUTHORIZER_IS_SMS_SERVICE_ENABLED=false SERVER_AUTHORIZER_RESET_PASSWORD_URL=/reset-password SERVER_AUTHORIZER_ROLES=user,admin SERVER_AUTHORIZER_DEFAULT_ROLES=user SERVER_AUTHORIZER_JWT_ROLE_CLAIM=role SERVER_MINIO_SERVER_HOST=nestjs-mod-fullstack-minio SERVER_MINIO_ACCESS_KEY=FWGmrAGaeMKM SERVER_MINIO_SECRET_KEY=QatVJuLoZRARlJguoZMpoKvZMJHzvuOR SERVER_MINIO_ROOT_USER=FWGmrAGaeMKM SERVER_MINIO_ROOT_PASSWORD=QatVJuLoZRARlJguoZMpoKvZMJHzvuOR SERVER_MINIO_MINIO_ROOT_USER=FWGmrAGaeMKM SERVER_MINIO_MINIO_ROOT_PASSWORD=QatVJuLoZRARlJguoZMpoKvZMJHzvuOR
12. Создаем E2E-тест для проверки обновления профиля и загрузки файла на сервер
Создаем файл apps/client-e2e/src/profile-as-user.spec.ts
import { faker } from '@faker-js/faker'; import { expect, Page, test } from '@playwright/test'; import { get } from 'env-var'; import { join } from 'path'; import { setTimeout } from 'timers/promises'; test.describe('Work with profile as "User" role', () => { test.describe.configure({ mode: 'serial' }); const user = { email: faker.internet.email({ provider: 'example.fakerjs.dev', }), password: faker.internet.password({ length: 8 }), site: `http://${faker.internet.domainName()}`, }; let page: Page; test.beforeAll(async ({ browser }) => { page = await browser.newPage({ viewport: { width: 1920, height: 1080 }, recordVideo: { dir: join(__dirname, 'video'), size: { width: 1920, height: 1080 }, }, }); await page.goto('/', { timeout: 7000, }); await page.evaluate((authorizerURL) => localStorage.setItem('authorizerURL', authorizerURL), get('SERVER_AUTHORIZER_URL').required().asString()); }); test.afterAll(async () => { await setTimeout(1000); await page.close(); }); test('sign up as user', async () => { await page.goto('/sign-up', { timeout: 7000, }); await page.locator('auth-sign-up-form').locator('[placeholder=email]').click(); await page.keyboard.type(user.email.toLowerCase(), { delay: 50, }); await expect(page.locator('auth-sign-up-form').locator('[placeholder=email]')).toHaveValue(user.email.toLowerCase()); await page.locator('auth-sign-up-form').locator('[placeholder=password]').click(); await page.keyboard.type(user.password, { delay: 50, }); await expect(page.locator('auth-sign-up-form').locator('[placeholder=password]')).toHaveValue(user.password); await page.locator('auth-sign-up-form').locator('[placeholder=confirm_password]').click(); await page.keyboard.type(user.password, { delay: 50, }); await expect(page.locator('auth-sign-up-form').locator('[placeholder=confirm_password]')).toHaveValue(user.password); await expect(page.locator('auth-sign-up-form').locator('button[type=submit]')).toHaveText('Sign-up'); await page.locator('auth-sign-up-form').locator('button[type=submit]').click(); await setTimeout(1500); await expect(page.locator('nz-header').locator('[nz-submenu]')).toContainText(`You are logged in as ${user.email.toLowerCase()}`); }); test('sign out after sign-up', async () => { await expect(page.locator('nz-header').locator('[nz-submenu]')).toContainText(`You are logged in as ${user.email.toLowerCase()}`); await page.locator('nz-header').locator('[nz-submenu]').first().click(); await expect(page.locator('[nz-submenu-none-inline-child]').locator('[nz-menu-item]').last()).toContainText(`Sign-out`); await page.locator('[nz-submenu-none-inline-child]').locator('[nz-menu-item]').last().click(); await setTimeout(4000); await expect(page.locator('nz-header').locator('[nz-menu-item]').last()).toContainText(`Sign-in`); }); test('sign in as user', async () => { await page.goto('/sign-in', { timeout: 7000, }); await page.locator('auth-sign-in-form').locator('[placeholder=email]').click(); await page.keyboard.type(user.email.toLowerCase(), { delay: 50, }); await expect(page.locator('auth-sign-in-form').locator('[placeholder=email]')).toHaveValue(user.email.toLowerCase()); await page.locator('auth-sign-in-form').locator('[placeholder=password]').click(); await page.keyboard.type(user.password, { delay: 50, }); await expect(page.locator('auth-sign-in-form').locator('[placeholder=password]')).toHaveValue(user.password); await expect(page.locator('auth-sign-in-form').locator('button[type=submit]')).toHaveText('Sign-in'); await page.locator('auth-sign-in-form').locator('button[type=submit]').click(); await setTimeout(1500); await expect(page.locator('nz-header').locator('[nz-submenu]')).toContainText(`You are logged in as ${user.email.toLowerCase()}`); }); test('should change password in profile', async () => { await expect(page.locator('nz-header').locator('[nz-submenu]')).toContainText(`You are logged in as ${user.email.toLowerCase()}`); await page.locator('nz-header').locator('[nz-submenu]').first().click(); await expect(page.locator('[nz-submenu-none-inline-child]').locator('[nz-menu-item]').first()).toContainText(`Profile`); await page.locator('[nz-submenu-none-inline-child]').locator('[nz-menu-item]').first().click(); await setTimeout(4000); // await page.locator('auth-profile-form').locator('[placeholder=old_password]').click(); await page.keyboard.type(user.password, { delay: 50, }); await expect(page.locator('auth-profile-form').locator('[placeholder=old_password]')).toHaveValue(user.password); await page.locator('auth-profile-form').locator('[placeholder=new_password]').click(); await page.keyboard.type(user.password + user.password, { delay: 50, }); await expect(page.locator('auth-profile-form').locator('[placeholder=new_password]')).toHaveValue(user.password + user.password); await page.locator('auth-profile-form').locator('[placeholder=confirm_new_password]').click(); await page.keyboard.type(user.password + user.password, { delay: 50, }); await expect(page.locator('auth-profile-form').locator('[placeholder=confirm_new_password]')).toHaveValue(user.password + user.password); const fileChooserPromise = page.waitForEvent('filechooser'); page.locator('nz-upload').locator('button').click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles(join(__dirname, 'dep.jpg')); await setTimeout(1000); await expect(page.locator('auth-profile-form').locator('button[type=submit]')).toHaveText('Update'); await page.locator('auth-profile-form').locator('button[type=submit]').click(); await setTimeout(1500); }); test('sign out', async () => { await expect(page.locator('nz-header').locator('[nz-submenu]')).toContainText(`You are logged in as ${user.email.toLowerCase()}`); await page.locator('nz-header').locator('[nz-submenu]').first().click(); await expect(page.locator('[nz-submenu-none-inline-child]').locator('[nz-menu-item]').last()).toContainText(`Sign-out`); await page.locator('[nz-submenu-none-inline-child]').locator('[nz-menu-item]').last().click(); await setTimeout(4000); await expect(page.locator('nz-header').locator('[nz-menu-item]').last()).toContainText(`Sign-in`); }); test('sign in as user with new password', async () => { await page.goto('/sign-in', { timeout: 7000, }); await page.locator('auth-sign-in-form').locator('[placeholder=email]').click(); await page.keyboard.type(user.email.toLowerCase(), { delay: 50, }); await expect(page.locator('auth-sign-in-form').locator('[placeholder=email]')).toHaveValue(user.email.toLowerCase()); await page.locator('auth-sign-in-form').locator('[placeholder=password]').click(); await page.keyboard.type(user.password + user.password, { delay: 50, }); await expect(page.locator('auth-sign-in-form').locator('[placeholder=password]')).toHaveValue(user.password + user.password); await expect(page.locator('auth-sign-in-form').locator('button[type=submit]')).toHaveText('Sign-in'); await page.locator('auth-sign-in-form').locator('button[type=submit]').click(); await setTimeout(1500); await expect(page.locator('nz-header').locator('[nz-submenu]')).toContainText(`You are logged in as ${user.email.toLowerCase()}`); }); });
Заключение
В данном посте и проекте конфигурация для файлового сервера Minio
написана без учета больших нагрузок и без репликаций, все описывается в качестве примера, при деплое в реальный продакшен нужно будет почитать дополнительный материал.
Текущий проект использует одного админа файлового сервера Minio
для всех пользователей приложения, если вам не потходит такой вариант, то вы можете взять в качестве сервера авторизации Keycloak
, и настроить его связку с Minio
для использования общих пользователей.
Планы
В следующем посте я подключу к проекту Redis
и настрою кэширование информации о профиле пользователя…
Ссылки
-
https://nestjs.com — официальный сайт фреймворка
-
https://nestjs-mod.com — официальный сайт дополнительных утилит
-
https://fullstack.nestjs-mod.com — сайт из поста
-
https://github.com/nestjs-mod/nestjs-mod-fullstack — проект из поста
ссылка на оригинал статьи https://habr.com/ru/articles/859306/
Добавить комментарий