From 4cf444ab426d9e4a2983b9e495d7cd6b21afeed3 Mon Sep 17 00:00:00 2001 From: "saito.k" Date: Wed, 17 May 2023 00:38:39 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=2098:=20API=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=EF=BC=88SAS=E3=83=88=E3=83=BC=E3=82=AF=E3=83=B3=E7=99=BA?= =?UTF-8?q?=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task1737: API実装(SASトークン発行)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/1737) - SASトークンを発行する処理を実装 - コンテナ作成処理を実装 - 本来はアカウント登録時に作成されるが、動作検証のためSAS発行時に作成するように実装している - コンテナ存在確認の処理を実装 ## レビューポイント - 実装した処理に漏れはないか - エラーの処理に考慮漏れはないか - テストケースは十分か ## 動作確認状況 - ローカルでコンテナ作成とURL発行を確認 ## 補足 - 相談、参考資料などがあれば --- dictation_server/.env | 2 +- dictation_server/.env.local.example | 12 +- dictation_server/package-lock.json | 136 +++++++++++++- dictation_server/package.json | 1 + dictation_server/src/app.module.ts | 2 + dictation_server/src/constants/index.ts | 54 ++++++ .../src/features/auth/auth.service.ts | 3 +- .../src/features/files/files.controller.ts | 20 ++- .../src/features/files/files.module.ts | 3 + .../src/features/files/files.service.spec.ts | 74 ++++++-- .../src/features/files/files.service.ts | 72 +++++++- .../features/files/test/files.service.mock.ts | 116 ++++++++++++ .../blobstorage/blobstorage.module.ts | 10 ++ .../blobstorage/blobstorage.service.ts | 169 ++++++++++++++++++ .../users/users.repository.service.ts | 4 +- 15 files changed, 651 insertions(+), 27 deletions(-) create mode 100644 dictation_server/src/features/files/test/files.service.mock.ts create mode 100644 dictation_server/src/gateways/blobstorage/blobstorage.module.ts create mode 100644 dictation_server/src/gateways/blobstorage/blobstorage.service.ts diff --git a/dictation_server/.env b/dictation_server/.env index 8d0796d..a362c4e 100644 --- a/dictation_server/.env +++ b/dictation_server/.env @@ -12,4 +12,4 @@ REFRESH_TOKEN_LIFETIME_DEFAULT=2592000000 TENANT_NAME=adb2codmsdev SIGNIN_FLOW_NAME=b2c_1_signin_dev EMAIL_CONFIRM_LIFETIME=86400000 -APP_DOMAIN=https://10.1.0.10:4443/ \ No newline at end of file +APP_DOMAIN=https://10.1.0.10:4443/ diff --git a/dictation_server/.env.local.example b/dictation_server/.env.local.example index e721623..64e244c 100644 --- a/dictation_server/.env.local.example +++ b/dictation_server/.env.local.example @@ -15,4 +15,14 @@ SENDGRID_API_KEY=xxxxxxxxxxxxxxxx MAIL_FROM=xxxxx@xxxxx.xxxx NOTIFICATION_HUB_NAME=ntf-odms-shared NOTIFICATION_HUB_CONNECT_STRING=XXXXXXXXXXXXXXXXXX -APP_DOMAIN=http://localhost:8081/ \ No newline at end of file +APP_DOMAIN=http://localhost:8081/ +STORAGE_TOKEN_EXPIRE_TIME=30 +STORAGE_ACCOUNT_NAME_US=saodmsusdev +STORAGE_ACCOUNT_NAME_AU=saodmsaudev +STORAGE_ACCOUNT_NAME_EU=saodmseudev +STORAGE_ACCOUNT_KEY_US=XXXXXXXXXXXXXXXXXXXXXXX +STORAGE_ACCOUNT_KEY_AU=XXXXXXXXXXXXXXXXXXXXXXX +STORAGE_ACCOUNT_KEY_EU=XXXXXXXXXXXXXXXXXXXXXXX +STORAGE_ACCOUNT_ENDPOINT_US=https://AAAAAAAAAAAAA +STORAGE_ACCOUNT_ENDPOINT_AU=https://AAAAAAAAAAAAA +STORAGE_ACCOUNT_ENDPOINT_EU=https://AAAAAAAAAAAAA diff --git a/dictation_server/package-lock.json b/dictation_server/package-lock.json index 9c8eb58..051c0e2 100644 --- a/dictation_server/package-lock.json +++ b/dictation_server/package-lock.json @@ -12,6 +12,7 @@ "@azure/identity": "^3.1.3", "@azure/keyvault-secrets": "^4.6.0", "@azure/notification-hubs": "^1.0.1", + "@azure/storage-blob": "^12.14.0", "@microsoft/microsoft-graph-client": "^3.0.5", "@nestjs/axios": "^0.1.0", "@nestjs/common": "^9.3.12", @@ -375,6 +376,30 @@ "node": ">=14.0.0" } }, + "node_modules/@azure/core-http": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@azure/core-http/-/core-http-3.0.1.tgz", + "integrity": "sha512-A3x+um3cAPgQe42Lu7Iv/x8/fNjhL/nIoEfqFxfn30EyxK6zC13n+OUxzZBRC0IzQqssqIbt4INf5YG7lYYFtw==", + "dependencies": { + "@azure/abort-controller": "^1.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-tracing": "1.0.0-preview.13", + "@azure/core-util": "^1.1.1", + "@azure/logger": "^1.0.0", + "@types/node-fetch": "^2.5.0", + "@types/tunnel": "^0.0.3", + "form-data": "^4.0.0", + "node-fetch": "^2.6.7", + "process": "^0.11.10", + "tslib": "^2.2.0", + "tunnel": "^0.0.6", + "uuid": "^8.3.0", + "xml2js": "^0.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@azure/core-http-compat": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-1.3.0.tgz", @@ -388,6 +413,30 @@ "node": ">=12.0.0" } }, + "node_modules/@azure/core-http/node_modules/@azure/core-tracing": { + "version": "1.0.0-preview.13", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.0.0-preview.13.tgz", + "integrity": "sha512-KxDlhXyMlh2Jhj2ykX6vNEU0Vou4nHr025KoSEiz7cS3BNiHNaZcdECk/DmLkEB0as5T7b/TpRcehJ5yV6NeXQ==", + "dependencies": { + "@opentelemetry/api": "^1.0.1", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@azure/core-http/node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/@azure/core-lro": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.5.1.tgz", @@ -602,6 +651,36 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/@azure/storage-blob": { + "version": "12.14.0", + "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.14.0.tgz", + "integrity": "sha512-g8GNUDpMisGXzBeD+sKphhH5yLwesB4JkHr1U6be/X3F+cAMcyGLPD1P89g2M7wbEtUJWoikry1rlr83nNRBzg==", + "dependencies": { + "@azure/abort-controller": "^1.0.0", + "@azure/core-http": "^3.0.0", + "@azure/core-lro": "^2.2.0", + "@azure/core-paging": "^1.1.1", + "@azure/core-tracing": "1.0.0-preview.13", + "@azure/logger": "^1.0.0", + "events": "^3.0.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@azure/storage-blob/node_modules/@azure/core-tracing": { + "version": "1.0.0-preview.13", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.0.0-preview.13.tgz", + "integrity": "sha512-KxDlhXyMlh2Jhj2ykX6vNEU0Vou4nHr025KoSEiz7cS3BNiHNaZcdECk/DmLkEB0as5T7b/TpRcehJ5yV6NeXQ==", + "dependencies": { + "@opentelemetry/api": "^1.0.1", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", @@ -2486,6 +2565,14 @@ "openapi-generator": "bin/openapi-generator" } }, + "node_modules/@opentelemetry/api": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz", + "integrity": "sha512-O2yRJce1GOc6PAy3QxFM4NzFiWzvScDC1/5ihYBL6BUEVdq0XMWN01sppE+H6bBXbaFYipjwFLEWLg5PaSOThA==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@redis/bloom": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", @@ -2854,8 +2941,29 @@ "node_modules/@types/node": { "version": "16.18.21", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.21.tgz", - "integrity": "sha512-TassPGd0AEZWA10qcNnXnSNwHlLfSth8XwUaWc3gTSDmBz/rKb613Qw5qRf6o2fdRBrLbsgeC9PMZshobkuUqg==", - "devOptional": true + "integrity": "sha512-TassPGd0AEZWA10qcNnXnSNwHlLfSth8XwUaWc3gTSDmBz/rKb613Qw5qRf6o2fdRBrLbsgeC9PMZshobkuUqg==" + }, + "node_modules/@types/node-fetch": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.3.tgz", + "integrity": "sha512-ETTL1mOEdq/sxUtgtOhKjyB2Irra4cjxksvcMUR5Zr4n+PxVhsCD9WS46oPbHL3et9Zde7CNRr+WUNlcHvsX+w==", + "dependencies": { + "@types/node": "*", + "form-data": "^3.0.0" + } + }, + "node_modules/@types/node-fetch/node_modules/form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } }, "node_modules/@types/parse-json": { "version": "4.0.0", @@ -2922,6 +3030,14 @@ "@types/superagent": "*" } }, + "node_modules/@types/tunnel": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/tunnel/-/tunnel-0.0.3.tgz", + "integrity": "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/uuid": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", @@ -8582,6 +8698,14 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -10051,6 +10175,14 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/dictation_server/package.json b/dictation_server/package.json index e6f383f..5a738d6 100644 --- a/dictation_server/package.json +++ b/dictation_server/package.json @@ -29,6 +29,7 @@ "@azure/identity": "^3.1.3", "@azure/keyvault-secrets": "^4.6.0", "@azure/notification-hubs": "^1.0.1", + "@azure/storage-blob": "^12.14.0", "@microsoft/microsoft-graph-client": "^3.0.5", "@nestjs/axios": "^0.1.0", "@nestjs/common": "^9.3.12", diff --git a/dictation_server/src/app.module.ts b/dictation_server/src/app.module.ts index 2d5dd97..356738b 100644 --- a/dictation_server/src/app.module.ts +++ b/dictation_server/src/app.module.ts @@ -28,6 +28,7 @@ import { FilesService } from './features/files/files.service'; import { TasksService } from './features/tasks/tasks.service'; import { TasksController } from './features/tasks/tasks.controller'; import { TasksModule } from './features/tasks/tasks.module'; +import { BlobstorageModule } from './gateways/blobstorage/blobstorage.module'; import { LicensesModule } from './features/licenses/licenses.module'; import { LicensesService } from './features/licenses/licenses.service'; import { LicensesController } from './features/licenses/licenses.controller'; @@ -68,6 +69,7 @@ import { LicensesController } from './features/licenses/licenses.controller'; }), NotificationModule, NotificationhubModule, + BlobstorageModule, LicensesModule, ], controllers: [ diff --git a/dictation_server/src/constants/index.ts b/dictation_server/src/constants/index.ts index a5a2b42..817059d 100644 --- a/dictation_server/src/constants/index.ts +++ b/dictation_server/src/constants/index.ts @@ -27,3 +27,57 @@ export const TIER_4 = 4; * @const {number} */ export const TIER_5 = 5; + +/** + * 音声ファイルをEast USに保存する国リスト + * @const {number} + */ +export const BLOB_STORAGE_REGION_US = ['CA', 'KY', 'US']; + +/** + * 音声ファイルをAustralia Eastに保存する国リスト + * @const {number} + */ +export const BLOB_STORAGE_REGION_AU = ['AU', 'NZ']; + +/** + * 音声ファイルをNorth Europeに保存する国リスト + * @const {number} + */ +export const BLOB_STORAGE_REGION_EU = [ + 'AT', + 'BE', + 'BG', + 'HR', + 'CY', + 'CZ', + 'DK', + 'EE', + 'FI', + 'FR', + 'DE', + 'GR', + 'HU', + 'IS', + 'IE', + 'IT', + 'LV', + 'LI', + 'LT', + 'LU', + 'MT', + 'NL', + 'NO', + 'PL', + 'PT', + 'RO', + 'RS', + 'SK', + 'SI', + 'ZA', + 'ES', + 'SE', + 'CH', + 'TR', + 'GB', +]; diff --git a/dictation_server/src/features/auth/auth.service.ts b/dictation_server/src/features/auth/auth.service.ts index 4525b80..12eae50 100644 --- a/dictation_server/src/features/auth/auth.service.ts +++ b/dictation_server/src/features/auth/auth.service.ts @@ -71,13 +71,14 @@ export class AuthService { HttpStatus.INTERNAL_SERVER_ERROR, ); } - // 要求された環境用トークンの寿命を決定 const refreshTokenLifetime = type === 'web' ? lifetimeWeb : lifetimeDefault; const privateKey = await this.cryptoService.getPrivateKey(); + const token = sign( { + //ユーザーの属しているアカウントの管理者にユーザーが設定されていればadminをセットする role: `${user.role} ${ user.account.primary_admin_user_id === user.id || user.account.secondary_admin_user_id === user.id diff --git a/dictation_server/src/features/files/files.controller.ts b/dictation_server/src/features/files/files.controller.ts index d88dbfa..967e53a 100644 --- a/dictation_server/src/features/files/files.controller.ts +++ b/dictation_server/src/features/files/files.controller.ts @@ -8,11 +8,13 @@ import { Query, } from '@nestjs/common'; import { - ApiResponse, - ApiOperation, ApiBearerAuth, + ApiOperation, + ApiResponse, ApiTags, } from '@nestjs/swagger'; +import jwt from 'jsonwebtoken'; +import { AccessToken } from '../../common/token'; import { ErrorResponse } from '../../common/error/types/types'; import { FilesService } from './files.service'; import { @@ -89,14 +91,18 @@ export class FilesController { }) @ApiBearerAuth() async uploadLocation( - @Headers() headers, + @Headers('authorization') authorization: string, @Query() query: AudioUploadLocationRequest, ): Promise { const {} = query; - // コンテナ作成処理の前にアクセストークンの認証を行う - // アップロード先を決定する国情報はトークンから取得する想定 - - return { url: '' }; + //TODO Guardsで認証するからここではデコードするだけ + const token = authorization.substring( + 'Bearer '.length, + authorization.length, + ); + const accessToken = jwt.decode(token, { json: true }) as AccessToken; + const url = await this.filesService.publishUploadSas(accessToken); + return { url }; } @Get('audio/download-location') diff --git a/dictation_server/src/features/files/files.module.ts b/dictation_server/src/features/files/files.module.ts index 97b6f58..0b78d44 100644 --- a/dictation_server/src/features/files/files.module.ts +++ b/dictation_server/src/features/files/files.module.ts @@ -1,8 +1,11 @@ import { Module } from '@nestjs/common'; import { FilesService } from './files.service'; import { FilesController } from './files.controller'; +import { UsersRepositoryModule } from '../../repositories/users/users.repository.module'; +import { BlobstorageModule } from '../../gateways/blobstorage/blobstorage.module'; @Module({ + imports: [UsersRepositoryModule, BlobstorageModule], providers: [FilesService], controllers: [FilesController], }) diff --git a/dictation_server/src/features/files/files.service.spec.ts b/dictation_server/src/features/files/files.service.spec.ts index c653b32..1986c7b 100644 --- a/dictation_server/src/features/files/files.service.spec.ts +++ b/dictation_server/src/features/files/files.service.spec.ts @@ -1,18 +1,70 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { FilesService } from './files.service'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; +import { + makeBlobstorageServiceMockValue, + makeDefaultUsersRepositoryMockValue, + makeFilesServiceMock, +} from './test/files.service.mock'; describe('FilesService', () => { - let service: FilesService; + it('アップロードSASトークンが乗っているURLを返却する', async () => { + const blobParam = makeBlobstorageServiceMockValue(); + const userRepoParam = makeDefaultUsersRepositoryMockValue(); + const service = await makeFilesServiceMock(blobParam, userRepoParam); - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [FilesService], - }).compile(); - - service = module.get(FilesService); + expect( + await service.publishUploadSas({ + userId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', + role: 'Author', + }), + ).toEqual('https://blob-storage?sas-token'); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('アカウント専用コンテナが無い場合でも、コンテナ作成しURLを返却する', async () => { + const blobParam = makeBlobstorageServiceMockValue(); + const userRepoParam = makeDefaultUsersRepositoryMockValue(); + blobParam.containerExists = false; + + const service = await makeFilesServiceMock(blobParam, userRepoParam); + + expect( + await service.publishUploadSas({ + userId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', + role: 'Author', + }), + ).toEqual('https://blob-storage?sas-token'); + }); + + it('ユーザー情報の取得に失敗した場合、例外エラーを返却する', async () => { + const blobParam = makeBlobstorageServiceMockValue(); + const service = await makeFilesServiceMock(blobParam, { + findUserByExternalId: new Error(''), + }); + + await expect( + service.publishUploadSas({ + userId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', + role: 'Author', + }), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E009999'), HttpStatus.UNAUTHORIZED), + ); + }); + + it('コンテナ作成に失敗した場合、例外エラーを返却する', async () => { + const blobParam = makeBlobstorageServiceMockValue(); + const service = await makeFilesServiceMock(blobParam, { + findUserByExternalId: new Error(''), + }); + blobParam.publishUploadSas = new Error('Azure service down'); + + await expect( + service.publishUploadSas({ + userId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', + role: 'Author', + }), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E009999'), HttpStatus.UNAUTHORIZED), + ); }); }); diff --git a/dictation_server/src/features/files/files.service.ts b/dictation_server/src/features/files/files.service.ts index ab8a4e6..71154c3 100644 --- a/dictation_server/src/features/files/files.service.ts +++ b/dictation_server/src/features/files/files.service.ts @@ -1,4 +1,72 @@ -import { Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; +import { AccessToken } from '../../common/token'; +import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; +import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service'; @Injectable() -export class FilesService {} +export class FilesService { + private readonly logger = new Logger(FilesService.name); + constructor( + private readonly usersRepository: UsersRepositoryService, + private readonly blobStorageService: BlobstorageService, + ) {} + /** + * Publishs upload sas + * @param companyName + * @returns upload sas + */ + async publishUploadSas(token: AccessToken): Promise { + //DBから国情報とアカウントIDを取得する + let accountId: number; + let country: string; + try { + const user = await this.usersRepository.findUserByExternalId( + token.userId, + ); + if (user.account) { + accountId = user.account.id; + country = user.account.country; + } + } catch (e) { + this.logger.error(`error=${e}`); + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + try { + // 国に応じたリージョンのBlobストレージにコンテナが存在するか確認 + const isContainerExist = await this.blobStorageService.containerExists( + accountId, + country, + ); + //TODO コンテナが無ければ作成 + if (!isContainerExist) { + await this.blobStorageService.createContainer(accountId, country); + } + } catch (e) { + this.logger.error(`error=${e}`); + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + try { + // SASトークン発行 + const url = await this.blobStorageService.publishUploadSas( + accountId, + country, + ); + return url; + } catch (e) { + this.logger.error(`error=${e}`); + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/dictation_server/src/features/files/test/files.service.mock.ts b/dictation_server/src/features/files/test/files.service.mock.ts new file mode 100644 index 0000000..a303346 --- /dev/null +++ b/dictation_server/src/features/files/test/files.service.mock.ts @@ -0,0 +1,116 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BlobstorageService } from '../../../gateways/blobstorage/blobstorage.service'; +import { User } from '../../../repositories/users/entity/user.entity'; +import { UsersRepositoryService } from '../../../repositories/users/users.repository.service'; +import { FilesService } from '../files.service'; + +export type BlobstorageServiceMockValue = { + createContainer: void | Error; + containerExists: boolean | Error; + publishUploadSas: string | Error; +}; + +export type UsersRepositoryMockValue = { + findUserByExternalId: User | Error; +}; + +export const makeFilesServiceMock = async ( + blobStorageService: BlobstorageServiceMockValue, + usersRepositoryMockValue: UsersRepositoryMockValue, +): Promise => { + const module: TestingModule = await Test.createTestingModule({ + providers: [FilesService], + }) + .useMocker((token) => { + switch (token) { + case BlobstorageService: + return makeBlobstorageServiceMock(blobStorageService); + case UsersRepositoryService: + return makeUsersRepositoryMock(usersRepositoryMockValue); + } + }) + .compile(); + + return module.get(FilesService); +}; + +export const makeBlobstorageServiceMock = ( + value: BlobstorageServiceMockValue, +) => { + const { containerExists, createContainer, publishUploadSas } = value; + + return { + containerExists: + containerExists instanceof Error + ? jest.fn, []>().mockRejectedValue(containerExists) + : jest.fn, []>().mockResolvedValue(containerExists), + createContainer: + createContainer instanceof Error + ? jest.fn, []>().mockRejectedValue(createContainer) + : jest.fn, []>().mockResolvedValue(createContainer), + publishUploadSas: + publishUploadSas instanceof Error + ? jest.fn, []>().mockRejectedValue(publishUploadSas) + : jest.fn, []>().mockResolvedValue(publishUploadSas), + }; +}; + +export const makeUsersRepositoryMock = (value: UsersRepositoryMockValue) => { + const { findUserByExternalId } = value; + + return { + findUserByExternalId: + findUserByExternalId instanceof Error + ? jest.fn, []>().mockRejectedValue(findUserByExternalId) + : jest.fn, []>().mockResolvedValue(findUserByExternalId), + }; +}; + +export const makeBlobstorageServiceMockValue = + (): BlobstorageServiceMockValue => { + return { + containerExists: true, + publishUploadSas: 'https://blob-storage?sas-token', + createContainer: undefined, + }; + }; + +// 個別のテストケースに対応してそれぞれのMockを用意するのは無駄が多いのでテストケース内で個別の値を設定する +export const makeDefaultUsersRepositoryMockValue = + (): UsersRepositoryMockValue => { + return { + findUserByExternalId: { + id: 2, + external_id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', + account_id: 1234567890123456, + role: 'none', + author_id: '', + accepted_terms_version: '1.0', + email_verified: true, + deleted_at: null, + created_by: 'test', + created_at: new Date(), + updated_by: null, + updated_at: null, + auto_renew: true, + license_alert: true, + notification: true, + account: { + id: 2, + parent_account_id: 2, + tier: 5, + country: '', + delegation_permission: true, + locked: false, + company_name: '', + verified: true, + primary_admin_user_id: 2, + deleted_at: null, + created_by: '', + created_at: new Date(), + updated_by: '', + updated_at: null, + }, + }, + }; + }; diff --git a/dictation_server/src/gateways/blobstorage/blobstorage.module.ts b/dictation_server/src/gateways/blobstorage/blobstorage.module.ts new file mode 100644 index 0000000..bc4f4a7 --- /dev/null +++ b/dictation_server/src/gateways/blobstorage/blobstorage.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { BlobstorageService } from './blobstorage.service'; +import { ConfigModule } from '@nestjs/config'; + +@Module({ + exports: [BlobstorageService], + imports: [ConfigModule], + providers: [BlobstorageService], +}) +export class BlobstorageModule {} diff --git a/dictation_server/src/gateways/blobstorage/blobstorage.service.ts b/dictation_server/src/gateways/blobstorage/blobstorage.service.ts new file mode 100644 index 0000000..32081f5 --- /dev/null +++ b/dictation_server/src/gateways/blobstorage/blobstorage.service.ts @@ -0,0 +1,169 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + BlobServiceClient, + StorageSharedKeyCredential, + ContainerClient, + ContainerSASPermissions, + generateBlobSASQueryParameters, +} from '@azure/storage-blob'; +import { ConfigService } from '@nestjs/config'; +import { + BLOB_STORAGE_REGION_AU, + BLOB_STORAGE_REGION_EU, + BLOB_STORAGE_REGION_US, +} from '../../constants'; + +@Injectable() +export class BlobstorageService { + private readonly logger = new Logger(BlobstorageService.name); + private readonly blobServiceClientUS: BlobServiceClient; + private readonly blobServiceClientEU: BlobServiceClient; + private readonly blobServiceClientAU: BlobServiceClient; + private readonly sharedKeyCredentialUS: StorageSharedKeyCredential; + private readonly sharedKeyCredentialAU: StorageSharedKeyCredential; + private readonly sharedKeyCredentialEU: StorageSharedKeyCredential; + constructor(private readonly configService: ConfigService) { + // TODO リソース作成後に接続情報をKeyVaultに格納し、そこから取得するように修正する + this.sharedKeyCredentialUS = new StorageSharedKeyCredential( + this.configService.get('STORAGE_ACCOUNT_NAME_US'), + this.configService.get('STORAGE_ACCOUNT_KEY_US'), + ); + this.sharedKeyCredentialAU = new StorageSharedKeyCredential( + this.configService.get('STORAGE_ACCOUNT_NAME_AU'), + this.configService.get('STORAGE_ACCOUNT_KEY_AU'), + ); + this.sharedKeyCredentialEU = new StorageSharedKeyCredential( + this.configService.get('STORAGE_ACCOUNT_NAME_EU'), + this.configService.get('STORAGE_ACCOUNT_KEY_EU'), + ); + this.blobServiceClientUS = new BlobServiceClient( + this.configService.get('STORAGE_ACCOUNT_ENDPOINT_US'), + this.sharedKeyCredentialUS, + ); + this.blobServiceClientAU = new BlobServiceClient( + this.configService.get('STORAGE_ACCOUNT_ENDPOINT_AU'), + this.sharedKeyCredentialAU, + ); + this.blobServiceClientEU = new BlobServiceClient( + this.configService.get('STORAGE_ACCOUNT_ENDPOINT_EU'), + this.sharedKeyCredentialEU, + ); + } + /** + * Creates container + * @param companyName + * @returns container + */ + async createContainer(accountId: number, country: string): Promise { + this.logger.log(`[IN] ${this.createContainer.name}`); + + // 国に応じたリージョンでコンテナ名を指定してClientを取得 + const containerClient = this.getContainerClient(accountId, country); + + try { + // コンテナ作成 + await containerClient.create(); + } catch (e) { + this.logger.error(`error=${e}`); + throw e; + } finally { + this.logger.log(`[OUT] ${this.createContainer.name}`); + } + } + /** + * Containers exists + * @param country + * @param accountId + * @returns exists + */ + async containerExists(accountId: number, country: string): Promise { + // 国に応じたリージョンでコンテナ名を指定してClientを取得 + const containerClient = this.getContainerClient(accountId, country); + const exists = await containerClient.exists(); + return exists; + } + + /** + * Publishs upload sas + * @param accountId + * @param country + * @returns upload sas + */ + async publishUploadSas(accountId: number, country: string): Promise { + this.logger.log(`[IN] ${this.publishUploadSas.name}`); + let containerClient: ContainerClient; + let sharedKeyCredential: StorageSharedKeyCredential; + try { + // コンテナ名を指定してClientを取得 + containerClient = this.getContainerClient(accountId, country); + // 国に対応したリージョンの接続情報を取得する + sharedKeyCredential = this.getSharedKeyCredential(country); + } catch (e) { + this.logger.error(`error=${e}`); + throw e; + } + + //SASの有効期限を設定 + //TODO 有効期限は仮で30分 + const expiryDate = new Date(); + expiryDate.setMinutes( + expiryDate.getMinutes() + + this.configService.get('STORAGE_TOKEN_EXPIRE_TIME'), + ); + + //SASの権限を設定 + const permissions = new ContainerSASPermissions(); + permissions.create = true; + + //SASを発行 + const sasToken = generateBlobSASQueryParameters( + { + containerName: containerClient.containerName, + permissions: permissions, + startsOn: new Date(), + expiresOn: expiryDate, + }, + sharedKeyCredential, + ); + + return `${containerClient.url}?${sasToken}`; + } + + /** + * Gets container client + * @param companyName + * @returns container client + */ + private getContainerClient( + accountId: number, + country: string, + ): ContainerClient { + const containerName = `account-${accountId}`; + if (BLOB_STORAGE_REGION_US.includes(country)) { + return this.blobServiceClientUS.getContainerClient(containerName); + } else if (BLOB_STORAGE_REGION_AU.includes(country)) { + return this.blobServiceClientAU.getContainerClient(containerName); + } else if (BLOB_STORAGE_REGION_EU.includes(country)) { + return this.blobServiceClientEU.getContainerClient(containerName); + } else { + throw new Error('invalid country'); + } + } + + /** + * Gets shared key credential + * @param country + * @returns shared key credential + */ + private getSharedKeyCredential(country: string): StorageSharedKeyCredential { + if (BLOB_STORAGE_REGION_US.includes(country)) { + return this.sharedKeyCredentialUS; + } else if (BLOB_STORAGE_REGION_AU.includes(country)) { + return this.sharedKeyCredentialAU; + } else if (BLOB_STORAGE_REGION_EU.includes(country)) { + return this.sharedKeyCredentialEU; + } else { + throw new Error('invalid country'); + } + } +} diff --git a/dictation_server/src/repositories/users/users.repository.service.ts b/dictation_server/src/repositories/users/users.repository.service.ts index 6e7bdb5..6318a9a 100644 --- a/dictation_server/src/repositories/users/users.repository.service.ts +++ b/dictation_server/src/repositories/users/users.repository.service.ts @@ -93,7 +93,7 @@ export class UsersRepositoryService { return user; } - async findUserByExternalId(sub: string): Promise { + async findUserByExternalId(sub: string): Promise { const user = await this.dataSource.getRepository(User).findOne({ where: { external_id: sub, @@ -104,7 +104,7 @@ export class UsersRepositoryService { }); if (!user) { - return undefined; + throw new UserNotFoundError(); } return user; }