فهرست منبع

Merge branch 'master' into feat/ldap-group-sync

Futa Arai 2 سال پیش
والد
کامیت
d6ff69488d
42فایلهای تغییر یافته به همراه1966 افزوده شده و 199 حذف شده
  1. 1 1
      apps/app/.env.test
  2. 6 2
      apps/app/package.json
  3. 7 0
      apps/app/public/static/locales/en_US/admin.json
  4. 7 0
      apps/app/public/static/locales/ja_JP/admin.json
  5. 7 0
      apps/app/public/static/locales/zh_CN/admin.json
  6. 78 0
      apps/app/src/client/services/AdminAppContainer.js
  7. 211 0
      apps/app/src/components/Admin/App/AzureSetting.tsx
  8. 75 2
      apps/app/src/components/Admin/App/FileUploadSetting.tsx
  9. 12 0
      apps/app/src/components/Admin/App/MaskedInput.module.scss
  10. 43 0
      apps/app/src/components/Admin/App/MaskedInput.tsx
  11. 1 0
      apps/app/src/features/questionnaire/interfaces/growi-info.ts
  12. 14 0
      apps/app/src/interfaces/res/admin/app-settings.ts
  13. 40 1
      apps/app/src/server/routes/apiv3/app-settings.js
  14. 0 22
      apps/app/src/server/routes/attachment.js
  15. 0 1
      apps/app/src/server/routes/index.js
  16. 48 0
      apps/app/src/server/service/config-loader.ts
  17. 12 0
      apps/app/src/server/service/config-manager.ts
  18. 260 0
      apps/app/src/server/service/file-uploader/azure.ts
  19. 1 0
      apps/app/src/server/service/file-uploader/index.js
  20. 10 0
      apps/app/src/server/service/g2g-transfer.ts
  21. 0 0
      apps/app/test-with-vite/.eslintrc.cjs
  22. 5 0
      apps/app/test-with-vite/download-mongo-binary/index.spec.ts
  23. 15 0
      apps/app/test-with-vite/download-mongo-binary/vitest.config.ts
  24. 4 0
      apps/app/test-with-vite/package.json
  25. 1 1
      apps/app/test-with-vite/setup/mongoms.ts
  26. 4 4
      package.json
  27. 1 1
      packages/core/package.json
  28. 1 1
      packages/hackmd/package.json
  29. 1 1
      packages/presentation/package.json
  30. 3 0
      packages/preset-templates/dist/marp-example/ja_JP/meta.json
  31. 326 0
      packages/preset-templates/dist/marp-example/ja_JP/template.md
  32. 3 0
      packages/preset-templates/dist/marp-example/zh_CN/meta.json
  33. 325 0
      packages/preset-templates/dist/marp-example/zh_CN/template.md
  34. 1 1
      packages/preset-templates/package.json
  35. 1 1
      packages/preset-themes/package.json
  36. 1 1
      packages/remark-attachment-refs/package.json
  37. 1 1
      packages/remark-drawio/package.json
  38. 1 1
      packages/remark-growi-directive/package.json
  39. 1 1
      packages/remark-lsx/package.json
  40. 1 1
      packages/slack/package.json
  41. 1 1
      packages/ui/package.json
  42. 436 154
      yarn.lock

+ 1 - 1
apps/app/.env.test

@@ -5,5 +5,5 @@
 ## > To prevent accidentally leaking env variables to the client, only variables prefixed with
 ## > VITE_ are exposed to your Vite-processed code. e.g. for the following env variables:
 ##
-VITE_MONGOMS_VERSION="6.0.6"
+VITE_MONGOMS_VERSION="6.0.9"
 # VITE_MONGOMS_DEBUG=1

+ 6 - 2
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.2.4-RC.0",
+  "version": "6.3.0-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -40,6 +40,7 @@
     "reg:run": "reg-suit run",
     "vitest:run": "vitest run config src --coverage",
     "vitest:run:integ": "vitest run -c vitest.config.integ.ts src --coverage",
+    "previtest:run:integ": "vitest run -c test-with-vite/download-mongo-binary/vitest.config.ts test-with-vite/download-mongo-binary",
     "//// misc": "",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
@@ -59,6 +60,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",
@@ -222,6 +225,7 @@
     "@types/express": "^4.17.11",
     "@types/jest": "^29.5.2",
     "@types/react-scroll": "^1.8.4",
+    "@vitest/coverage-v8": "^0.34.6",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
     "bootstrap": "^4.6.1",
@@ -243,7 +247,7 @@
     "jquery": "^3.7.0",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
-    "mongodb-memory-server": "^8.15.1",
+    "mongodb-memory-server-core": "^9.1.1",
     "morgan": "^1.10.0",
     "null-loader": "^4.0.1",
     "penpal": "^4.0.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

@@ -389,6 +389,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

@@ -389,6 +389,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 });

+ 0 - 22
apps/app/src/server/routes/attachment.js

@@ -310,28 +310,6 @@ module.exports = function(crowi, app) {
     return responseForAttachment(req, res, brandLogoAttachment);
   };
 
-  /**
-   * @api {get} /attachments.obsoletedGetForMongoDB get attachments from mongoDB
-   * @apiName get
-   * @apiGroup Attachment
-   *
-   * @apiParam {String} pageId, fileName
-   */
-  api.obsoletedGetForMongoDB = async function(req, res) {
-    if (crowi.configManager.getConfig('crowi', 'app:fileUploadType') !== 'mongodb') {
-      return res.status(400);
-    }
-
-    const pageId = req.params.pageId;
-    const fileName = req.params.fileName;
-    const filePath = `attachment/${pageId}/${fileName}`;
-
-    const attachment = await Attachment.findOne({ filePath });
-
-    return responseForAttachment(req, res, attachment);
-  };
-
-
   /**
    * @swagger
    *

+ 0 - 1
apps/app/src/server/routes/index.js

@@ -155,7 +155,6 @@ module.exports = function(crowi, app) {
   app.get('/me/*'                                 , loginRequiredStrictly, next.delegateToNext);
   app.get('/attachment/:id([0-9a-z]{24})'         , certifySharedPageAttachmentMiddleware , loginRequired, attachment.api.get);
   app.get('/attachment/profile/:id([0-9a-z]{24})' , loginRequired, attachment.api.get);
-  app.get('/attachment/:pageId/:fileName'       , loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
   app.get('/download/:id([0-9a-z]{24})'         , certifySharedPageAttachmentMiddleware, loginRequired, attachment.api.download);
 
   app.get('/_search'                            , loginRequired, next.delegateToNext);

+ 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')
+      )
     ));
   }
 

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

@@ -0,0 +1,260 @@
+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: {
+        blobContentType: attachment.fileFormat,
+        blobContentDisposition: `attachment;filename*=UTF-8''${encodeURIComponent(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*=UTF-8''${encodeURIComponent(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: false,
+      includeSnapshots: false,
+      includeTags: false,
+      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:
     }
 

+ 0 - 0
apps/app/test-with-vite/.eslintrc.js → apps/app/test-with-vite/.eslintrc.cjs


+ 5 - 0
apps/app/test-with-vite/download-mongo-binary/index.spec.ts

@@ -0,0 +1,5 @@
+describe('Download mongo-binary', () => {
+  it('should be success', () => {
+    expect(true).toBeTruthy();
+  });
+});

+ 15 - 0
apps/app/test-with-vite/download-mongo-binary/vitest.config.ts

@@ -0,0 +1,15 @@
+import { defineConfig, mergeConfig } from 'vitest/config';
+
+import configShared from '../../vitest.config';
+
+export default mergeConfig(
+  configShared,
+  defineConfig({
+    test: {
+      hookTimeout: 60000, // increased for downloading MongoDB binary file
+      setupFiles: [
+        './test-with-vite/setup/mongoms.ts',
+      ],
+    },
+  }),
+);

+ 4 - 0
apps/app/test-with-vite/package.json

@@ -0,0 +1,4 @@
+{
+  "$schame": "http://json-schema.org/schema",
+  "type": "module"
+}

+ 1 - 1
apps/app/test-with-vite/setup/mongoms.ts

@@ -1,4 +1,4 @@
-import { MongoMemoryServer } from 'mongodb-memory-server';
+import { MongoMemoryServer } from 'mongodb-memory-server-core';
 import mongoose from 'mongoose';
 
 import { mongoOptions } from '~/server/util/mongoose-utils';

+ 4 - 4
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "6.2.4-RC.0",
+  "version": "6.3.0-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -60,7 +60,7 @@
     "@typescript-eslint/eslint-plugin": "^5.59.7",
     "@typescript-eslint/parser": "^5.59.7",
     "@vitejs/plugin-react": "^4.0.3",
-    "@vitest/coverage-c8": "^0.31.1",
+    "@vitest/coverage-v8": "^0.34.6",
     "@vitest/ui": "^0.31.1",
     "cypress": "^13.3.0",
     "cypress-wait-until": "^2.0.1",
@@ -90,10 +90,10 @@
     "ts-node-dev": "^2.0.0",
     "tsconfig-paths": "^3.9.0",
     "typescript": "~5.0.0",
-    "vite": "^4.4.0",
+    "vite": "^4.5.0",
     "vite-plugin-dts": "^2.3.0",
     "vite-tsconfig-paths": "^4.2.0",
-    "vitest": "^0.31.4",
+    "vitest": "^0.34.6",
     "vitest-mock-extended": "^1.1.3"
   },
   "engines": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "6.2.4-RC.0",
+  "version": "6.3.0-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/hackmd/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/hackmd",
-  "version": "6.2.4-RC.0",
+  "version": "6.3.0-RC.0",
   "description": "GROWI js and css files to use hackmd",
   "license": "MIT",
   "type": "module",

+ 1 - 1
packages/presentation/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/presentation",
-  "version": "6.2.4-RC.0",
+  "version": "6.3.0-RC.0",
   "description": "GROWI plugin for presentation",
   "license": "MIT",
   "keywords": [

+ 3 - 0
packages/preset-templates/dist/marp-example/ja_JP/meta.json

@@ -0,0 +1,3 @@
+{
+  "title": "Marpを使ったプレゼンテーションの例"
+}

+ 326 - 0
packages/preset-templates/dist/marp-example/ja_JP/template.md

@@ -0,0 +1,326 @@
+---
+marp: true
+---
+
+Marp
+===
+
+![h:250](https://avatars1.githubusercontent.com/u/20685754?v=4)
+
+##### Markdown プレゼンテーションのエコシステム
+
+###### by Marp チーム ([@marp-team][marp-team])
+
+[marp-team]: https://github.com/marp-team
+[marpit]: https://github.com/marp-team/marpit
+[marp-core]: https://github.com/marp-team/marp-core
+[marp-cli]: https://github.com/marp-team/marp-cli
+[marp-vscode]: https://github.com/marp-team/marp-vscode
+
+---
+
+# 特徴
+
+- :memo: **Markdown 記法を使用したプレゼンテーションの作成** (CommonMark)
+- :factory: [Marpit framework][marpit] を基に構築: プレゼンテーションを作成するための新しく軽量なフレームワーク
+- :gear: [Marp Core][marp-core]: npm を介してコアエンジンやビルトインテーマを使いやすく
+- :tv: [Marp CLI][marp-cli]:  Markdown を HTML 、 PDF 、 PPTX 、画像に変換可能
+- :vs: [Marp for VS Code][marp-vscode]: 編集中のスライドをライブプレビュー表示
+- and more...
+
+---
+
+# スライドの記述のしかた
+
+やり方はとてもシンプルです。ハイフン記号 (e.g. `---`)でページを分割します。
+
+```markdown
+# スライド 1
+
+本文
+
+---
+
+# スライド 2
+
+本文
+```
+
+---
+
+# Directives
+
+Marp では美しいスライド作成を支援するため、**"Directives"** という拡張構文を用意しています。
+
+Markdown 記法の本文の前に挿入します:
+
+```
+---
+theme: default
+---
+```
+
+または本文のどこかに HTML 形式で記述します:
+
+```html
+<!-- theme: default -->
+```
+
+https://marpit.marp.app/directives
+
+---
+
+## [グローバル Directives](https://marpit.marp.app/directives?id=global-directives)
+
+- `theme` : テーマを選ぶ
+- `size` : スライドのサイズを `16:9` か `4:3` で選択する *(Marpit frameworkを除く)* 
+- [`headingDivider`](https://marpit.marp.app/directives?id=heading-divider) : 任意の見出しの前にスライドのページ区切りを挿入する
+
+```
+---
+theme: gaia
+size: 4:3
+---
+
+# 内容
+```
+
+> Marp では以下の [built-in themes in Marp Core](https://github.com/marp-team/marp-core/tree/master/themes#readme) が利用可能 : `default` `gaia` `uncover`.
+
+---
+
+## [ローカル Directives](https://marpit.marp.app/directives?id=local-directives)
+
+スライドページ毎に設定できる値一覧
+
+- `paginate` : `true`でページ数を表示する
+- `header` : ヘッダーの内容を指定する
+- `footer` : フッターの内容を指定する
+- `class` : 現在のスライドにHTMLのクラス設定をする
+- `color` : 文字色を指定する
+- `backgroundColor` : 背景色を指定する
+
+---
+
+### スポット Directives
+
+ローカル構文は**任意のページとそれ以降のページ**に適用されます。 
+
+`_class` のようにアンダーバーと接頭辞を使うことで一つのページに適用できます。
+
+![bg right 95%](https://marpit.marp.app/assets/directives.png)
+
+---
+
+### 活用例
+
+このページは  [defined in Marp built-in theme](https://github.com/marp-team/marp-core/tree/master/themes#readme) で色彩を反転させています。
+
+<!-- _class: invert -->
+
+```html
+<!-- _class: invert -->
+```
+
+---
+
+# [画像イメージのための構文](https://marpit.marp.app/image-syntax)
+
+以下のキーワードを使って、画像サイズの変更やフィルターを適用できます
+ : `width` (`w`) 、 `height` (`h`) 、 CSS のフィルター
+
+```markdown
+![width:100px height:100px](image.png)
+```
+
+```markdown
+![blur sepia:50%](filters.png)
+```
+
+[resizing image syntax](https://marpit.marp.app/image-syntax?id=resizing-image)  と  [a list of CSS filters](https://marpit.marp.app/image-syntax?id=image-filters) を指定してください。
+
+![w:100px h:100px](https://avatars1.githubusercontent.com/u/20685754?v=4) ![w:100 h:100 blur sepia:50%](https://avatars1.githubusercontent.com/u/20685754?v=4)
+
+---
+
+# [背景イメージのための構文](https://marpit.marp.app/image-syntax?id=slide-backgrounds)
+
+ `bg` でスライドに背景イメージを設定できます。
+
+```markdown
+![bg opacity](https://yhatt-marp-cli-example.netlify.com/assets/gradient.jpg)
+```
+
+![bg opacity](https://yhatt-marp-cli-example.netlify.com/assets/gradient.jpg)
+
+---
+
+## 複数の背景イメージを利用する ([Marpit's advanced backgrounds](https://marpit.marp.app/image-syntax?id=advanced-backgrounds))
+
+Marp では複数の背景イメージを利用できます。
+
+```markdown
+![bg blur:3px](https://fakeimg.pl/800x600/fff/ccc/?text=A)
+![bg blur:3px](https://fakeimg.pl/800x600/eee/ccc/?text=B)
+![bg blur:3px](https://fakeimg.pl/800x600/ddd/ccc/?text=C)
+```
+
+ `vertical` で、アライメントの方向も変更可能です。
+
+![bg blur:3px](https://fakeimg.pl/800x600/fff/ccc/?text=A)
+![bg blur:3px](https://fakeimg.pl/800x600/eee/ccc/?text=B)
+![bg blur:3px](https://fakeimg.pl/800x600/ddd/ccc/?text=C)
+
+---
+
+## [背景を分割する](https://marpit.marp.app/image-syntax?id=split-backgrounds)
+
+Marp では背景を分割する [Deckset](https://docs.deckset.com/English.lproj/Media/01-background-images.html#split-slides) を利用できます。
+
+ `bg` + `left` / `right` で背景イメージを配置するスぺースを指定可能です。
+
+```markdown
+![bg right](image.jpg)
+```
+
+![bg right](https://images.unsplash.com/photo-1568488789544-e37edf90eb67?crop=entropy&cs=tinysrgb&fit=crop&fm=jpg&h=720&ixlib=rb-1.2.1&q=80&w=640)
+
+<!-- _footer: "*Photo by [Mohamed Nohassi](https://unsplash.com/@coopery?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) on [Unsplash](https://unsplash.com/?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)*" -->
+
+---
+
+## [階層リスト](https://marpit.marp.app/fragmented-list)
+
+Marp ではアスタリスク記号をつけることで、各コンテンツを階層リストとして分類します。 (_**HTML 形式のエクスポートのみ** by [Marp CLI][marp-cli] / [Marp for VS Code][marp-vscode]_)
+
+```markdown
+# 箇条書きリスト
+
+- 1
+- 2
+- 3
+
+---
+
+# 階層リスト
+
+* 1
+* 2
+* 3
+```
+
+---
+
+## 数式の設定 ( [Marp Core][marp-core] のみ)
+
+[KaTeX](https://katex.org/) math typesetting は $ax^2+bc+c$ のように [Pandoc's math syntax](https://pandoc.org/MANUAL.html#math) で利用できます。
+
+$$I_{xx}=\int\int_Ry^2f(x,y)\cdot{}dydx$$
+
+```tex
+$ax^2+bc+c$
+```
+```tex
+$$I_{xx}=\int\int_Ry^2f(x,y)\cdot{}dydx$$
+```
+
+---
+
+## オートスケーリング ( [Marp Core][marp-core] のみ)
+
+*Several built-in themes* はコードブロックと typesettings でオートスケーリングされます。
+
+```text
+Too long code block will be scaled-down automatically. ------------>
+```
+```text
+Too long code block will be scaled-down automatically. ------------------------>
+```
+```text
+Too long code block will be scaled-down automatically. ------------------------------------------------>
+```
+
+---
+
+##### <!--fit--> ヘッダーの自動調整 ( [Marp Core][marp-core] のみ)は
+##### <!--fit--> `<!--fit-->` のような注釈をヘッダーに入れることで利用可能です。
+
+<br />
+
+```html
+## <!--fit--> Auto-fitting header (only for Marp Core)
+```
+
+---
+
+## [CSS テーマ](https://marpit.marp.app/theme-css)
+
+Marp では各スライドのコンテナとして `<section>` を使います。その他は基本の Markdown 記法と同様です。 [Marp CLI][marp-cli] と [Marp for VS Code][marp-vscode] ではカスタムテーマを利用可能です。
+
+```css
+/* @theme your-theme */
+
+@import 'default';
+
+section {
+  /* Specify slide size */
+  width: 960px;
+  height: 720px;
+}
+
+h1 {
+  font-size: 30px;
+  color: #c33;
+}
+```
+
+---
+
+## [Markdown 記法のスタイル調整](https://marpit.marp.app/theme-css?id=tweak-style-through-markdown)
+
+Markdown 記法の `<style>` タグは theme CSS のコンテキストで機能します。
+
+```markdown
+---
+theme: default
+---
+
+<style>
+section {
+  background: yellow;
+}
+</style>
+
+Re-painted yellow background, ha-ha.
+```
+
+> `section.custom-class { ... }` のように class を活用することで、カスタムスタイルの追加も可能です。
+> `<!-- _class: custom-class -->` でスタイルを適用します。
+
+---
+
+## [特定範囲へのスタイル適用](https://marpit.marp.app/theme-css?id=scoped-style)
+
+現在のページの特定範囲へスタイルを適用したい場合は `<style scoped>` をご利用ください。
+
+```markdown
+<style scoped>
+a {
+  color: green;
+}
+</style>
+
+![Green link!](https://marp.app/)
+```
+
+<style scoped>
+a { color: green; }
+</style>
+
+---
+
+# スライド作成を楽しみましょう! :v: <!--fit-->
+
+##### ![w:1em h:1em](https://avatars1.githubusercontent.com/u/20685754?v=4)  Marp: Markdown  — https://marp.app/
+
+###### by Marp チーム ([@marp-team][marp-team])

+ 3 - 0
packages/preset-templates/dist/marp-example/zh_CN/meta.json

@@ -0,0 +1,3 @@
+{
+  "title": "使用 Marp 的演示示例"
+}

+ 325 - 0
packages/preset-templates/dist/marp-example/zh_CN/template.md

@@ -0,0 +1,325 @@
+---
+marp: true
+---
+
+Marp
+===
+
+![h:250](https://avatars1.githubusercontent.com/u/20685754?v=4)
+
+##### Markdown 演示生态系统
+
+###### by Marp 团队 ([@marp-team][marp-team])
+
+[marp-team]: https://github.com/marp-team
+[marpit]: https://github.com/marp-team/marpit
+[marp-core]: https://github.com/marp-team/marp-core
+[marp-cli]: https://github.com/marp-team/marp-cli
+[marp-vscode]: https://github.com/marp-team/marp-vscode
+
+---
+
+# 特点
+
+- :memo: **使用普通的 Markdown 编写投影片** (CommonMark)
+- :factory: 基于 [Marpit framework][marpit]: 一个全新的轻量级框架,用于创建投影片
+- :gear: [Marp Core][marp-core]: 通过 npm 轻松启动核心引擎并使用内置主题
+- :tv: [Marp CLI][marp-cli]: 将 Markdown 转换为 HTML、 PDF、 PPTX、 和图像
+- :vs: [Marp for VS Code][marp-vscode]: 在编辑时实时预览您的投影片
+- 等等...
+
+---
+
+# 如何编写幻灯片?
+
+通过水平标尺拆分页面 (e.g. `---`)。 这很简单。
+
+```markdown
+# 幻灯片 1
+
+张三李四
+
+---
+
+# 幻灯片 2
+
+张三李四
+```
+
+---
+
+# Directives
+
+Marp 引入了名为 **"Directives"** 的扩展语法,以支持创建漂亮的幻灯片。
+
+在 Markdown 正文之前插入:
+
+```
+---
+theme: default
+---
+```
+
+或者在正文的任意位置使用 HTML 进行描述:
+
+```html
+<!-- theme: default -->
+```
+
+https://marpit.marp.app/directives
+
+---
+
+## [全球 directives](https://marpit.marp.app/directives?id=global-directives)
+
+- `theme`: 选择主题
+- `size`: 选择幻灯片尺寸为 `16:9` 或 `4:3` *(不包括 Marpit framework)*
+- [`headingDivider`](https://marpit.marp.app/directives?id=heading-divider): 在任意标题之前插入幻灯片的分页符
+
+```
+---
+theme: gaia
+size: 4:3
+---
+
+# Content
+```
+
+> Marp 中有以下 [built-in themes in Marp Core](https://github.com/marp-team/marp-core/tree/master/themes#readme) 可供使用 : `default`, `gaia`, and `uncover`.
+
+---
+
+## [局部 directives](https://marpit.marp.app/directives?id=local-directives)
+
+每个幻灯片页面可设置的值列表
+
+- `paginate`: 使用 `true` 显示页数
+- `header`: 指定页眉内容
+- `footer`: 指定页脚内容
+- `class`: 为当前幻灯片设置 HTML 类
+- `color`: 指定文字颜色
+- `backgroundColor`: 指定背景颜色
+
+---
+
+### 点 directives
+
+局部 directives 将应用于 **defined page and following pages**.
+
+通过使用下划线和前缀,例如 `_class` ,可以将其应用于单个页面。
+
+![bg right 95%](https://marpit.marp.app/assets/directives.png)
+
+---
+
+### 例子
+
+该页面使用了反转色彩方案 [defined in Marp built-in theme](https://github.com/marp-team/marp-core/tree/master/themes#readme)。
+
+<!-- _class: invert -->
+
+```html
+<!-- _class: invert -->
+```
+
+---
+
+# [图像标记语言的语法](https://marpit.marp.app/image-syntax)
+
+您可以通过关键词调整图像大小并应用过滤器 : `width` (`w`), `height` (`h`), 和滤镜CSS关键词
+
+```markdown
+![width:100px height:100px](image.png)
+```
+
+```markdown
+![blur sepia:50%](filters.png)
+```
+
+请指定 [resizing image syntax](https://marpit.marp.app/image-syntax?id=resizing-image) 和 [a list of CSS filters](https://marpit.marp.app/image-syntax?id=image-filters)。
+
+![w:100px h:100px](https://avatars1.githubusercontent.com/u/20685754?v=4) ![w:100 h:100 blur sepia:50%](https://avatars1.githubusercontent.com/u/20685754?v=4)
+
+---
+
+# [背景图像的语法](https://marpit.marp.app/image-syntax?id=slide-backgrounds)
+
+您可以使用 `bg` 关键字为幻灯片设置背景图像。
+
+```markdown
+![bg opacity](https://yhatt-marp-cli-example.netlify.com/assets/gradient.jpg)
+```
+
+![bg opacity](https://yhatt-marp-cli-example.netlify.com/assets/gradient.jpg)
+
+---
+
+## 利用多个背景图像 ([Marpit's advanced backgrounds](https://marpit.marp.app/image-syntax?id=advanced-backgrounds))
+
+Marp 可以使用多个背景图像。
+
+```markdown
+![bg blur:3px](https://fakeimg.pl/800x600/fff/ccc/?text=A)
+![bg blur:3px](https://fakeimg.pl/800x600/eee/ccc/?text=B)
+![bg blur:3px](https://fakeimg.pl/800x600/ddd/ccc/?text=C)
+```
+
+还可以通过包含 `vertical` 关键字来更改对齐方向。
+
+![bg blur:3px](https://fakeimg.pl/800x600/fff/ccc/?text=A)
+![bg blur:3px](https://fakeimg.pl/800x600/eee/ccc/?text=B)
+![bg blur:3px](https://fakeimg.pl/800x600/ddd/ccc/?text=C)
+
+---
+
+## [分割背景](https://marpit.marp.app/image-syntax?id=split-backgrounds)
+
+Marp 可以使用 [Deckset](https://docs.deckset.com/English.lproj/Media/01-background-images.html#split-slides) 风格的分割背景。
+
+通过 `bg` + `left` / `right` 关键字为背景留出空间。
+
+```markdown
+![bg right](image.jpg)
+```
+
+![bg right](https://images.unsplash.com/photo-1568488789544-e37edf90eb67?crop=entropy&cs=tinysrgb&fit=crop&fm=jpg&h=720&ixlib=rb-1.2.1&q=80&w=640)
+
+<!-- _footer: "*Photo by [Mohamed Nohassi](https://unsplash.com/@coopery?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) on [Unsplash](https://unsplash.com/?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)*" -->
+
+---
+
+## [分段列表](https://marpit.marp.app/fragmented-list)
+
+Marp 将列表与星号标记解析为分段列表,以逐一显示内容。 (_**仅适用于导出的 HTML** by [Marp CLI][marp-cli] / [Marp for VS Code][marp-vscode]_)
+
+```markdown
+# 项目符号列表
+
+- 一
+- 二
+- 三
+
+---
+
+# 分段列表
+
+* 一
+* 二
+* 三
+```
+
+---
+
+## 数学排版 (仅适用于 [Marp Core][marp-core])
+
+使用 [KaTeX](https://katex.org/) 进行数学排版,例如 $ax^2+bc+c$ 可以与 [Pandoc's math syntax](https://pandoc.org/MANUAL.html#math) 一起使用。
+
+$$I_{xx}=\int\int_Ry^2f(x,y)\cdot{}dydx$$
+
+```tex
+$ax^2+bc+c$
+```
+```tex
+$$I_{xx}=\int\int_Ry^2f(x,y)\cdot{}dydx$$
+```
+
+---
+
+## 自动缩放 (仅适用于 [Marp Core][marp-core])
+
+*Several built-in themes* 支持对代码块和数学排版进行自动缩放。
+
+```text
+Too long code block will be scaled-down automatically. ------------>
+```
+```text
+Too long code block will be scaled-down automatically. ------------------------>
+```
+```text
+Too long code block will be scaled-down automatically. ------------------------------------------------>
+```
+
+---
+
+##### <!--fit--> 自动调整标题 (仅适用于 [Marp Core][marp-core])
+##### <!--fit--> 在标题中注释 `<!--fit-->` 即可使用。
+
+<br />
+
+```html
+## <!--fit--> Auto-fitting header (only for Marp Core)
+```
+
+---
+
+## [主题样式表](https://marpit.marp.app/theme-css)
+
+Marp 使用 `<section>` 作为每个幻灯片的容器。 其他的样式与普通的 Markdown 样式相同。定制主题可在 [Marp CLI][marp-cli] 和 [Marp for VS Code][marp-vscode] 中使用。
+
+```css
+/* @theme your-theme */
+
+@import 'default';
+
+section {
+  /* Specify slide size */
+  width: 960px;
+  height: 720px;
+}
+
+h1 {
+  font-size: 30px;
+  color: #c33;
+}
+```
+
+---
+
+## [在 Markdown 中微调样式](https://marpit.marp.app/theme-css?id=tweak-style-through-markdown)
+
+Markdown 中的 `<style>` 标签将在主题 CSS 的上下文中起作用。
+
+```markdown
+---
+theme: default
+---
+
+<style>
+section {
+  background: yellow;
+}
+</style>
+
+Re-painted yellow background, ha-ha.
+```
+
+> 您还可以通过类别添加自定义样式,例如 `section.custom-class { ... }` 。
+> 通过 `<!-- _class: custom-class -->` 应用样式。
+
+---
+
+## [局部样式](https://marpit.marp.app/theme-css?id=scoped-style)
+
+如果您想为当前页面设置一次性样式,可以使用 `<style scoped>` 。
+
+```markdown
+<style scoped>
+a {
+  color: green;
+}
+</style>
+
+![Green link!](https://marp.app/)
+```
+
+<style scoped>
+a { color: green; }
+</style>
+
+---
+
+# 尽情享受幻灯片创作吧! :v: <!--fit-->
+
+##### ![w:1em h:1em](https://avatars1.githubusercontent.com/u/20685754?v=4)  Marp: Markdown presentation ecosystem — https://marp.app/
+
+###### by Marp 团队 ([@marp-team][marp-team])

+ 1 - 1
packages/preset-templates/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/preset-templates",
-  "version": "6.2.4-RC.0",
+  "version": "6.3.0-RC.0",
   "scripts": {
     "test": "vitest run",
     "version": "yarn version --no-git-tag-version --preid=RC"

+ 1 - 1
packages/preset-themes/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@growi/preset-themes",
   "description": "GROWI preset themes",
-  "version": "6.2.4-RC.0",
+  "version": "6.3.0-RC.0",
   "license": "MIT",
   "main": "dist/libs/preset-themes.umd.js",
   "module": "dist/libs/preset-themes.mjs",

+ 1 - 1
packages/remark-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-attachment-refs",
-  "version": "6.2.4-RC.0",
+  "version": "6.3.0-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/remark-drawio/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-drawio",
-  "version": "6.2.4-RC.0",
+  "version": "6.3.0-RC.0",
   "description": "remark plugin to draw diagrams with draw.io (diagrams.net)",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/remark-growi-directive/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-growi-directive",
-  "version": "6.2.4-RC.0",
+  "version": "6.3.0-RC.0",
   "description": "remark plugin to support GROWI plugin (forked from remark-directive@2.0.1)",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/remark-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-lsx",
-  "version": "6.2.4-RC.0",
+  "version": "6.3.0-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "6.2.4-RC.0",
+  "version": "6.3.0-RC.0",
   "license": "MIT",
   "type": "module",
   "main": "dist/index.cjs",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "6.2.4-RC.0",
+  "version": "6.3.0-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 436 - 154
yarn.lock


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است