Jelajahi Sumber

- feat: add Azure BLOB storage support

sakazuki 2 tahun lalu
induk
melakukan
202a6057a3

+ 2 - 0
apps/app/package.json

@@ -58,6 +58,8 @@
     "@akebifiky/remark-simple-plantuml": "^1.0.2",
     "@aws-sdk/client-s3": "^3.58.0",
     "@aws-sdk/s3-request-presigner": "^3.58.0",
+    "@azure/identity": "^3.3.2",
+    "@azure/storage-blob": "^12.16.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",

+ 7 - 0
apps/app/public/static/locales/en_US/admin.json

@@ -381,6 +381,13 @@
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
+    "azure_label": "Azure(Blob)",
+    "azure_tenant_id": "Tenant ID",
+    "azure_client_id": "Client ID",
+    "azure_client_secret": "Client Secret",
+    "azure_storage_account_name": "Storage Account Name",
+    "azure_storage_container_name": "Container Name",
+    "azure_note_for_the_only_env_option": "The Azure Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "test_connection": "Test connection to mail",
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",

+ 7 - 0
apps/app/public/static/locales/ja_JP/admin.json

@@ -388,6 +388,13 @@
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
+    "azure_label": "Azure(Blob)",
+    "azure_tenant_id": "テナントID",
+    "azure_client_id": "クライアントID",
+    "azure_client_secret": "クライアントシークレット",
+    "azure_storage_account_name": "ストレージアカウント名",
+    "azure_storage_container_name": "コンテナ名",
+    "azure_note_for_the_only_env_option": "現在Azure設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
     "fixed_by_env_var": "環境変数 <code>FILE_UPLOAD={{fileUploadType}}</code> により固定されています。",
     "file_upload": "ファイルをアップロードするための設定を行います。ファイルアップロードの設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "test_connection": "接続テスト",

+ 7 - 0
apps/app/public/static/locales/zh_CN/admin.json

@@ -388,6 +388,13 @@
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
+    "azure_label": "Azure(Blob)",
+    "azure_tenant_id": "Tenant ID",
+    "azure_client_id": "Client ID",
+    "azure_client_secret": "Client Secret",
+    "azure_storage_account_name": "Storage Account Name",
+    "azure_storage_container_name": "Container Name",
+    "azure_note_for_the_only_env_option": "The Azure Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "test_connection": "测试邮件服务器连接",

+ 78 - 0
apps/app/src/client/services/AdminAppContainer.js

@@ -60,6 +60,19 @@ export default class AdminAppContainer extends Container {
       s3SecretAccessKey: '',
       s3ReferenceFileWithRelayMode: false,
 
+      azureReferenceFileWithRelayMode: false,
+      azureUseOnlyEnvVars: false,
+      azureTenantId: '',
+      azureClientId: '',
+      azureClientSecret: '',
+      azureStorageAccountName: '',
+      azureStorageContainerName: '',
+      envAzureTenantId: '',
+      envAzureClientId: '',
+      envAzureClientSecret: '',
+      envAzureStorageAccountName: '',
+      envAzureStorageContainerName: '',
+
       isEnabledPlugins: true,
 
       isMaintenanceMode: false,
@@ -120,6 +133,20 @@ export default class AdminAppContainer extends Container {
       envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
       envGcsBucket: appSettingsParams.envGcsBucket,
       envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
+
+      azureUseOnlyEnvVars: appSettingsParams.azureUseOnlyEnvVars,
+      azureTenantId: appSettingsParams.azureTenantId,
+      azureClientId: appSettingsParams.azureClientId,
+      azureClientSecret: appSettingsParams.azureClientSecret,
+      azureStorageAccountName: appSettingsParams.azureStorageAccountName,
+      azureStorageContainerName: appSettingsParams.azureStorageContainerName,
+      azureReferenceFileWithRelayMode: appSettingsParams.azureReferenceFileWithRelayMode,
+      envAzureTenantId: appSettingsParams.envAzureTenantId,
+      envAzureClientId: appSettingsParams.envAzureClientId,
+      envAzureClientSecret: appSettingsParams.envAzureClientSecret,
+      envAzureStorageAccountName: appSettingsParams.envAzureStorageAccountName,
+      envAzureStorageContainerName: appSettingsParams.envAzureStorageContainerName,
+
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
       isMaintenanceMode: appSettingsParams.isMaintenanceMode,
     });
@@ -316,6 +343,48 @@ export default class AdminAppContainer extends Container {
     this.setState({ gcsReferenceFileWithRelayMode });
   }
 
+  /**
+   * Change azureReferenceFileWithRelayMode
+   */
+  changeAzureReferenceFileWithRelayMode(azureReferenceFileWithRelayMode) {
+    this.setState({ azureReferenceFileWithRelayMode });
+  }
+
+  /**
+   * Change azureTenantId
+   */
+  changeAzureTenantId(azureTenantId) {
+    this.setState({ azureTenantId });
+  }
+
+  /**
+   * Change azureClientId
+   */
+  changeAzureClientId(azureClientId) {
+    this.setState({ azureClientId });
+  }
+
+  /**
+   * Change azureClientSecret
+   */
+  changeAzureClientSecret(azureClientSecret) {
+    this.setState({ azureClientSecret });
+  }
+
+  /**
+   * Change azureStorageAccountName
+   */
+  changeAzureStorageAccountName(azureStorageAccountName) {
+    this.setState({ azureStorageAccountName });
+  }
+
+  /**
+   * Change azureStorageContainerName
+   */
+  changeAzureStorageContainerName(azureStorageContainerName) {
+    this.setState({ azureStorageContainerName });
+  }
+
   /**
    * Update app setting
    * @memberOf AdminAppContainer
@@ -430,6 +499,15 @@ export default class AdminAppContainer extends Container {
       requestParams.s3ReferenceFileWithRelayMode = this.state.s3ReferenceFileWithRelayMode;
     }
 
+    if (fileUploadType === 'azure') {
+      requestParams.azureTenantId = this.state.azureTenantId;
+      requestParams.azureClientId = this.state.azureClientId;
+      requestParams.azureClientSecret = this.state.azureClientSecret;
+      requestParams.azureStorageAccountName = this.state.azureStorageAccountName;
+      requestParams.azureStorageContainerName = this.state.azureStorageContainerName;
+      requestParams.azureReferenceFileWithRelayMode = this.state.azureReferenceFileWithRelayMode;
+    }
+
     const response = await apiv3Put('/app-settings/file-upload-setting', requestParams);
     const { responseParams } = response.data;
     return this.setState(responseParams);

+ 211 - 0
apps/app/src/components/Admin/App/AzureSetting.tsx

@@ -0,0 +1,211 @@
+import { useTranslation } from 'next-i18next';
+
+import MaskedInput from './MaskedInput';
+
+export type AzureSettingMoleculeProps = {
+  azureReferenceFileWithRelayMode
+  azureUseOnlyEnvVars
+  azureTenantId
+  azureClientId
+  azureClientSecret
+  azureStorageAccountName
+  azureStorageContainerName
+  envAzureTenantId?
+  envAzureClientId?
+  envAzureClientSecret?
+  envAzureStorageAccountName?
+  envAzureStorageContainerName?
+  onChangeAzureReferenceFileWithRelayMode: (val: boolean) => void
+  onChangeAzureTenantId: (val: string) => void
+  onChangeAzureClientId: (val: string) => void
+  onChangeAzureClientSecret: (val: string) => void
+  onChangeAzureStorageAccountName: (val: string) => void
+  onChangeAzureStorageContainerName: (val: string) => void
+};
+
+export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    azureReferenceFileWithRelayMode,
+    azureUseOnlyEnvVars,
+    azureTenantId,
+    azureClientId,
+    azureClientSecret,
+    azureStorageAccountName,
+    envAzureTenantId,
+    envAzureClientId,
+    envAzureClientSecret,
+    envAzureStorageAccountName,
+    azureStorageContainerName,
+    envAzureStorageContainerName,
+  } = props;
+
+  return (
+    <>
+
+      <div className="row form-group my-3">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.file_delivery_method')}
+        </label>
+
+        <div className="col-md-6">
+          <div className="dropdown">
+            <button
+              className="btn btn-outline-secondary dropdown-toggle"
+              type="button"
+              id="ddAzureReferenceFileWithRelayMode"
+              data-toggle="dropdown"
+              aria-haspopup="true"
+              aria-expanded="true"
+            >
+              {azureReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
+              {!azureReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')}
+            </button>
+            <div className="dropdown-menu" aria-labelledby="ddAzureReferenceFileWithRelayMode">
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { props?.onChangeAzureReferenceFileWithRelayMode(true) }}
+              >
+                {t('admin:app_setting.file_delivery_method_relay')}
+              </button>
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { props?.onChangeAzureReferenceFileWithRelayMode(false) }}
+              >
+                { t('admin:app_setting.file_delivery_method_redirect')}
+              </button>
+            </div>
+
+            <p className="form-text text-muted small">
+              {t('admin:app_setting.file_delivery_method_redirect_info')}
+              <br />
+              {t('admin:app_setting.file_delivery_method_relay_info')}
+            </p>
+          </div>
+        </div>
+      </div>
+
+      {azureUseOnlyEnvVars && (
+        <p
+          className="alert alert-info"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('admin:app_setting.azure_note_for_the_only_env_option', { env: 'AZURE_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }) }}
+        />
+      )}
+      <table className={`table settings-table ${azureUseOnlyEnvVars && 'use-only-env-vars'}`}>
+        <colgroup>
+          <col className="item-name" />
+          <col className="from-db" />
+          <col className="from-env-vars" />
+        </colgroup>
+        <thead>
+          <tr>
+            <th></th>
+            <th>Database</th>
+            <th>Environment variables</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr>
+            <th>{t('admin:app_setting.azure_tenant_id')}</th>
+            <td>
+              <MaskedInput
+                name="azureTenantId"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureTenantId}
+                onChange={e => props?.onChangeAzureTenantId(e.target.value)}
+              />
+            </td>
+            <td>
+              <MaskedInput name="envAzureTenantId" defaultValue={envAzureTenantId || ''} readOnly tabIndex={-1} />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_TENANT_ID' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.azure_client_id')}</th>
+            <td>
+              <MaskedInput
+                name="azureClientId"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureClientId}
+                onChange={e => props?.onChangeAzureClientId(e.target.value)}
+              />
+            </td>
+            <td>
+              <MaskedInput name="envAzureClientId" defaultValue={envAzureClientId || ''} readOnly tabIndex={-1} />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_CLIENT_ID' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.azure_client_secret')}</th>
+            <td>
+              <MaskedInput
+                name="azureClientSecret"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureClientSecret}
+                onChange={e => props?.onChangeAzureClientSecret(e.target.value)}
+              />
+            </td>
+            <td>
+              <MaskedInput name="envAzureClientSecret" defaultValue={envAzureClientSecret || ''} readOnly tabIndex={-1} />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_CLIENT_SECRET' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.azure_storage_account_name')}</th>
+            <td>
+              <input
+                className="form-control"
+                type="text"
+                name="azureStorageAccountName"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureStorageAccountName}
+                onChange={e => props?.onChangeAzureStorageAccountName(e.target.value)}
+              />
+            </td>
+            <td>
+              <input className="form-control" type="text" value={envAzureStorageAccountName || ''} readOnly tabIndex={-1} />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_STORAGE_ACCOUNT_NAME' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.azure_storage_container_name')}</th>
+            <td>
+              <input
+                className="form-control"
+                type="text"
+                name="azureStorageContainerName"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureStorageContainerName}
+                onChange={e => props?.onChangeAzureStorageContainerName(e.target.value)}
+              />
+            </td>
+            <td>
+              <input className="form-control" type="text" value={envAzureStorageContainerName || ''} readOnly tabIndex={-1} />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_STORAGE_CONTAINER_NAME' }) }} />
+              </p>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+    </>
+  );
+};

+ 75 - 2
apps/app/src/components/Admin/App/FileUploadSetting.tsx

@@ -10,17 +10,20 @@ import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 import { AwsSettingMolecule } from './AwsSetting';
 import type { AwsSettingMoleculeProps } from './AwsSetting';
+import { AzureSettingMolecule } from './AzureSetting';
+import type { AzureSettingMoleculeProps } from './AzureSetting';
 import { GcsSettingMolecule } from './GcsSetting';
 import type { GcsSettingMoleculeProps } from './GcsSetting';
 
-const fileUploadTypes = ['aws', 'gcs', 'gridfs', 'local'] as const;
+
+const fileUploadTypes = ['aws', 'gcs', 'azure', 'gridfs', 'local'] as const;
 
 type FileUploadSettingMoleculeProps = {
   fileUploadType: string
   isFixedFileUploadByEnvVar: boolean
   envFileUploadType?: string
   onChangeFileUploadType: (e: ChangeEvent, type: string) => void
-} & AwsSettingMoleculeProps & GcsSettingMoleculeProps;
+} & AwsSettingMoleculeProps & GcsSettingMoleculeProps & AzureSettingMoleculeProps;
 
 export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMoleculeProps): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
@@ -102,6 +105,28 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
           onChangeGcsUploadNamespace={props.onChangeGcsUploadNamespace}
         />
       )}
+      {props.fileUploadType === 'azure' && (
+        <AzureSettingMolecule
+          azureReferenceFileWithRelayMode={props.azureReferenceFileWithRelayMode}
+          azureUseOnlyEnvVars={props.azureUseOnlyEnvVars}
+          azureTenantId={props.azureTenantId}
+          azureClientId={props.azureClientId}
+          azureClientSecret={props.azureClientSecret}
+          azureStorageAccountName={props.azureStorageAccountName}
+          azureStorageContainerName={props.azureStorageContainerName}
+          envAzureStorageAccountName={props.envAzureStorageAccountName}
+          envAzureStorageContainerName={props.envAzureStorageContainerName}
+          envAzureTenantId={props.envAzureTenantId}
+          envAzureClientId={props.envAzureClientId}
+          envAzureClientSecret={props.envAzureClientSecret}
+          onChangeAzureReferenceFileWithRelayMode={props.onChangeAzureReferenceFileWithRelayMode}
+          onChangeAzureTenantId={props.onChangeAzureTenantId}
+          onChangeAzureClientId={props.onChangeAzureClientId}
+          onChangeAzureClientSecret={props.onChangeAzureClientSecret}
+          onChangeAzureStorageAccountName={props.onChangeAzureStorageAccountName}
+          onChangeAzureStorageContainerName={props.onChangeAzureStorageContainerName}
+        />
+      )}
     </>
   );
 });
@@ -124,6 +149,11 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
     gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars,
     gcsApiKeyJsonPath, envGcsApiKeyJsonPath, gcsBucket,
     envGcsBucket, gcsUploadNamespace, envGcsUploadNamespace,
+    azureReferenceFileWithRelayMode, azureUseOnlyEnvVars,
+    azureTenantId, azureClientId, azureClientSecret,
+    azureStorageAccountName, azureStorageContainerName,
+    envAzureTenantId, envAzureClientId, envAzureClientSecret,
+    envAzureStorageAccountName, envAzureStorageContainerName,
   } = adminAppContainer.state;
 
   const submitHandler = useCallback(async() => {
@@ -182,6 +212,31 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
     adminAppContainer.changeGcsUploadNamespace(val);
   }, [adminAppContainer]);
 
+  // Azure
+  const onChangeAzureReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
+    adminAppContainer.changeAzureReferenceFileWithRelayMode(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureTenantIdHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureTenantId(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureClientIdHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureClientId(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureClientSecretHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureClientSecret(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureStorageAccountNameHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureStorageAccountName(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureStorageContainerNameHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureStorageContainerName(val);
+  }, [adminAppContainer]);
+
   return (
     <>
       <FileUploadSettingMolecule
@@ -213,6 +268,24 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
         onChangeGcsApiKeyJsonPath={onChangeGcsApiKeyJsonPathHandler}
         onChangeGcsBucket={onChangeGcsBucketHandler}
         onChangeGcsUploadNamespace={onChangeGcsUploadNamespaceHandler}
+        azureReferenceFileWithRelayMode={azureReferenceFileWithRelayMode}
+        azureUseOnlyEnvVars={azureUseOnlyEnvVars}
+        azureTenantId={azureTenantId}
+        azureClientId={azureClientId}
+        azureClientSecret={azureClientSecret}
+        azureStorageAccountName={azureStorageAccountName}
+        azureStorageContainerName={azureStorageContainerName}
+        envAzureTenantId={envAzureTenantId}
+        envAzureClientId={envAzureClientId}
+        envAzureClientSecret={envAzureClientSecret}
+        envAzureStorageAccountName={envAzureStorageAccountName}
+        envAzureStorageContainerName={envAzureStorageContainerName}
+        onChangeAzureReferenceFileWithRelayMode={onChangeAzureReferenceFileWithRelayModeHandler}
+        onChangeAzureTenantId={onChangeAzureTenantIdHandler}
+        onChangeAzureClientId={onChangeAzureClientIdHandler}
+        onChangeAzureClientSecret={onChangeAzureClientSecretHandler}
+        onChangeAzureStorageAccountName={onChangeAzureStorageAccountNameHandler}
+        onChangeAzureStorageContainerName={onChangeAzureStorageContainerNameHandler}
       />
       <AdminUpdateButtonRow onClick={submitHandler} disabled={retrieveError != null} />
     </>

+ 12 - 0
apps/app/src/components/Admin/App/MaskedInput.module.scss

@@ -0,0 +1,12 @@
+.MaskedInput {
+  position: relative;
+  display: flex;
+}
+
+.PasswordReveal {
+  position: absolute;
+  top: 0rem;
+  right: 0.5rem;
+  left: auto;
+  font-size: 1.4rem;
+}

+ 43 - 0
apps/app/src/components/Admin/App/MaskedInput.tsx

@@ -0,0 +1,43 @@
+import { useState } from 'react';
+
+import styles from './MaskedInput.module.scss';
+
+type Props = {
+  name: string
+  readOnly: boolean
+  defaultValue: string
+  onChange?: (e: any) => void
+  tabIndex?: number | undefined
+};
+
+export default function MaskedInput(props: Props): JSX.Element {
+  const [passwordShown, setPasswordShown] = useState(false);
+  const togglePassword = () => {
+    setPasswordShown(!passwordShown);
+  };
+
+  const {
+    name, readOnly, defaultValue, onChange, tabIndex,
+  } = props;
+
+  return (
+    <div className={styles.MaskedInput}>
+      <input
+        className="form-control"
+        type={passwordShown ? 'text' : 'password'}
+        name={name}
+        readOnly={readOnly}
+        defaultValue={defaultValue}
+        onChange={onChange}
+        tabIndex={tabIndex}
+      />
+      <span onClick={togglePassword} className={styles.PasswordReveal}>
+        {passwordShown ? (
+          <i className="fa fa-eye" />
+        ) : (
+          <i className="fa fa-eye-slash" />
+        )}
+      </span>
+    </div>
+  );
+}

+ 1 - 0
apps/app/src/features/questionnaire/interfaces/growi-info.ts

@@ -13,6 +13,7 @@ export const GrowiAttachmentType = {
   aws: 'aws',
   gcs: 'gcs',
   gcp: 'gcp',
+  azure: 'azure',
   gridfs: 'gridfs',
   mongo: 'mongo',
   mongodb: 'mongodb',

+ 14 - 0
apps/app/src/interfaces/res/admin/app-settings.ts

@@ -39,6 +39,20 @@ export type IResAppSettings = {
   envGcsBucket: string,
   envGcsUploadNamespace: string,
 
+  azureUseOnlyEnvVars: boolean,
+  azureTenantId: string,
+  azureClientId: string,
+  azureClientSecret: string,
+  azureStorageAccountName: string,
+  azureStorageContainerName: string,
+  azureReferenceFileWithRelayMode: string,
+
+  envAzureTenantId: string,
+  envAzureClientId: string,
+  envAzureClientSecret: string,
+  envAzureStorageAccountName: string,
+  envAzureStorageContainerName: string,
+
   isEnabledPlugins: boolean,
 
   isQuestionnaireEnabled: boolean,

+ 40 - 1
apps/app/src/server/routes/apiv3/app-settings.js

@@ -184,7 +184,7 @@ module.exports = (crowi) => {
       body('sesSecretAccessKey').trim(),
     ],
     fileUploadSetting: [
-      body('fileUploadType').isIn(['aws', 'gcs', 'local', 'gridfs']),
+      body('fileUploadType').isIn(['aws', 'gcs', 'local', 'gridfs', 'azure']),
       body('gcsApiKeyJsonPath').trim(),
       body('gcsBucket').trim(),
       body('gcsUploadNamespace').trim(),
@@ -197,6 +197,13 @@ module.exports = (crowi) => {
       body('s3AccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
       body('s3SecretAccessKey').trim(),
       body('s3ReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
+      body('azureTenantId').trim(),
+      body('azureClientId').trim(),
+      body('azureClientSecret').trim(),
+      body('azureStorageAccountName').trim(),
+      body('azureStorageStorageName').trim(),
+      body('azureReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
+
     ],
     questionnaireSettings: [
       body('isQuestionnaireEnabled').isBoolean(),
@@ -269,6 +276,20 @@ module.exports = (crowi) => {
       envGcsBucket: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:bucket'),
       envGcsUploadNamespace: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:uploadNamespace'),
 
+      azureUseOnlyEnvVars: crowi.configManager.getConfig('crowi', 'azure:useOnlyEnvVarsForSomeOptions'),
+      azureTenantId: crowi.configManager.getConfigFromDB('crowi', 'azure:tenantId'),
+      azureClientId: crowi.configManager.getConfigFromDB('crowi', 'azure:clientId'),
+      azureClientSecret: crowi.configManager.getConfigFromDB('crowi', 'azure:clientSecret'),
+      azureStorageAccountName: crowi.configManager.getConfigFromDB('crowi', 'azure:storageAccountName'),
+      azureStorageContainerName: crowi.configManager.getConfigFromDB('crowi', 'azure:storageContainerName'),
+      azureReferenceFileWithRelayMode: crowi.configManager.getConfig('crowi', 'azure:referenceFileWithRelayMode'),
+
+      envAzureTenantId: crowi.configManager.getConfigFromEnvVars('crowi', 'azure:tenantId'),
+      envAzureClientId: crowi.configManager.getConfigFromEnvVars('crowi', 'azure:clientId'),
+      envAzureClientSecret: crowi.configManager.getConfigFromEnvVars('crowi', 'azure:clientSecret'),
+      envAzureStorageAccountName: crowi.configManager.getConfigFromEnvVars('crowi', 'azure:storageAccountName'),
+      envAzureStorageContainerName: crowi.configManager.getConfigFromEnvVars('crowi', 'azure:storageContainerName'),
+
       isEnabledPlugins: crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins'),
 
       isQuestionnaireEnabled: crowi.configManager.getConfig('crowi', 'questionnaire:isQuestionnaireEnabled'),
@@ -648,6 +669,15 @@ module.exports = (crowi) => {
       requestParams['aws:referenceFileWithRelayMode'] = req.body.s3ReferenceFileWithRelayMode;
     }
 
+    if (fileUploadType === 'azure') {
+      requestParams['azure:tenantId'] = req.body.azureTenantId;
+      requestParams['azure:clientId'] = req.body.azureClientId;
+      requestParams['azure:clientSecret'] = req.body.azureClientSecret;
+      requestParams['azure:storageAccountName'] = req.body.azureStorageAccountName;
+      requestParams['azure:storageContainerName'] = req.body.azureStorageContainerName;
+      requestParams['azure:referenceFileWithRelayMode'] = req.body.azureReferenceFileWithRelayMode;
+    }
+
     try {
       await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams, true);
 
@@ -677,6 +707,15 @@ module.exports = (crowi) => {
         responseParams.s3AccessKeyId = crowi.configManager.getConfig('crowi', 'aws:s3AccessKeyId');
         responseParams.s3ReferenceFileWithRelayMode = crowi.configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
       }
+
+      if (fileUploadType === 'azure') {
+        responseParams.azureTenantId = crowi.configManager.getConfig('crowi', 'azure:tenantId');
+        responseParams.azureClientId = crowi.configManager.getConfig('crowi', 'azure:clientId');
+        responseParams.azureClientSecret = crowi.configManager.getConfig('crowi', 'azure:clientSecret');
+        responseParams.azureStorageAccountName = crowi.configManager.getConfig('crowi', 'azure:storageAccountName');
+        responseParams.azureStorageContainerName = crowi.configManager.getConfig('crowi', 'azure:storageContainerName');
+        responseParams.azureReferenceFileWithRelayMode = crowi.configManager.getConfig('crowi', 'azure:referenceFileWithRelayMode');
+      }
       const parameters = { action: SupportedAction.ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ responseParams });

+ 48 - 0
apps/app/src/server/service/config-loader.ts

@@ -520,6 +520,54 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     default: false,
   },
+  AZURE_TENANT_ID: {
+    ns:      'crowi',
+    key:     'azure:tenantId',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  AZURE_CLIENT_ID: {
+    ns:      'crowi',
+    key:     'azure:clientId',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  AZURE_CLIENT_SECRET: {
+    ns:      'crowi',
+    key:     'azure:clientSecret',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  AZURE_STORAGE_ACCOUNT_NAME: {
+    ns:      'crowi',
+    key:     'azure:storageAccountName',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  AZURE_STORAGE_CONTAINER_NAME: {
+    ns:      'crowi',
+    key:     'azure:storageContainerName',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  AZURE_LIFETIME_SEC_FOR_TEMPORARY_URL: {
+    ns:      'crowi',
+    key:     'azure:lifetimeSecForTemporaryUrl',
+    type:    ValueType.NUMBER,
+    default: 120,
+  },
+  AZURE_REFERENCE_FILE_WITH_RELAY_MODE: {
+    ns:      'crowi',
+    key:     'azure:referenceFileWithRelayMode',
+    type:    ValueType.BOOLEAN,
+    default: false,
+  },
+  AZURE_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
+    ns:      'crowi',
+    key:     'azure:useOnlyEnvVarsForSomeOptions',
+    type:    ValueType.BOOLEAN,
+    default: false,
+  },
   GROWI_CLOUD_URI: {
     ns:      'crowi',
     key:     'app:growiCloudUri',

+ 12 - 0
apps/app/src/server/service/config-manager.ts

@@ -36,6 +36,13 @@ const KEYS_FOR_GCS_USE_ONLY_ENV_OPTION = [
   'gcs:uploadNamespace',
 ];
 
+const KEYS_FOR_AZURE_USE_ONLY_ENV_OPTION = [
+  'azure:tenantId',
+  'azure:clientId',
+  'azure:clientSecret',
+  'azure:storageAccountName',
+  'azure:storageContainerName',
+];
 
 export interface ConfigManager {
   loadConfigs(): Promise<void>,
@@ -255,6 +262,11 @@ class ConfigManagerImpl implements ConfigManager, S2sMessageHandlable {
         KEYS_FOR_GCS_USE_ONLY_ENV_OPTION.includes(key)
         && this.searchOnlyFromEnvVarConfigs('crowi', 'gcs:useOnlyEnvVarsForSomeOptions')
       )
+      // azure option
+      || (
+        KEYS_FOR_AZURE_USE_ONLY_ENV_OPTION.includes(key)
+        && this.searchOnlyFromEnvVarConfigs('crowi', 'azure:useOnlyEnvVarsForSomeOptions')
+      )
     ));
   }
 

+ 259 - 0
apps/app/src/server/service/file-uploader/azure.ts

@@ -0,0 +1,259 @@
+import path from 'path';
+
+import { ClientSecretCredential, TokenCredential } from '@azure/identity';
+import {
+  BlobServiceClient,
+  BlobClient,
+  BlockBlobClient,
+  BlobDeleteOptions,
+  BlobDeleteIfExistsResponse,
+  BlockBlobUploadResponse,
+  ContainerClient,
+  generateBlobSASQueryParameters,
+  ContainerSASPermissions,
+  SASProtocol,
+  BlockBlobParallelUploadOptions,
+  BlockBlobUploadStreamOptions,
+} from '@azure/storage-blob';
+
+import loggerFactory from '~/utils/logger';
+
+import { configManager } from '../config-manager';
+
+import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+
+const urljoin = require('url-join');
+
+const logger = loggerFactory('growi:service:fileUploaderAzure');
+
+interface FileMeta {
+  name: string;
+  size: number;
+}
+
+type AzureConfig = {
+  accountName: string,
+  containerName: string,
+}
+
+class AzureFileUploader extends AbstractFileUploader {
+
+  /**
+   * @inheritdoc
+   */
+  override isValidUploadSettings(): boolean {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override saveFile(param: SaveFileParam) {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override deleteFiles() {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override respond(res: Response, attachment: Response): void {
+    throw new Error('Method not implemented.');
+  }
+
+}
+
+module.exports = (crowi) => {
+  const lib = new AzureFileUploader(crowi);
+
+  function getAzureConfig(): AzureConfig {
+    return {
+      accountName: configManager.getConfig('crowi', 'azure:storageAccountName'),
+      containerName: configManager.getConfig('crowi', 'azure:storageContainerName'),
+    };
+  }
+
+  function getCredential(): TokenCredential {
+    const tenantId = configManager.getConfig('crowi', 'azure:tenantId');
+    const clientId = configManager.getConfig('crowi', 'azure:clientId');
+    const clientSecret = configManager.getConfig('crowi', 'azure:clientSecret');
+    return new ClientSecretCredential(tenantId, clientId, clientSecret);
+  }
+
+  async function getContainerClient(): Promise<ContainerClient> {
+    const { accountName, containerName } = getAzureConfig();
+    const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
+    return blobServiceClient.getContainerClient(containerName);
+  }
+
+  // Server creates User Delegation SAS Token for container
+  // https://learn.microsoft.com/ja-jp/azure/storage/blobs/storage-blob-create-user-delegation-sas-javascript
+  async function getSasToken(lifetimeSec) {
+    const { accountName, containerName } = getAzureConfig();
+    const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
+
+    const now = Date.now();
+    const startsOn = new Date(now - 30 * 1000);
+    const expiresOn = new Date(now + lifetimeSec * 1000);
+    const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);
+
+    // https://github.com/Azure/azure-sdk-for-js/blob/d4d55f73/sdk/storage/storage-blob/src/ContainerSASPermissions.ts#L24
+    // r:read, a:add, c:create, w:write, d:delete, l:list
+    const containerPermissionsForAnonymousUser = 'rl';
+    const sasOptions = {
+      containerName,
+      permissions: ContainerSASPermissions.parse(containerPermissionsForAnonymousUser),
+      protocol: SASProtocol.HttpsAndHttp,
+      startsOn,
+      expiresOn,
+    };
+
+    const sasToken = generateBlobSASQueryParameters(sasOptions, userDelegationKey, accountName).toString();
+
+    return sasToken;
+  }
+
+  function getFilePathOnStorage(attachment) {
+    const dirName = (attachment.page != null) ? 'attachment' : 'user';
+    return urljoin(dirName, attachment.fileName);
+  }
+
+  lib.isValidUploadSettings = function() {
+    return configManager.getConfig('crowi', 'azure:storageAccountName') != null
+      && configManager.getConfig('crowi', 'azure:storageContainerName') != null;
+  };
+
+  lib.canRespond = function() {
+    return !configManager.getConfig('crowi', 'azure:referenceFileWithRelayMode');
+  };
+
+  (lib as any).respond = async function(res, attachment) {
+    const containerClient = await getContainerClient();
+    const filePath = getFilePathOnStorage(attachment);
+    const blockBlobClient: BlockBlobClient = await containerClient.getBlockBlobClient(filePath);
+    const lifetimeSecForTemporaryUrl = configManager.getConfig('crowi', 'azure:lifetimeSecForTemporaryUrl');
+
+    const sasToken = await getSasToken(lifetimeSecForTemporaryUrl);
+    const signedUrl = `${blockBlobClient.url}?${sasToken}`;
+
+    res.redirect(signedUrl);
+
+    try {
+      return attachment.cashTemporaryUrlByProvideSec(signedUrl, lifetimeSecForTemporaryUrl);
+    }
+    catch (err) {
+      logger.error(err);
+    }
+
+  };
+
+  (lib as any).deleteFile = async function(attachment) {
+    const filePath = getFilePathOnStorage(attachment);
+    const containerClient = await getContainerClient();
+    const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
+    const options: BlobDeleteOptions = { deleteSnapshots: 'include' };
+    const blobDeleteIfExistsResponse: BlobDeleteIfExistsResponse = await blockBlobClient.deleteIfExists(options);
+    if (!blobDeleteIfExistsResponse.errorCode) {
+      logger.info(`deleted blob ${filePath}`);
+    }
+  };
+
+  (lib as any).deleteFiles = async function(attachments) {
+    if (!lib.getIsUploadable()) {
+      throw new Error('Azure is not configured.');
+    }
+    for await (const attachment of attachments) {
+      (lib as any).deleteFile(attachment);
+    }
+  };
+
+  (lib as any).uploadAttachment = async function(readStream, attachment) {
+    if (!lib.getIsUploadable()) {
+      throw new Error('Azure is not configured.');
+    }
+
+    logger.debug(`File uploading: fileName=${attachment.fileName}`);
+    const filePath = getFilePathOnStorage(attachment);
+    const containerClient = await getContainerClient();
+    const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
+    const DEFAULT_BLOCK_BUFFER_SIZE_BYTES: number = 8 * 1024 * 1024; // 8MB
+    const DEFAULT_MAX_CONCURRENCY = 5;
+    const options: BlockBlobUploadStreamOptions = {
+      blobHTTPHeaders: {
+        blobContentDisposition: `attachment; filename="${encodeURI(attachment.originalName)}"`,
+      },
+    };
+    return blockBlobClient.uploadStream(readStream, DEFAULT_BLOCK_BUFFER_SIZE_BYTES, DEFAULT_MAX_CONCURRENCY, options);
+  };
+
+  lib.saveFile = async function({ filePath, contentType, data }) {
+    const containerClient = await getContainerClient();
+    const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
+    const options: BlockBlobParallelUploadOptions = {
+      blobHTTPHeaders: {
+        blobContentType: contentType,
+        blobContentDisposition: `attachment; filename="${encodeURI(path.basename(filePath))}"`,
+      },
+    };
+    const blockBlobUploadResponse: BlockBlobUploadResponse = await blockBlobClient.upload(data, data.length, options);
+    if (blockBlobUploadResponse.errorCode) { throw new Error(blockBlobUploadResponse.errorCode) }
+    return;
+  };
+
+  (lib as any).findDeliveryFile = async function(attachment) {
+    if (!lib.getIsReadable()) {
+      throw new Error('Azure is not configured.');
+    }
+
+    const filePath = getFilePathOnStorage(attachment);
+    const containerClient = await getContainerClient();
+    const blobClient: BlobClient = containerClient.getBlobClient(filePath);
+    const downloadResponse = await blobClient.download();
+    if (downloadResponse.errorCode) {
+      logger.error(downloadResponse.errorCode);
+      throw new Error(downloadResponse.errorCode);
+    }
+    if (!downloadResponse?.readableStreamBody) {
+      throw new Error(`Coudn't get file from Azure for the Attachment (${filePath})`);
+    }
+
+    return downloadResponse.readableStreamBody;
+  };
+
+
+  (lib as any).checkLimit = async function(uploadFileSize) {
+    const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
+    const gcsTotalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
+    return lib.doCheckLimit(uploadFileSize, maxFileSize, gcsTotalLimit);
+  };
+
+  (lib as any).listFiles = async function() {
+    if (!lib.getIsReadable()) {
+      throw new Error('Azure is not configured.');
+    }
+
+    const files: FileMeta[] = [];
+    const containerClient = await getContainerClient();
+
+    for await (const blob of containerClient.listBlobsFlat({
+      includeMetadata: true,
+      includeSnapshots: false,
+      includeTags: true,
+      includeVersions: false,
+      prefix: '',
+    })) {
+      files.push(
+        { name: blob.name, size: blob.properties.contentLength || 0 },
+      );
+    }
+
+    return files;
+  };
+
+  return lib;
+};

+ 1 - 0
apps/app/src/server/service/file-uploader/index.js

@@ -11,6 +11,7 @@ const envToModuleMappings = {
   gridfs:  'gridfs',
   gcp:     'gcs',
   gcs:     'gcs',
+  azure:   'azure',
 };
 
 class FileUploadServiceFactory {

+ 10 - 0
apps/app/src/server/service/g2g-transfer.ts

@@ -39,6 +39,10 @@ const UPLOAD_CONFIG_KEYS = [
   'gcs:uploadNamespace',
   'gcs:referenceFileWithRelayMode',
   'gcs:useOnlyEnvVarsForSomeOptions',
+  'azure:storageAccountName',
+  'azure:storageContainerName',
+  'azure:referenceFileWithRelayMode',
+  'azure:useOnlyEnvVarsForSomeOptions',
 ] as const;
 
 /**
@@ -544,6 +548,8 @@ export class G2GTransferReceiverService implements Receiver {
       bucket: undefined,
       customEndpoint: undefined, // for S3
       uploadNamespace: undefined, // for GCS
+      accountName: undefined, // for Azure Blob
+      containerName: undefined,
     };
 
     // put storage location info to check storage identification
@@ -556,6 +562,10 @@ export class G2GTransferReceiverService implements Receiver {
         attachmentInfo.bucket = configManager.getConfig('crowi', 'gcs:bucket');
         attachmentInfo.uploadNamespace = configManager.getConfig('crowi', 'gcs:uploadNamespace');
         break;
+      case 'azure':
+        attachmentInfo.accountName = configManager.getConfig('crowi', 'azure:storageAccountName');
+        attachmentInfo.containerName = configManager.getConfig('crowi', 'azure:storageContainerName');
+        break;
       default:
     }
 

+ 244 - 1
yarn.lock

@@ -1662,6 +1662,174 @@
   dependencies:
     tslib "^2.3.1"
 
+"@azure/abort-controller@^1.0.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@azure/abort-controller/-/abort-controller-1.1.0.tgz#788ee78457a55af8a1ad342acb182383d2119249"
+  integrity sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==
+  dependencies:
+    tslib "^2.2.0"
+
+"@azure/core-auth@^1.3.0", "@azure/core-auth@^1.4.0", "@azure/core-auth@^1.5.0":
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/@azure/core-auth/-/core-auth-1.5.0.tgz#a41848c5c31cb3b7c84c409885267d55a2c92e44"
+  integrity sha512-udzoBuYG1VBoHVohDTrvKjyzel34zt77Bhp7dQntVGGD0ehVq48owENbBG8fIgkHRNUBQH5k1r0hpoMu5L8+kw==
+  dependencies:
+    "@azure/abort-controller" "^1.0.0"
+    "@azure/core-util" "^1.1.0"
+    tslib "^2.2.0"
+
+"@azure/core-client@^1.4.0":
+  version "1.7.3"
+  resolved "https://registry.yarnpkg.com/@azure/core-client/-/core-client-1.7.3.tgz#f8cb2a1f91e8bc4921fa2e745cfdfda3e6e491a3"
+  integrity sha512-kleJ1iUTxcO32Y06dH9Pfi9K4U+Tlb111WXEnbt7R/ne+NLRwppZiTGJuTD5VVoxTMK5NTbEtm5t2vcdNCFe2g==
+  dependencies:
+    "@azure/abort-controller" "^1.0.0"
+    "@azure/core-auth" "^1.4.0"
+    "@azure/core-rest-pipeline" "^1.9.1"
+    "@azure/core-tracing" "^1.0.0"
+    "@azure/core-util" "^1.0.0"
+    "@azure/logger" "^1.0.0"
+    tslib "^2.2.0"
+
+"@azure/core-http@^3.0.0":
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@azure/core-http/-/core-http-3.0.3.tgz#792c0af7d8ebec7d34a99bbd7108aa370cd49948"
+  integrity sha512-QMib3wXotJMFhHgmJBPUF9YsyErw34H0XDFQd9CauH7TPB+RGcyl9Ayy7iURtJB04ngXhE6YwrQsWDXlSLrilg==
+  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"
+
+"@azure/core-lro@^2.2.0":
+  version "2.5.4"
+  resolved "https://registry.yarnpkg.com/@azure/core-lro/-/core-lro-2.5.4.tgz#b21e2bcb8bd9a8a652ff85b61adeea51a8055f90"
+  integrity sha512-3GJiMVH7/10bulzOKGrrLeG/uCBH/9VtxqaMcB9lIqAeamI/xYQSHJL/KcsLDuH+yTjYpro/u6D/MuRe4dN70Q==
+  dependencies:
+    "@azure/abort-controller" "^1.0.0"
+    "@azure/core-util" "^1.2.0"
+    "@azure/logger" "^1.0.0"
+    tslib "^2.2.0"
+
+"@azure/core-paging@^1.1.1":
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/@azure/core-paging/-/core-paging-1.5.0.tgz#5a5b09353e636072e6a7fc38f7879e11d0afb15f"
+  integrity sha512-zqWdVIt+2Z+3wqxEOGzR5hXFZ8MGKK52x4vFLw8n58pR6ZfKRx3EXYTxTaYxYHc/PexPUTyimcTWFJbji9Z6Iw==
+  dependencies:
+    tslib "^2.2.0"
+
+"@azure/core-rest-pipeline@^1.1.0", "@azure/core-rest-pipeline@^1.9.1":
+  version "1.12.2"
+  resolved "https://registry.yarnpkg.com/@azure/core-rest-pipeline/-/core-rest-pipeline-1.12.2.tgz#a8952164f93b63ab15ae09aac416138da20daecd"
+  integrity sha512-wLLJQdL4v1yoqYtEtjKNjf8pJ/G/BqVomAWxcKOR1KbZJyCEnCv04yks7Y1NhJ3JzxbDs307W67uX0JzklFdCg==
+  dependencies:
+    "@azure/abort-controller" "^1.0.0"
+    "@azure/core-auth" "^1.4.0"
+    "@azure/core-tracing" "^1.0.1"
+    "@azure/core-util" "^1.3.0"
+    "@azure/logger" "^1.0.0"
+    form-data "^4.0.0"
+    http-proxy-agent "^5.0.0"
+    https-proxy-agent "^5.0.0"
+    tslib "^2.2.0"
+
+"@azure/core-tracing@1.0.0-preview.13":
+  version "1.0.0-preview.13"
+  resolved "https://registry.yarnpkg.com/@azure/core-tracing/-/core-tracing-1.0.0-preview.13.tgz#55883d40ae2042f6f1e12b17dd0c0d34c536d644"
+  integrity sha512-KxDlhXyMlh2Jhj2ykX6vNEU0Vou4nHr025KoSEiz7cS3BNiHNaZcdECk/DmLkEB0as5T7b/TpRcehJ5yV6NeXQ==
+  dependencies:
+    "@opentelemetry/api" "^1.0.1"
+    tslib "^2.2.0"
+
+"@azure/core-tracing@^1.0.0", "@azure/core-tracing@^1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@azure/core-tracing/-/core-tracing-1.0.1.tgz#352a38cbea438c4a83c86b314f48017d70ba9503"
+  integrity sha512-I5CGMoLtX+pI17ZdiFJZgxMJApsK6jjfm85hpgp3oazCdq5Wxgh4wMr7ge/TTWW1B5WBuvIOI1fMU/FrOAMKrw==
+  dependencies:
+    tslib "^2.2.0"
+
+"@azure/core-util@^1.0.0", "@azure/core-util@^1.1.0", "@azure/core-util@^1.1.1", "@azure/core-util@^1.2.0", "@azure/core-util@^1.3.0":
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/@azure/core-util/-/core-util-1.6.1.tgz#fea221c4fa43c26543bccf799beb30c1c7878f5a"
+  integrity sha512-h5taHeySlsV9qxuK64KZxy4iln1BtMYlNt5jbuEFN3UFSAd1EwKg/Gjl5a6tZ/W8t6li3xPnutOx7zbDyXnPmQ==
+  dependencies:
+    "@azure/abort-controller" "^1.0.0"
+    tslib "^2.2.0"
+
+"@azure/identity@^3.3.2":
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/@azure/identity/-/identity-3.3.2.tgz#052c33f1e5f952fd4701fb5cffc4da82994a5f28"
+  integrity sha512-aDLwgMXpNBEXOlfCP9r5Rn+inmbnTbadlOnrKI2dPS9Lpf4gHvpYBV+DEZKttakfJ+qn4iWWb7zONQSO3A4XSA==
+  dependencies:
+    "@azure/abort-controller" "^1.0.0"
+    "@azure/core-auth" "^1.5.0"
+    "@azure/core-client" "^1.4.0"
+    "@azure/core-rest-pipeline" "^1.1.0"
+    "@azure/core-tracing" "^1.0.0"
+    "@azure/core-util" "^1.0.0"
+    "@azure/logger" "^1.0.0"
+    "@azure/msal-browser" "^2.37.1"
+    "@azure/msal-common" "^13.1.0"
+    "@azure/msal-node" "^1.17.3"
+    events "^3.0.0"
+    jws "^4.0.0"
+    open "^8.0.0"
+    stoppable "^1.1.0"
+    tslib "^2.2.0"
+    uuid "^8.3.0"
+
+"@azure/logger@^1.0.0":
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@azure/logger/-/logger-1.0.4.tgz#28bc6d0e5b3c38ef29296b32d35da4e483593fa1"
+  integrity sha512-ustrPY8MryhloQj7OWGe+HrYx+aoiOxzbXTtgblbV3xwCqpzUK36phH3XNHQKj3EPonyFUuDTfR3qFhTEAuZEg==
+  dependencies:
+    tslib "^2.2.0"
+
+"@azure/msal-browser@^2.37.1":
+  version "2.38.3"
+  resolved "https://registry.yarnpkg.com/@azure/msal-browser/-/msal-browser-2.38.3.tgz#2f131fa9b7a8a9546fc8d34e5d99ce4c18b04147"
+  integrity sha512-2WuLFnWWPR1IdvhhysT18cBbkXx1z0YIchVss5AwVA95g7CU5CpT3d+5BcgVGNXDXbUU7/5p0xYHV99V5z8C/A==
+  dependencies:
+    "@azure/msal-common" "13.3.1"
+
+"@azure/msal-common@13.3.1", "@azure/msal-common@^13.1.0":
+  version "13.3.1"
+  resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-13.3.1.tgz#012465bf940d12375dc47387b754ccf9d6b92180"
+  integrity sha512-Lrk1ozoAtaP/cp53May3v6HtcFSVxdFrg2Pa/1xu5oIvsIwhxW6zSPibKefCOVgd5osgykMi5jjcZHv8XkzZEQ==
+
+"@azure/msal-node@^1.17.3":
+  version "1.18.4"
+  resolved "https://registry.yarnpkg.com/@azure/msal-node/-/msal-node-1.18.4.tgz#c921b0447c92fb3b0cb1ebf5a9a76fcad2ec7c21"
+  integrity sha512-Kc/dRvhZ9Q4+1FSfsTFDME/v6+R2Y1fuMty/TfwqE5p9GTPw08BPbKgeWinE8JRHRp+LemjQbUZsn4Q4l6Lszg==
+  dependencies:
+    "@azure/msal-common" "13.3.1"
+    jsonwebtoken "^9.0.0"
+    uuid "^8.3.0"
+
+"@azure/storage-blob@^12.16.0":
+  version "12.16.0"
+  resolved "https://registry.yarnpkg.com/@azure/storage-blob/-/storage-blob-12.16.0.tgz#c41fb1e538d6f6e2a6756bfcc69382eededf4fa1"
+  integrity sha512-jz33rUSUGUB65FgYrTRgRDjG6hdPHwfvHe+g/UrwVG8MsyLqSxg9TaW7Yuhjxu1v1OZ5xam2NU6+IpCN0xJO8Q==
+  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"
+
 "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.22.5":
   version "7.22.5"
   resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.5.tgz#234d98e1551960604f1246e6475891a570ad5658"
@@ -3042,6 +3210,11 @@
     "@nodelib/fs.scandir" "2.1.5"
     fastq "^1.6.0"
 
+"@opentelemetry/api@^1.0.1":
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.6.0.tgz#de2c6823203d6f319511898bb5de7e70f5267e19"
+  integrity sha512-OWlrQAnWn9577PhVgqjUvMr1pg57Bc4jv0iL4w0PRuOSRvq67rvHW9Ie/dZVMvCzhSCB+UxhcY/PmCmFj33Q+g==
+
 "@pkgjs/parseargs@^0.11.0":
   version "0.11.0"
   resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
@@ -3365,6 +3538,11 @@
   resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
   integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==
 
+"@tootallnate/once@2":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
+  integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==
+
 "@ts-morph/common@~0.19.0":
   version "0.19.0"
   resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.19.0.tgz#927fcd81d1bbc09c89c4a310a84577fb55f3694e"
@@ -3802,6 +3980,14 @@
   resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197"
   integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
 
+"@types/node-fetch@^2.5.0":
+  version "2.6.8"
+  resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.8.tgz#9a2993583975849c2e1f360b6ca2f11755b2c504"
+  integrity sha512-nnH5lV9QCMPsbEVdTb5Y+F3GQxLSw1xQgIydrb2gSfEavRPs50FnMr+KUaa+LoPSqibm2N+ZZxH7lavZlAT4GA==
+  dependencies:
+    "@types/node" "*"
+    form-data "^4.0.0"
+
 "@types/node@*", "@types/node@>=10.0.0", "@types/node@>=12", "@types/node@>=12.0.0", "@types/node@>=8.9.0", "@types/node@^18.17.5":
   version "18.18.3"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-18.18.3.tgz#e5188135fc2909b46530c798ef49be65083be3fd"
@@ -3947,6 +4133,13 @@
   dependencies:
     "@types/node" "*"
 
+"@types/tunnel@^0.0.3":
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/@types/tunnel/-/tunnel-0.0.3.tgz#f109e730b072b3136347561fc558c9358bb8c6e9"
+  integrity sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA==
+  dependencies:
+    "@types/node" "*"
+
 "@types/unist@*", "@types/unist@^2.0.0":
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e"
@@ -7362,6 +7555,11 @@ events@1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
 
+events@^3.0.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
+  integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
+
 execa@4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a"
@@ -8732,6 +8930,15 @@ http-proxy-agent@^4.0.0:
     agent-base "6"
     debug "4"
 
+http-proxy-agent@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43"
+  integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==
+  dependencies:
+    "@tootallnate/once" "2"
+    agent-base "6"
+    debug "4"
+
 http-signature@~1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
@@ -9969,6 +10176,22 @@ jsonwebtoken@^8.5.1:
     ms "^2.1.1"
     semver "^5.6.0"
 
+jsonwebtoken@^9.0.0:
+  version "9.0.2"
+  resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3"
+  integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==
+  dependencies:
+    jws "^3.2.2"
+    lodash.includes "^4.3.0"
+    lodash.isboolean "^3.0.3"
+    lodash.isinteger "^4.0.4"
+    lodash.isnumber "^3.0.3"
+    lodash.isplainobject "^4.0.6"
+    lodash.isstring "^4.0.1"
+    lodash.once "^4.0.0"
+    ms "^2.1.1"
+    semver "^7.5.4"
+
 jsprim@^1.2.2:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
@@ -11764,6 +11987,13 @@ node-fetch@2.6.7, node-fetch@^2.3.0, node-fetch@^2.6.1:
   dependencies:
     whatwg-url "^5.0.0"
 
+node-fetch@^2.6.7:
+  version "2.7.0"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
+  integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
+  dependencies:
+    whatwg-url "^5.0.0"
+
 node-forge@^0.10.0:
   version "0.10.0"
   resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3"
@@ -15396,7 +15626,7 @@ tsconfig@^7.0.0:
     strip-bom "^3.0.0"
     strip-json-comments "^2.0.0"
 
-"tslib@1 || 2", tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.5.0, tslib@^2.6.1:
+"tslib@1 || 2", tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.5.0, tslib@^2.6.1:
   version "2.6.2"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
   integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
@@ -15434,6 +15664,11 @@ tunnel-agent@^0.6.0:
   dependencies:
     safe-buffer "^5.0.1"
 
+tunnel@^0.0.6:
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c"
+  integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==
+
 tweetnacl@^0.14.3, tweetnacl@~0.14.0:
   version "0.14.5"
   resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
@@ -16322,6 +16557,14 @@ xml2js@^0.4.23:
     sax ">=0.6.0"
     xmlbuilder "~11.0.0"
 
+xml2js@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.5.0.tgz#d9440631fbb2ed800203fad106f2724f62c493b7"
+  integrity sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==
+  dependencies:
+    sax ">=0.6.0"
+    xmlbuilder "~11.0.0"
+
 xmlbuilder@^15.1.1:
   version "15.1.1"
   resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5"