Просмотр исходного кода

Merge pull request #3220 from weseek/imprv/4614-refactor-file-uploader

Imprv/4614 refactor file uploader
itizawa 5 лет назад
Родитель
Сommit
84cc8848dd

+ 5 - 0
resource/locales/en_US/admin/admin.json

@@ -43,6 +43,11 @@
     "confirm_to_initialize_mail_settings": "You can't restore to the current settings. Are you sure you want to initialize e-mail settings?",
     "file_upload_settings":"File Upload Settings",
     "file_upload_method":"File Upload Method",
+    "file_delivery_method":"File Delivery Method",
+    "file_delivery_method_redirect":"Redirect",
+    "file_delivery_method_relay":"Internal System Relay",
+    "file_delivery_method_redirect_info":"Redirect: It redirects to a signed URL without GROWI server, it gives excellent performance.",
+    "file_delivery_method_relay_info":"Internal System Relay: The GROWI server delivers to clients, it provides complete security.",
     "fixed_by_env_var": "This is fixed by the env var <code>FILE_UPLOAD={{fileUploadType}}</code>.",
     "gcs_label": "GCP(GCS)",
     "aws_label": "AWS(S3)",

+ 5 - 0
resource/locales/ja_JP/admin/admin.json

@@ -43,6 +43,11 @@
     "confirm_to_initialize_mail_settings": "一度初期化した設定は戻せません。本当に初期化しますか?",
     "file_upload_settings":"ファイルアップロード設定",
     "file_upload_method":"ファイルアップロード方法",
+    "file_delivery_method":"ファイルの配信方法",
+    "file_delivery_method_redirect":"リダイレクト",
+    "file_delivery_method_relay":"内部システム中継",
+    "file_delivery_method_redirect_info":"リダイレクト: GROWIサーバーを介さずに署名付きURLにリダイレクトされるため、優れたパフォーマンスを出します。",
+    "file_delivery_method_relay_info":"内部システム中継: GROWIサーバーがクライアントに配信するため、完全なセキュリティーを提供します。",
     "gcs_label": "GCP(GCS)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",

+ 5 - 0
resource/locales/zh_CN/admin/admin.json

@@ -43,6 +43,11 @@
     "confirm_to_initialize_mail_settings": "当前设置将被清空且不可恢复。确认重置?",
     "file_upload_settings":"文件上传设置",
     "file_upload_method":"文件上传方法",
+    "file_delivery_method":"File Delivery Method",
+    "file_delivery_method_redirect":"Redirect",
+    "file_delivery_method_relay":"Internal System Relay",
+    "file_delivery_method_redirect_info":"Redirect: It redirects to a signed URL without GROWI server, it gives excellent performance.",
+    "file_delivery_method_relay_info":"Internal System Relay: The GROWI server delivers to clients, it provides complete security.",
     "gcs_label": "GCP(GCS)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",

+ 60 - 12
src/client/js/components/Admin/App/AwsSetting.jsx

@@ -9,9 +9,55 @@ import AdminAppContainer from '../../../services/AdminAppContainer';
 
 function AwsSetting(props) {
   const { t, adminAppContainer } = props;
+  const { s3ReferenceFileWithRelayMode } = adminAppContainer.state;
 
   return (
     <React.Fragment>
+
+      <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="ddS3ReferenceFileWithRelayMode"
+              data-toggle="dropdown"
+              aria-haspopup="true"
+              aria-expanded="true"
+            >
+              {s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
+              {!s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')}
+            </button>
+            <div className="dropdown-menu" aria-labelledby="ddS3ReferenceFileWithRelayMode">
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { adminAppContainer.changeS3ReferenceFileWithRelayMode(true) }}
+              >
+                {t('admin:app_setting.file_delivery_method_relay')}
+              </button>
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { adminAppContainer.changeS3ReferenceFileWithRelayMode(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>
+
       <div className="row form-group">
         <label className="text-left text-md-right col-md-3 col-form-label">
           {t('admin:app_setting.region')}
@@ -22,8 +68,8 @@ function AwsSetting(props) {
             placeholder={`${t('eg')} ap-northeast-1`}
             defaultValue={adminAppContainer.state.s3Region || ''}
             onChange={(e) => {
-                adminAppContainer.changeS3Region(e.target.value);
-              }}
+              adminAppContainer.changeS3Region(e.target.value);
+            }}
           />
         </div>
       </div>
@@ -39,8 +85,8 @@ function AwsSetting(props) {
             placeholder={`${t('eg')} http://localhost:9000`}
             defaultValue={adminAppContainer.state.s3CustomEndpoint || ''}
             onChange={(e) => {
-                adminAppContainer.changeS3CustomEndpoint(e.target.value);
-              }}
+              adminAppContainer.changeS3CustomEndpoint(e.target.value);
+            }}
           />
           <p className="form-text text-muted">{t('admin:app_setting.custom_endpoint_change')}</p>
         </div>
@@ -57,15 +103,15 @@ function AwsSetting(props) {
             placeholder={`${t('eg')} crowi`}
             defaultValue={adminAppContainer.state.s3Bucket || ''}
             onChange={(e) => {
-                adminAppContainer.changeS3Bucket(e.target.value);
-              }}
+              adminAppContainer.changeS3Bucket(e.target.value);
+            }}
           />
         </div>
       </div>
 
       <div className="row form-group">
         <label className="text-left text-md-right col-md-3 col-form-label">
-            Access key ID
+          Access key ID
         </label>
         <div className="col-md-6">
           <input
@@ -73,15 +119,15 @@ function AwsSetting(props) {
             type="text"
             defaultValue={adminAppContainer.state.s3AccessKeyId || ''}
             onChange={(e) => {
-                adminAppContainer.changeS3AccessKeyId(e.target.value);
-              }}
+              adminAppContainer.changeS3AccessKeyId(e.target.value);
+            }}
           />
         </div>
       </div>
 
       <div className="row form-group">
         <label className="text-left text-md-right col-md-3 col-form-label">
-            Secret access key
+          Secret access key
         </label>
         <div className="col-md-6">
           <input
@@ -89,11 +135,13 @@ function AwsSetting(props) {
             type="text"
             defaultValue={adminAppContainer.state.s3SecretAccessKey || ''}
             onChange={(e) => {
-                adminAppContainer.changeS3SecretAccessKey(e.target.value);
-              }}
+              adminAppContainer.changeS3SecretAccessKey(e.target.value);
+            }}
           />
         </div>
       </div>
+
+
     </React.Fragment>
   );
 }

+ 47 - 1
src/client/js/components/Admin/App/GcsSettings.jsx

@@ -11,10 +11,55 @@ import AdminAppContainer from '../../../services/AdminAppContainer';
 
 function GcsSetting(props) {
   const { t, adminAppContainer } = props;
-  const { gcsUseOnlyEnvVars } = adminAppContainer.state;
+  const { gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars } = adminAppContainer.state;
 
   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="ddGcsReferenceFileWithRelayMode"
+              data-toggle="dropdown"
+              aria-haspopup="true"
+              aria-expanded="true"
+            >
+              {gcsReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
+              {!gcsReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')}
+            </button>
+            <div className="dropdown-menu" aria-labelledby="ddGcsReferenceFileWithRelayMode">
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { adminAppContainer.changeGcsReferenceFileWithRelayMode(true) }}
+              >
+                {t('admin:app_setting.file_delivery_method_relay')}
+              </button>
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { adminAppContainer.changeGcsReferenceFileWithRelayMode(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>
+
       {gcsUseOnlyEnvVars && (
         <p
           className="alert alert-info"
@@ -98,6 +143,7 @@ function GcsSetting(props) {
           </tr>
         </tbody>
       </table>
+
     </>
   );
 

+ 21 - 0
src/client/js/services/AdminAppContainer.js

@@ -46,12 +46,14 @@ export default class AdminAppContainer extends Container {
       envGcsBucket: '',
       gcsUploadNamespace: '',
       envGcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
 
       s3Region: '',
       s3CustomEndpoint: '',
       s3Bucket: '',
       s3AccessKeyId: '',
       s3SecretAccessKey: '',
+      s3ReferenceFileWithRelayMode: false,
 
       isEnabledPlugins: true,
     };
@@ -99,10 +101,13 @@ export default class AdminAppContainer extends Container {
       s3Bucket: appSettingsParams.s3Bucket,
       s3AccessKeyId: appSettingsParams.s3AccessKeyId,
       s3SecretAccessKey: appSettingsParams.s3SecretAccessKey,
+      s3ReferenceFileWithRelayMode: appSettingsParams.s3ReferenceFileWithRelayMode,
+
       gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars,
       gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath,
       gcsBucket: appSettingsParams.gcsBucket,
       gcsUploadNamespace: appSettingsParams.gcsUploadNamespace,
+      gcsReferenceFileWithRelayMode: appSettingsParams.gcsReferenceFileWithRelayMode,
       envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
       envGcsBucket: appSettingsParams.envGcsBucket,
       envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
@@ -238,6 +243,13 @@ export default class AdminAppContainer extends Container {
     this.setState({ s3SecretAccessKey });
   }
 
+  /**
+   * Change s3ReferenceFileWithRelayMode
+   */
+  changeS3ReferenceFileWithRelayMode(s3ReferenceFileWithRelayMode) {
+    this.setState({ s3ReferenceFileWithRelayMode });
+  }
+
   /**
    * Change gcsApiKeyJsonPath
    */
@@ -259,6 +271,13 @@ export default class AdminAppContainer extends Container {
     this.setState({ gcsUploadNamespace });
   }
 
+  /**
+   * Change gcsReferenceFileWithRelayMode
+   */
+  changeGcsReferenceFileWithRelayMode(gcsReferenceFileWithRelayMode) {
+    this.setState({ gcsReferenceFileWithRelayMode });
+  }
+
   /**
    * Change secret key
    */
@@ -367,6 +386,7 @@ export default class AdminAppContainer extends Container {
       requestParams.gcsApiKeyJsonPath = this.state.gcsApiKeyJsonPath;
       requestParams.gcsBucket = this.state.gcsBucket;
       requestParams.gcsUploadNamespace = this.state.gcsUploadNamespace;
+      requestParams.gcsReferenceFileWithRelayMode = this.state.gcsReferenceFileWithRelayMode;
     }
 
     if (fileUploadType === 'aws') {
@@ -375,6 +395,7 @@ export default class AdminAppContainer extends Container {
       requestParams.s3Bucket = this.state.s3Bucket;
       requestParams.s3AccessKeyId = this.state.s3AccessKeyId;
       requestParams.s3SecretAccessKey = this.state.s3SecretAccessKey;
+      requestParams.s3ReferenceFileWithRelayMode = this.state.s3ReferenceFileWithRelayMode;
     }
 
     const response = await this.appContainer.apiv3.put('/app-settings/file-upload-setting', requestParams);

+ 24 - 0
src/server/models/attachment.js

@@ -8,6 +8,7 @@ const path = require('path');
 const mongoose = require('mongoose');
 const uniqueValidator = require('mongoose-unique-validator');
 const mongoosePaginate = require('mongoose-paginate-v2');
+const { addSeconds } = require('date-fns');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -28,6 +29,8 @@ module.exports = function(crowi) {
     fileFormat: { type: String, required: true },
     fileSize: { type: Number, default: 0 },
     createdAt: { type: Date, default: Date.now },
+    temporaryUrlCached: { type: String },
+    temporaryUrlExpiredAt: { type: Date },
   });
   attachmentSchema.plugin(uniqueValidator);
   attachmentSchema.plugin(mongoosePaginate);
@@ -66,5 +69,26 @@ module.exports = function(crowi) {
   };
 
 
+  attachmentSchema.methods.getValidTemporaryUrl = function() {
+    if (this.temporaryUrlExpiredAt == null) {
+      return null;
+    }
+    // return null when expired url
+    if (this.temporaryUrlExpiredAt.getTime() < new Date().getTime()) {
+      return null;
+    }
+    return this.temporaryUrlCached;
+  };
+
+  attachmentSchema.methods.cashTemporaryUrlByProvideSec = function(temporaryUrl, provideSec) {
+    if (temporaryUrl == null) {
+      throw new Error('url is required.');
+    }
+    this.temporaryUrlCached = temporaryUrl;
+    this.temporaryUrlExpiredAt = addSeconds(new Date(), provideSec);
+
+    return this.save();
+  };
+
   return mongoose.model('Attachment', attachmentSchema);
 };

+ 21 - 5
src/server/routes/apiv3/app-settings.js

@@ -93,21 +93,24 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          fileUploadType:
  *            type: string
  *            description: fileUploadType
- *          region:
+ *          s3Region:
  *            type: string
  *            description: region of AWS S3
- *          customEndpoint:
+ *          s3CustomEndpoint:
  *            type: string
  *            description: custom endpoint of AWS S3
- *          bucket:
+ *          s3Bucket:
  *            type: string
  *            description: AWS S3 bucket name
- *          accessKeyId:
+ *          s3AccessKeyId:
  *            type: string
  *            description: accesskey id for authentification of AWS
- *          secretAccessKey:
+ *          s3SecretAccessKey:
  *            type: string
  *            description: secret key for authentification of AWS
+ *          s3ReferenceFileWithRelayMode:
+ *            type: boolean
+ *            description: is enable internal stream system for s3 file request
  *          gcsApiKeyJsonPath:
  *            type: string
  *            description: apiKeyJsonPath of gcp
@@ -117,6 +120,9 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          gcsUploadNamespace:
  *            type: string
  *            description: name space of gcs
+ *          gcsReferenceFileWithRelayMode:
+ *            type: boolean
+ *            description: is enable internal stream system for gcs file request
  *          envGcsApiKeyJsonPath:
  *            type: string
  *            description: Path of the JSON file that contains service account key to authenticate to GCP API
@@ -171,6 +177,7 @@ module.exports = (crowi) => {
       body('gcsApiKeyJsonPath').trim(),
       body('gcsBucket').trim(),
       body('gcsUploadNamespace').trim(),
+      body('gcsReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
       body('s3Region').trim().if(value => value !== '').matches(/^[a-z]+-[a-z]+-\d+$/)
         .withMessage((value, { req }) => req.t('validation.aws_region')),
       body('s3CustomEndpoint').trim().if(value => value !== '').matches(/^(https?:\/\/[^/]+|)$/)
@@ -178,6 +185,7 @@ module.exports = (crowi) => {
       body('s3Bucket').trim(),
       body('s3AccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
       body('s3SecretAccessKey').trim(),
+      body('s3ReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
     ],
     pluginSetting: [
       body('isEnabledPlugins').isBoolean(),
@@ -232,10 +240,14 @@ module.exports = (crowi) => {
       s3Bucket: crowi.configManager.getConfig('crowi', 'aws:s3Bucket'),
       s3AccessKeyId: crowi.configManager.getConfig('crowi', 'aws:s3AccessKeyId'),
       s3SecretAccessKey: crowi.configManager.getConfig('crowi', 'aws:s3SecretAccessKey'),
+      s3ReferenceFileWithRelayMode: crowi.configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode'),
+
       gcsUseOnlyEnvVars: crowi.configManager.getConfig('crowi', 'gcs:useOnlyEnvVarsForSomeOptions'),
       gcsApiKeyJsonPath: crowi.configManager.getConfig('crowi', 'gcs:apiKeyJsonPath'),
       gcsBucket: crowi.configManager.getConfig('crowi', 'gcs:bucket'),
       gcsUploadNamespace: crowi.configManager.getConfig('crowi', 'gcs:uploadNamespace'),
+      gcsReferenceFileWithRelayMode: crowi.configManager.getConfig('crowi', 'gcs:referenceFileWithRelayMode'),
+
       envGcsApiKeyJsonPath: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:apiKeyJsonPath'),
       envGcsBucket: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:bucket'),
       envGcsUploadNamespace: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:uploadNamespace'),
@@ -581,6 +593,7 @@ module.exports = (crowi) => {
       requestParams['gcs:apiKeyJsonPath'] = req.body.gcsApiKeyJsonPath;
       requestParams['gcs:bucket'] = req.body.gcsBucket;
       requestParams['gcs:uploadNamespace'] = req.body.gcsUploadNamespace;
+      requestParams['gcs:referenceFileWithRelayMode'] = req.body.gcsReferenceFileWithRelayMode;
     }
 
     if (fileUploadType === 'aws') {
@@ -589,6 +602,7 @@ module.exports = (crowi) => {
       requestParams['aws:s3Bucket'] = req.body.s3Bucket;
       requestParams['aws:s3AccessKeyId'] = req.body.s3AccessKeyId;
       requestParams['aws:s3SecretAccessKey'] = req.body.s3SecretAccessKey;
+      requestParams['aws:referenceFileWithRelayMode'] = req.body.s3ReferenceFileWithRelayMode;
     }
 
     try {
@@ -604,6 +618,7 @@ module.exports = (crowi) => {
         responseParams.gcsApiKeyJsonPath = crowi.configManager.getConfig('crowi', 'gcs:apiKeyJsonPath');
         responseParams.gcsBucket = crowi.configManager.getConfig('crowi', 'gcs:bucket');
         responseParams.gcsUploadNamespace = crowi.configManager.getConfig('crowi', 'gcs:uploadNamespace');
+        responseParams.gcsReferenceFileWithRelayMode = crowi.configManager.getConfig('crowi', 'gcs:referenceFileWithRelayMode ');
       }
 
       if (fileUploadType === 'aws') {
@@ -612,6 +627,7 @@ module.exports = (crowi) => {
         responseParams.s3Bucket = crowi.configManager.getConfig('crowi', 'aws:s3Bucket');
         responseParams.s3AccessKeyId = crowi.configManager.getConfig('crowi', 'aws:s3AccessKeyId');
         responseParams.s3SecretAccessKey = crowi.configManager.getConfig('crowi', 'aws:s3SecretAccessKey');
+        responseParams.s3ReferenceFileWithRelayMode = crowi.configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
       }
 
       return res.apiv3({ responseParams });

+ 24 - 0
src/server/service/config-loader.js

@@ -332,6 +332,18 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: null,
   },
+  S3_REFERENCE_FILE_WITH_RELAY_MODE: {
+    ns:      'crowi',
+    key:     'aws:referenceFileWithRelayMode',
+    type:    TYPES.BOOLEAN,
+    default: false,
+  },
+  S3_LIFETIME_SEC_FOR_TEMPORARY_URL: {
+    ns:      'crowi',
+    key:     'aws:lifetimeSecForTemporaryUrl',
+    type:    TYPES.NUMBER,
+    default: 120,
+  },
   GCS_API_KEY_JSON_PATH: {
     ns:      'crowi',
     key:     'gcs:apiKeyJsonPath',
@@ -350,12 +362,24 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: null,
   },
+  GCS_REFERENCE_FILE_WITH_RELAY_MODE: {
+    ns:      'crowi',
+    key:     'gcs:referenceFileWithRelayMode',
+    type:    TYPES.BOOLEAN,
+    default: false,
+  },
   GCS_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
     ns:      'crowi',
     key:     'gcs:useOnlyEnvVarsForSomeOptions',
     type:    TYPES.BOOLEAN,
     default: false,
   },
+  GCS_LIFETIME_SEC_FOR_TEMPORARY_URL: {
+    ns:      'crowi',
+    key:     'gcs:lifetimeSecForTemporaryUrl',
+    type:    TYPES.NUMBER,
+    default: 120,
+  },
 };
 
 class ConfigLoader {

+ 37 - 0
src/server/service/file-uploader/aws.js

@@ -72,6 +72,43 @@ module.exports = function(crowi) {
       && this.configManager.getConfig('crowi', 'aws:s3Bucket') != null;
   };
 
+  lib.canRespond = function() {
+    return !this.configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
+  };
+
+  lib.respond = async function(res, attachment) {
+    if (!this.getIsUploadable()) {
+      throw new Error('AWS is not configured.');
+    }
+    const temporaryUrl = attachment.getValidTemporaryUrl();
+    if (temporaryUrl != null) {
+      return res.redirect(temporaryUrl);
+    }
+
+    const s3 = S3Factory();
+    const awsConfig = getAwsConfig();
+    const filePath = getFilePathOnStorage(attachment);
+    const lifetimeSecForTemporaryUrl = this.configManager.getConfig('crowi', 'aws:lifetimeSecForTemporaryUrl');
+
+    // issue signed url (default: expires 120 seconds)
+    // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
+    const params = {
+      Bucket: awsConfig.bucket,
+      Key: filePath,
+      Expires: lifetimeSecForTemporaryUrl,
+    };
+    const signedUrl = s3.getSignedUrl('getObject', params);
+
+    res.redirect(signedUrl);
+
+    try {
+      return attachment.cashTemporaryUrlByProvideSec(signedUrl, lifetimeSecForTemporaryUrl);
+    }
+    catch (err) {
+      logger.error(err);
+    }
+
+  };
 
   lib.deleteFile = async function(attachment) {
     const filePath = getFilePathOnStorage(attachment);

+ 37 - 0
src/server/service/file-uploader/gcs.js

@@ -50,6 +50,43 @@ module.exports = function(crowi) {
       && this.configManager.getConfig('crowi', 'gcs:bucket') != null;
   };
 
+  lib.canRespond = function() {
+    return !this.configManager.getConfig('crowi', 'gcs:referenceFileWithRelayMode');
+  };
+
+  lib.respond = async function(res, attachment) {
+    if (!this.getIsUploadable()) {
+      throw new Error('GCS is not configured.');
+    }
+    const temporaryUrl = attachment.getValidTemporaryUrl();
+    if (temporaryUrl != null) {
+      return res.redirect(temporaryUrl);
+    }
+
+    const gcs = getGcsInstance();
+    const myBucket = gcs.bucket(getGcsBucket());
+    const filePath = getFilePathOnStorage(attachment);
+    const file = myBucket.file(filePath);
+    const lifetimeSecForTemporaryUrl = this.configManager.getConfig('crowi', 'gcs:lifetimeSecForTemporaryUrl');
+
+    // issue signed url (default: expires 120 seconds)
+    // https://cloud.google.com/storage/docs/access-control/signed-urls
+    const signedUrl = await file.getSignedUrl({
+      action: 'read',
+      expires: Date.now() + lifetimeSecForTemporaryUrl * 1000,
+    });
+
+    res.redirect(signedUrl);
+
+    try {
+      return attachment.cashTemporaryUrlByProvideSec(signedUrl, lifetimeSecForTemporaryUrl);
+    }
+    catch (err) {
+      logger.error(err);
+    }
+
+  };
+
   lib.deleteFile = async function(attachment) {
     const filePath = getFilePathOnStorage(attachment);
     return lib.deleteFileByFilePath(filePath);