From 921e3dfc0819bf275e6205c66690bbc65f3e652c Mon Sep 17 00:00:00 2001
From: Jrmi <jrmi+gitlab@jeremiez.net>
Date: Thu, 20 Jan 2022 15:50:31 +0000
Subject: [PATCH] Resolve "The "Try to reconnect" modal shortly appears when
 trying to download a user file"

---
 backend/requirements/base.txt                 |  2 +
 backend/src/baserow/config/settings/base.py   | 21 ++++
 changelog.md                                  |  1 +
 deploy/heroku/settings.py                     | 23 -----
 docs/getting-started/introduction.md          |  4 +
 .../scss/components/file_field_modal.scss     | 30 +++++-
 .../modules/core/components/DownloadLink.vue  | 99 +++++++++++++++++++
 .../core/components/trash/TrashContents.vue   |  2 +-
 web-frontend/modules/core/module.js           |  9 ++
 web-frontend/modules/core/plugins/global.js   |  2 +
 .../export/ExportTableLoadingBar.vue          |  8 +-
 .../components/field/FileFieldModal.vue       | 11 ++-
 12 files changed, 178 insertions(+), 34 deletions(-)
 create mode 100644 web-frontend/modules/core/components/DownloadLink.vue

diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt
index 9307b6bcf..1da4ffa4e 100644
--- a/backend/requirements/base.txt
+++ b/backend/requirements/base.txt
@@ -28,3 +28,5 @@ click==7.1.2
 cryptography==3.4.8
 antlr4-python3-runtime==4.8.0
 tqdm==4.62.3
+boto3==1.20.38
+django-storages==1.12.3
diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py
index cb5efd21f..03bd7e74d 100644
--- a/backend/src/baserow/config/settings/base.py
+++ b/backend/src/baserow/config/settings/base.py
@@ -266,6 +266,27 @@ SPECTACULAR_SETTINGS = {
 # The storage must always overwrite existing files.
 DEFAULT_FILE_STORAGE = "baserow.core.storage.OverwriteFileSystemStorage"
 
+# Optional S3 storage configuration
+if os.getenv("AWS_ACCESS_KEY_ID", "") != "":
+    DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
+    AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID")
+    AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY")
+    AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME")
+    AWS_S3_OBJECT_PARAMETERS = {
+        "CacheControl": "max-age=86400",
+    }
+    AWS_S3_FILE_OVERWRITE = True
+    AWS_DEFAULT_ACL = "public-read"
+
+if os.getenv("AWS_S3_REGION_NAME", "") != "":
+    AWS_S3_REGION_NAME = os.getenv("AWS_S3_REGION_NAME")
+
+if os.getenv("AWS_S3_ENDPOINT_URL", "") != "":
+    AWS_S3_ENDPOINT_URL = os.getenv("AWS_S3_ENDPOINT_URL")
+
+if os.getenv("AWS_S3_CUSTOM_DOMAIN", "") != "":
+    AWS_S3_CUSTOM_DOMAIN = os.getenv("AWS_S3_CUSTOM_DOMAIN")
+
 MJML_BACKEND_MODE = "tcpserver"
 MJML_TCPSERVERS = [
     (os.getenv("MJML_SERVER_HOST", "mjml"), int(os.getenv("MJML_SERVER_PORT", 28101))),
diff --git a/changelog.md b/changelog.md
index 1dfdbc4b1..e5973ab84 100644
--- a/changelog.md
+++ b/changelog.md
@@ -13,6 +13,7 @@
 
 * Fixed migration failing when upgrading a version of Baserow installed using Postgres 
   10 or lower.
+* Fixed download/preview files from another origin
 
 ## Released (2022-01-13)
 
diff --git a/deploy/heroku/settings.py b/deploy/heroku/settings.py
index d9d58ba65..38418ddd5 100644
--- a/deploy/heroku/settings.py
+++ b/deploy/heroku/settings.py
@@ -3,8 +3,6 @@ import os
 import ssl
 import dj_database_url
 
-INSTALLED_APPS = INSTALLED_APPS + ["storages"]
-
 MEDIA_ROOT = "/baserow/media"
 
 MJML_BACKEND_MODE = "cmd"
@@ -39,24 +37,3 @@ CELERY_REDIS_MAX_CONNECTIONS = min(
 DATABASES = {
     "default": dj_database_url.parse(os.environ["DATABASE_URL"], conn_max_age=600)
 }
-
-if os.getenv("AWS_ACCESS_KEY_ID", "") != "":
-    DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
-    AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID")
-    AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY")
-    AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME")
-    AWS_S3_OBJECT_PARAMETERS = {
-        "CacheControl": "max-age=86400",
-        "ContentDisposition": "attachment",
-    }
-    AWS_S3_FILE_OVERWRITE = True
-    AWS_DEFAULT_ACL = "public-read"
-
-if os.getenv("AWS_S3_REGION_NAME", "") != "":
-    AWS_S3_REGION_NAME = os.getenv("AWS_S3_REGION_NAME")
-
-if os.getenv("AWS_S3_ENDPOINT_URL", "") != "":
-    AWS_S3_ENDPOINT_URL = os.getenv("AWS_S3_ENDPOINT_URL")
-
-if os.getenv("AWS_S3_CUSTOM_DOMAIN", "") != "":
-    AWS_S3_CUSTOM_DOMAIN = os.getenv("AWS_S3_CUSTOM_DOMAIN")
diff --git a/docs/getting-started/introduction.md b/docs/getting-started/introduction.md
index adc5a7645..f949d08e0 100644
--- a/docs/getting-started/introduction.md
+++ b/docs/getting-started/introduction.md
@@ -90,6 +90,10 @@ are accepted.
 * `DATABASE_PASSWORD` (default `baserow`): The password for the PostgreSQL database.
 * `DATABASE_HOST` (default `db`): The hostname of the PostgreSQL server.
 * `DATABASE_PORT` (default `5432`): The port of the PostgreSQL server.
+* `DOWNLOAD_FILE_VIA_XHR` (default `0`): Set to `1` to force download links to
+  download files via XHR query to bypass `Content-Disposition: inline` that
+  can't be overridden in another way. If your files are stored under another
+  origin, you also must add CORS headers to your server.
 * `MJML_SERVER_HOST` (default `mjml`): The hostname of the MJML TCP server. In the
   development environment we use the `liminspace/mjml-tcpserver:0.10` image.
 * `MJML_SERVER_PORT` (default `28101`): The port of the MJML TCP server.
diff --git a/web-frontend/modules/core/assets/scss/components/file_field_modal.scss b/web-frontend/modules/core/assets/scss/components/file_field_modal.scss
index ea8c97727..efca992ac 100644
--- a/web-frontend/modules/core/assets/scss/components/file_field_modal.scss
+++ b/web-frontend/modules/core/assets/scss/components/file_field_modal.scss
@@ -42,7 +42,12 @@
 }
 
 .file-field-modal__body {
-  @include absolute($file-field-modal-head-height, 0, $file-field-modal-foot-height, 0);
+  @include absolute(
+    $file-field-modal-head-height,
+    0,
+    $file-field-modal-foot-height,
+    0
+  );
 }
 
 .file-field-modal__body-nav {
@@ -75,7 +80,12 @@
   align-items: center;
   justify-content: center;
 
-  @include absolute(0, $file-field-modal-body-nav-width, 0, $file-field-modal-body-nav-width);
+  @include absolute(
+    0,
+    $file-field-modal-body-nav-width,
+    0,
+    $file-field-modal-body-nav-width
+  );
 }
 
 .file-field-modal__preview-icon {
@@ -166,4 +176,20 @@
   &:hover {
     background-color: $color-neutral-700;
   }
+
+  &.file-field-modal__action--loading {
+    // Prevent interactions while loading file
+    pointer-events: none;
+
+    &::after {
+      content: '';
+
+      @include loading(16px);
+      @include absolute(8px, 8px, auto, auto);
+    }
+
+    & .fas {
+      visibility: hidden;
+    }
+  }
 }
diff --git a/web-frontend/modules/core/components/DownloadLink.vue b/web-frontend/modules/core/components/DownloadLink.vue
new file mode 100644
index 000000000..995297cc9
--- /dev/null
+++ b/web-frontend/modules/core/components/DownloadLink.vue
@@ -0,0 +1,99 @@
+<template>
+  <a
+    v-if="!downloadXHR"
+    :href="`${url}?dl=${filename}`"
+    target="_blank"
+    :download="filename"
+  >
+    <slot></slot>
+  </a>
+  <a
+    v-else
+    :href="`${url}`"
+    target="_blank"
+    :download="filename"
+    :class="{ [loadingClass]: loading }"
+    @click="onClick($event)"
+  >
+    <slot></slot>
+  </a>
+</template>
+
+<script>
+export default {
+  name: 'DownloadLink',
+  props: {
+    url: {
+      type: String,
+      required: true,
+    },
+    filename: {
+      type: String,
+      required: true,
+    },
+    onError: {
+      type: Function,
+      required: false,
+      default: null,
+    },
+    loadingClass: {
+      type: String,
+      required: true,
+    },
+  },
+  data() {
+    return { loading: false }
+  },
+  computed: {
+    downloadXHR() {
+      return this.$env.DOWNLOAD_FILE_VIA_XHR === '1'
+    },
+  },
+  methods: {
+    async download() {
+      this.loading = true
+      // We are using fetch here to avoid extra header
+      // as we need to add them to CORS later
+      const response = await fetch(this.url)
+      const blob = await response.blob()
+      const data = window.URL.createObjectURL(blob)
+
+      this.loading = false
+
+      // Create temporary anchor element to trigger the download
+      const a = document.createElement('a')
+      a.style = 'display: none'
+      a.href = data
+      a.target = '_blank'
+      a.download = this.filename
+      document.body.appendChild(a)
+      a.onclick = (e) => {
+        // prevent modal/whatever closing
+        e.stopPropagation()
+      }
+      a.click()
+
+      setTimeout(function () {
+        // Remove the element
+        document.body.removeChild(a)
+        // Release resource on after triggering the download
+        window.URL.revokeObjectURL(data)
+      }, 500)
+    },
+    onClick(event) {
+      if (this.downloadXHR) {
+        event.preventDefault()
+        this.download().catch((error) => {
+          // In any case, to be sure the loading animation will not last
+          this.loading = false
+          if (this.onError) {
+            this.onError(error)
+          } else {
+            throw error
+          }
+        })
+      }
+    },
+  },
+}
+</script>
diff --git a/web-frontend/modules/core/components/trash/TrashContents.vue b/web-frontend/modules/core/components/trash/TrashContents.vue
index bc11966c0..7ff4d71ce 100644
--- a/web-frontend/modules/core/components/trash/TrashContents.vue
+++ b/web-frontend/modules/core/components/trash/TrashContents.vue
@@ -141,7 +141,7 @@ export default {
     },
     trashDuration() {
       const hours = this.$env.HOURS_UNTIL_TRASH_PERMANENTLY_DELETED
-      return moment().subtract(hours, 'hours').fromNow().replace('ago', '')
+      return moment().subtract(hours, 'hours').fromNow(true)
     },
   },
   methods: {
diff --git a/web-frontend/modules/core/module.js b/web-frontend/modules/core/module.js
index 943107088..49ad8b05a 100644
--- a/web-frontend/modules/core/module.js
+++ b/web-frontend/modules/core/module.js
@@ -51,6 +51,15 @@ export default function CoreModule(options) {
           key: 'INITIAL_TABLE_DATA_LIMIT',
           default: null,
         },
+        {
+          // Set to `1` to force download links to download files via XHR query
+          // to bypass `Content-Disposition: inline` that can't be overridden
+          // in another way.
+          // If your files are stored under another origin, you also
+          // must add CORS headers to your server.
+          key: 'DOWNLOAD_FILE_VIA_XHR',
+          default: false,
+        },
         {
           // If you change this default please also update the default for the
           // backend found in src/baserow/config/settings/base.py:321
diff --git a/web-frontend/modules/core/plugins/global.js b/web-frontend/modules/core/plugins/global.js
index 0f6d5fad0..9cc9bd126 100644
--- a/web-frontend/modules/core/plugins/global.js
+++ b/web-frontend/modules/core/plugins/global.js
@@ -12,6 +12,7 @@ import Error from '@baserow/modules/core/components/Error'
 import SwitchInput from '@baserow/modules/core/components/SwitchInput'
 import Copied from '@baserow/modules/core/components/Copied'
 import MarkdownIt from '@baserow/modules/core/components/MarkdownIt'
+import DownloadLink from '@baserow/modules/core/components/DownloadLink'
 
 import lowercase from '@baserow/modules/core/filters/lowercase'
 import uppercase from '@baserow/modules/core/filters/uppercase'
@@ -37,6 +38,7 @@ Vue.component('Error', Error)
 Vue.component('SwitchInput', SwitchInput)
 Vue.component('Copied', Copied)
 Vue.component('MarkdownIt', MarkdownIt)
+Vue.component('DownloadLink', DownloadLink)
 
 Vue.filter('lowercase', lowercase)
 Vue.filter('uppercase', uppercase)
diff --git a/web-frontend/modules/database/components/export/ExportTableLoadingBar.vue b/web-frontend/modules/database/components/export/ExportTableLoadingBar.vue
index 83cf940df..15a2cc0d9 100644
--- a/web-frontend/modules/database/components/export/ExportTableLoadingBar.vue
+++ b/web-frontend/modules/database/components/export/ExportTableLoadingBar.vue
@@ -25,16 +25,18 @@
     >
       {{ $t('exportTableLoadingBar.export') }}
     </button>
-    <a
+    <DownloadLink
       v-else
       class="
         button button--large button--success
         export-table-modal__export-button
       "
-      :href="`${job.url}?dl=${filename}`"
+      :url="job.url"
+      :filename="filename"
+      :loading-class="'button--loading'"
     >
       {{ $t('exportTableLoadingBar.download') }}
-    </a>
+    </DownloadLink>
   </div>
 </template>
 
diff --git a/web-frontend/modules/database/components/field/FileFieldModal.vue b/web-frontend/modules/database/components/field/FileFieldModal.vue
index 0a2876642..df8a00edc 100644
--- a/web-frontend/modules/database/components/field/FileFieldModal.vue
+++ b/web-frontend/modules/database/components/field/FileFieldModal.vue
@@ -86,12 +86,13 @@
           </li>
         </ul>
         <ul v-if="preview" class="file-field-modal__actions">
-          <a
-            :href="preview.url + `?dl=${preview.visible_name}`"
+          <DownloadLink
             class="file-field-modal__action"
-          >
-            <i class="fas fa-download"></i>
-          </a>
+            :url="preview.url"
+            :filename="preview.visible_name"
+            :loading-class="'file-field-modal__action--loading'"
+            ><i class="fas fa-download"></i
+          ></DownloadLink>
           <a
             v-if="!readOnly"
             class="file-field-modal__action"