diff --git a/doc/admin.md b/doc/admin.md index 95898c1e376ec095fcb8f2582dc48c51c7e5729c..56447c737f2c3268987f12543ddbd4a6628d03ed 100644 --- a/doc/admin.md +++ b/doc/admin.md @@ -7,7 +7,15 @@ Then open the Mail app from the app menu. Put in your mail account credentials a ## Configuration -Certain advanced or experimental features need to be specifically enabled in your `config.php`: +### Attachment size limit + +Admins can prevent users from attaching large attachments to their emails. Users will be asked to use link shares instead. + +```php +'app.mail.attachment-size-limit' => 3*1024*1024, +``` + +The unit is bytes. The example about with limit to 3MB attachments. The default is 0 bytes which means no upload limit. ### Timeouts Depending on your mail host, it may be necessary to increase your IMAP and/or SMTP timeout threshold. Currently IMAP defaults to 20 seconds and SMTP defaults to 2 seconds. They can be changed as follows: diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 4577bc9833f3bf0a4ba6c12f5fd1ce54c6a9af10..9be84d4a830830350a3f0a97ee4b331bd580501e 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -134,6 +134,7 @@ class PageController extends Controller { $response = new TemplateResponse($this->appName, 'index', [ 'debug' => $this->config->getSystemValue('debug', false), + 'attachment-size-limit' => $this->config->getSystemValue('app.mail.attachment-size-limit', 0), 'app-version' => $this->config->getAppValue('mail', 'installed_version'), 'accounts' => base64_encode(json_encode($accountsJson)), 'external-avatars' => $this->preferences->getPreference('external-avatars', 'true'), diff --git a/package-lock.json b/package-lock.json index 673abb499d51bc4c8aa305a4fa078b7a472ef393..d2ddca70b089b7e4d2c98929166c57b7f06e90f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3724,6 +3724,11 @@ } } }, + "base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha1-eAqZyE59YAJgNhURxId2E78k9rs=" + }, "base64-js": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", @@ -5511,7 +5516,7 @@ }, "domelementtype": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "resolved": "http://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==" }, "domexception": { @@ -6651,6 +6656,11 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" }, + "fast-xml-parser": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.17.5.tgz", + "integrity": "sha512-lEvThd1Xq+CCylf1n+05bUZCDZjTufaaaqpxM3JZ+4iDqtlG+d/oKgtMmg9GEMOuzBgUoalIzFOaClht9YiGJQ==" + }, "fastest-levenshtein": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz", @@ -7275,6 +7285,11 @@ "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", "dev": true }, + "hot-patcher": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-0.5.0.tgz", + "integrity": "sha512-2Uu2W0s8+dnqXzdlg0MRsRzPoDCs1wVjOGSyMRRaMzLDX4bgHw6xDYKccsWafXPPxQpkQfEjgW6+17pwcg60bw==" + }, "hsl-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hsl-regex/-/hsl-regex-1.0.0.tgz", @@ -9323,6 +9338,11 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "nested-property": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nested-property/-/nested-property-4.0.0.tgz", + "integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==" + }, "nextcloud_issuetemplate_builder": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/nextcloud_issuetemplate_builder/-/nextcloud_issuetemplate_builder-0.1.0.tgz", @@ -10111,6 +10131,11 @@ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" }, + "path-posix": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz", + "integrity": "sha1-BrJhE/Vr6rBCVFojv6iAA8ysJg8=" + }, "path-to-regexp": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", @@ -11263,6 +11288,11 @@ "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=", "dev": true }, + "querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, "quick-lru": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", @@ -11811,6 +11841,11 @@ "integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==", "dev": true }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" + }, "resolve": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", @@ -13741,6 +13776,11 @@ } } }, + "url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==" + }, "url-loader": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz", @@ -13794,6 +13834,15 @@ } } }, + "url-parse": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.7.tgz", + "integrity": "sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg==", + "requires": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", @@ -14275,6 +14324,38 @@ "chokidar": "^2.1.8" } }, + "webdav": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/webdav/-/webdav-3.6.1.tgz", + "integrity": "sha512-WRE6M4iePpd4TPJ0l5PCWMuoZS4UOHawtf/fr7FcmTZWsSjx1syywyW3TbgEFHptqyh4mMoLUrv8dAUtmTqbEg==", + "requires": { + "axios": "^0.20.0", + "base-64": "^0.1.0", + "fast-xml-parser": "^3.17.4", + "he": "^1.2.0", + "hot-patcher": "^0.5.0", + "minimatch": "^3.0.4", + "nested-property": "^4.0.0", + "path-posix": "^1.0.0", + "url-join": "^4.0.1", + "url-parse": "^1.4.7" + }, + "dependencies": { + "axios": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.20.0.tgz", + "integrity": "sha512-ANA4rr2BDcmmAQLOKft2fufrtuvlqR+cXNNinUmvfeSNCOF98PZL+7M/v1zIdGo7OLjEA9J2gXJL+j4zGsl0bA==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, + "follow-redirects": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz", + "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==" + } + } + }, "webidl-conversions": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", diff --git a/package.json b/package.json index 09fa8db8ec137cc0f8b9f75f23c4f11a923a8f06..2e180949af99f511eeabe5df1357bb91021f9ed6 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "vue-slide-up-down": "^2.0.1", "vue-tabs-component": "^1.5.0", "vuex": "^3.6.0", - "vuex-router-sync": "^5.0.0" + "vuex-router-sync": "^5.0.0", + "webdav": "^3.6.1" }, "browserslist": [ "extends @nextcloud/browserslist-config" diff --git a/src/components/Composer.vue b/src/components/Composer.vue index 8258f131d3749c0f87473dce89a72fe9a95357d0..76b95746d2c8bed6b3f0f098fbb0fa4ce3eada22 100644 --- a/src/components/Composer.vue +++ b/src/components/Composer.vue @@ -143,7 +143,10 @@ :is-reply-or-forward="isReply || isForward" /> </div> <div class="composer-actions"> - <ComposerAttachments v-model="attachments" :bus="bus" @upload="onAttachmentsUploading" /> + <ComposerAttachments v-model="attachments" + :bus="bus" + :upload-size-limit="attachmentSizeLimit" + @upload="onAttachmentsUploading" /> <div class="composer-actions-right"> <p class="composer-actions-draft"> <span v-if="!canSaveDraft" id="draft-status">{{ t('mail', 'Can not save draft because this account does not have a drafts mailbox configured.') }}</span> @@ -385,6 +388,9 @@ export default { allRecipients() { return this.selectTo.concat(this.selectCc).concat(this.selectBcc) }, + attachmentSizeLimit() { + return this.$store.getters.getPreference('attachment-size-limit') + }, selectableRecipients() { return this.newRecipients .concat(this.autocompleteRecipients) diff --git a/src/components/ComposerAttachments.vue b/src/components/ComposerAttachments.vue index cb0a3d3427da555057437859edcd08f8183858f6..fb829af768338cf1c595d741fb95e01e59cd8b12 100644 --- a/src/components/ComposerAttachments.vue +++ b/src/components/ComposerAttachments.vue @@ -47,13 +47,19 @@ import map from 'lodash/fp/map' import trimStart from 'lodash/fp/trimCharsStart' import { getRequestToken } from '@nextcloud/auth' -import { translate as t } from '@nextcloud/l10n' -import { getFilePickerBuilder } from '@nextcloud/dialogs' +import { formatFileSize } from '@nextcloud/files' +import prop from 'lodash/fp/prop' +import { getFilePickerBuilder, showWarning } from '@nextcloud/dialogs' +import sum from 'lodash/fp/sum' +import sumBy from 'lodash/fp/sumBy' +import { translate as t, translatePlural as n } from '@nextcloud/l10n' + import Vue from 'vue' -import Logger from '../logger' -import { uploadLocalAttachment } from '../service/AttachmentService' +import logger from '../logger' +import { getFileSize } from '../service/FileService' import { shareFile } from '../service/FileSharingService' +import { uploadLocalAttachment } from '../service/AttachmentService' export default { name: 'ComposerAttachments', @@ -66,6 +72,10 @@ export default { type: Object, required: true, }, + uploadSizeLimit: { + type: Number, + default: 0, + }, }, data() { return { @@ -93,22 +103,48 @@ export default { onAddLocalAttachment() { this.$refs.localAttachments.click() }, - fileNameToAttachment(name, id) { + fileNameToAttachment(name, size, id) { return { fileName: name, displayName: trimStart('/')(name), id, + size, isLocal: id !== undefined, } }, emitNewAttachments(attachments) { this.$emit('input', this.value.concat(attachments)) }, + totalSizeOfUpload() { + return Object.values(this.value).reduce((acc, upload) => { + if (!upload.isLocal) { + // Ignore link shares + return acc + } + + return acc + upload.size + }, 0) + }, onLocalAttachmentSelected(e) { this.uploading = true Vue.set(this, 'uploads', {}) + const toUpload = sumBy(prop('size'), Object.values(e.target.files)) + const newTotal = toUpload + this.totalSizeOfUpload() + logger.debug('checking upload size limit', { + existingUploads: this.totalSizeOfUpload(), + toUpload, + limit: this.uploadSizeLimit, + newTotal, + }) + if (this.uploadSizeLimit && newTotal > this.uploadSizeLimit) { + this.showAttachmentFileSizeWarning(e.target.files.length) + + this.uploading = false + return + } + const progress = (id) => (prog, uploaded) => { this.uploads[id].uploaded = uploaded } @@ -120,38 +156,60 @@ export default { }) return uploadLocalAttachment(file, progress(file.name)).then(({ file, id }) => { - Logger.info('uploaded') - return this.emitNewAttachments([this.fileNameToAttachment(file.name, id)]) + logger.info('uploaded') + return this.emitNewAttachments([this.fileNameToAttachment(file.name, file.size, id)]) }) - })(e.target.files) + }, e.target.files) const done = Promise.all(promises) - .catch((error) => Logger.error('could not upload all attachments', { error })) + .catch((error) => logger.error('could not upload all attachments', { error })) .then(() => (this.uploading = false)) this.$emit('upload', done) return done }, - onAddCloudAttachment() { + async onAddCloudAttachment() { const picker = getFilePickerBuilder(t('mail', 'Choose a file to add as attachment')).setMultiSelect(true).build() - return picker - .pick(t('mail', 'Choose a file to add as attachment')) - .then((paths) => this.emitNewAttachments(paths.map(this.fileNameToAttachment))) - .catch((error) => Logger.error('could not choose a file as attachment', { error })) + try { + const paths = await picker.pick(t('mail', 'Choose a file to add as attachment')) + const fileSizes = await Promise.all(paths.map(getFileSize)) + const newTotal = sum(fileSizes) + this.totalSizeOfUpload() + + if (this.uploadSizeLimit && newTotal > this.uploadSizeLimit) { + this.showAttachmentFileSizeWarning(paths.length) + + return + } + + this.emitNewAttachments(paths.map(this.fileNameToAttachment)) + } catch (error) { + logger.error('could not choose a file as attachment', { error }) + } }, - onAddCloudAttachmentLink() { + async onAddCloudAttachmentLink() { const picker = getFilePickerBuilder(t('mail', 'Choose a file to share as a link')).build() - return picker - .pick(t('mail', 'Choose a file to share as a link')) - .then(async(path) => { - const url = await shareFile(path, getRequestToken()) + try { + const path = await picker.pick(t('mail', 'Choose a file to share as a link')) + const url = await shareFile(path, getRequestToken()) - return this.appendToBodyAtCursor(`<a href="${url}">${url}</a>`) - }) - .catch((error) => Logger.error('could not choose a file as attachment link', { error })) + this.appendToBodyAtCursor(`<a href="${url}">${url}</a>`) + } catch (error) { + logger.error('could not choose a file as attachment link', { error }) + } + }, + showAttachmentFileSizeWarning(num) { + showWarning(n( + 'mail', + 'The attachment exceed the allowed attachments size of {size}. Please share the file via link instead.', + 'The attachments exceed the allowed attachments size of {size}. Please share the files via link instead.', + num, + { + size: formatFileSize(this.uploadSizeLimit), + } + )) }, onDelete(attachment) { this.$emit( diff --git a/src/main.js b/src/main.js index 1b26ac3228e97cb4ddb9ccfaf3258f2fb04e4eda..1bf4d7676aeabe56d683db6268491f0c93636c8c 100644 --- a/src/main.js +++ b/src/main.js @@ -60,6 +60,10 @@ store.commit('savePreference', { key: 'debug', value: getPreferenceFromPage('debug-mode'), }) +store.commit('savePreference', { + key: 'attachment-size-limit', + value: Number.parseInt(getPreferenceFromPage('attachment-size-limit'), 10), +}) store.commit('savePreference', { key: 'version', value: getPreferenceFromPage('config-installed-version'), diff --git a/src/service/FileService.js b/src/service/FileService.js new file mode 100644 index 0000000000000000000000000000000000000000..d37da3a8f674e7cd3673127a44ee043d32b8c937 --- /dev/null +++ b/src/service/FileService.js @@ -0,0 +1,54 @@ +/* + * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import axios from '@nextcloud/axios' +import { generateRemoteUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import memoize from 'lodash/fp/memoize' +import webdav from 'webdav' + +const getWebDavClient = memoize(() => { + // Add this so the server knows it is an request from the browser + axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest' + + // force our axios + const patcher = webdav.getPatcher() + patcher.patch('request', axios) + + return webdav.createClient( + generateRemoteUrl(`dav/files/${getCurrentUser().uid}`) + ) +}) + +export async function getFileSize(path) { + const response = await getWebDavClient().stat(path, { + data: `<?xml version="1.0"?> + <d:propfind xmlns:d="DAV:" + xmlns:oc="http://owncloud.org/ns"> + <d:prop> + <oc:size /> + </d:prop> + </d:propfind>`, + details: true, + }) + + return response?.data?.props?.size +} diff --git a/templates/index.php b/templates/index.php index dbee99b902ab36ae40617f781a8afb5f35237a8d..5c804b86c07ac39b1375f374986f1e093184a79f 100644 --- a/templates/index.php +++ b/templates/index.php @@ -29,6 +29,7 @@ script('mail', 'mail'); ?> <input type="hidden" id="debug-mode" value="<?php p($_['debug'] ? 'true' : 'false'); ?>"> +<input type="hidden" id="attachment-size-limit" value="<?php p($_['attachment-size-limit']); ?>"> <input type="hidden" id="config-installed-version" value="<?php p($_['app-version']); ?>"> <input type="hidden" id="serialized-accounts" value="<?php p($_['accounts']); ?>"> <input type="hidden" id="external-avatars" value="<?php p($_['external-avatars']); ?>"> diff --git a/tests/Unit/Controller/PageControllerTest.php b/tests/Unit/Controller/PageControllerTest.php index 9bbb9b7020f1494e29d57e4090f0a92a882c3ced..b7d9e2a927625b1edc855302e5200fb0ad91f673 100644 --- a/tests/Unit/Controller/PageControllerTest.php +++ b/tests/Unit/Controller/PageControllerTest.php @@ -196,10 +196,12 @@ class PageControllerTest extends TestCase { $this->userSession->expects($this->once()) ->method('getUser') ->will($this->returnValue($user)); - $this->config->expects($this->once()) + $this->config ->method('getSystemValue') - ->with('debug', false) - ->will($this->returnValue(true)); + ->willReturnMap([ + ['debug', false, true], + ['app.mail.attachment-size-limit', 0, 123], + ]); $this->config->expects($this->once()) ->method('getAppValue') ->with('mail', 'installed_version') @@ -225,6 +227,7 @@ class PageControllerTest extends TestCase { $expected = new TemplateResponse($this->appName, 'index', [ 'debug' => true, + 'attachment-size-limit' => 123, 'external-avatars' => 'true', 'app-version' => '1.2.3', 'accounts' => base64_encode(json_encode($accountsJson)),