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"