diff --git a/client/src/components/attachments/Attachments/Item.jsx b/client/src/components/attachments/Attachments/Item.jsx index 24c95bf5..9a5b51e4 100644 --- a/client/src/components/attachments/Attachments/Item.jsx +++ b/client/src/components/attachments/Attachments/Item.jsx @@ -80,7 +80,7 @@ const Item = React.memo(({ id, isVisible }) => { break; default: if (attachment.data.encoding === Encodings.UTF8) { - if (attachment.data.sizeInBytes <= Config.MAX_SIZE_IN_BYTES_TO_DISPLAY_CONTENT) { + if (attachment.data.size <= Config.MAX_SIZE_TO_DISPLAY_CONTENT) { content = ( { + const [t] = useTranslation(); + + return ( + + + {t('common.uploadFailedFileIsTooBig')} + + ); +}); + +export default FileIsTooBig; diff --git a/client/src/components/common/Toaster/NotEnoughStorage.jsx b/client/src/components/common/Toaster/NotEnoughStorage.jsx new file mode 100644 index 00000000..8c68614a --- /dev/null +++ b/client/src/components/common/Toaster/NotEnoughStorage.jsx @@ -0,0 +1,21 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icon, Message } from 'semantic-ui-react'; + +const NotEnoughStorage = React.memo(() => { + const [t] = useTranslation(); + + return ( + + + {t('common.uploadFailedNotEnoughStorageSpace')} + + ); +}); + +export default NotEnoughStorage; diff --git a/client/src/components/common/Toaster/Toaster.jsx b/client/src/components/common/Toaster/Toaster.jsx index 1161e128..93ccdacb 100644 --- a/client/src/components/common/Toaster/Toaster.jsx +++ b/client/src/components/common/Toaster/Toaster.jsx @@ -7,9 +7,13 @@ import React from 'react'; import { Toaster as HotToaster, ToastBar as HotToastBar } from 'react-hot-toast'; import ToastTypes from '../../../constants/ToastTypes'; +import FileIsTooBig from './FileIsTooBig'; +import NotEnoughStorage from './NotEnoughStorage'; import EmptyTrashToast from './EmptyTrashToast'; const TOAST_BY_TYPE = { + [ToastTypes.FILE_IS_TOO_BIG]: FileIsTooBig, + [ToastTypes.NOT_ENOUGH_STORAGE]: NotEnoughStorage, [ToastTypes.EMPTY_TRASH]: EmptyTrashToast, }; diff --git a/client/src/constants/Config.js b/client/src/constants/Config.js index 14e357c3..e48c58ab 100755 --- a/client/src/constants/Config.js +++ b/client/src/constants/Config.js @@ -15,7 +15,7 @@ const CARDS_LIMIT = 50; const COMMENTS_LIMIT = 50; const ACTIVITIES_LIMIT = 50; -const MAX_SIZE_IN_BYTES_TO_DISPLAY_CONTENT = 256 * 1024; +const MAX_SIZE_TO_DISPLAY_CONTENT = 256 * 1024; const IS_MAC = navigator.platform.startsWith('Mac'); @@ -28,6 +28,6 @@ export default { CARDS_LIMIT, COMMENTS_LIMIT, ACTIVITIES_LIMIT, - MAX_SIZE_IN_BYTES_TO_DISPLAY_CONTENT, + MAX_SIZE_TO_DISPLAY_CONTENT, IS_MAC, }; diff --git a/client/src/constants/ToastTypes.js b/client/src/constants/ToastTypes.js index e5378201..b06077ef 100644 --- a/client/src/constants/ToastTypes.js +++ b/client/src/constants/ToastTypes.js @@ -3,8 +3,12 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ +const FILE_IS_TOO_BIG = 'FILE_IS_TOO_BIG'; +const NOT_ENOUGH_STORAGE = 'NOT_ENOUGH_STORAGE'; const EMPTY_TRASH = 'EMPTY_TRASH'; export default { + FILE_IS_TOO_BIG, + NOT_ENOUGH_STORAGE, EMPTY_TRASH, }; diff --git a/client/src/locales/ar-YE/core.js b/client/src/locales/ar-YE/core.js index e6831441..f4e8f1aa 100644 --- a/client/src/locales/ar-YE/core.js +++ b/client/src/locales/ar-YE/core.js @@ -280,6 +280,8 @@ export default { typeNameToConfirm: null, typeTitleToConfirm: null, unsavedChanges: null, + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: null, url: null, userActions_title: 'إجراءات المستخدم', diff --git a/client/src/locales/bg-BG/core.js b/client/src/locales/bg-BG/core.js index 2807c91b..4ce2b559 100644 --- a/client/src/locales/bg-BG/core.js +++ b/client/src/locales/bg-BG/core.js @@ -284,6 +284,8 @@ export default { typeNameToConfirm: null, typeTitleToConfirm: null, unsavedChanges: null, + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: null, url: null, userActions_title: 'Потребителски действия', diff --git a/client/src/locales/cs-CZ/core.js b/client/src/locales/cs-CZ/core.js index 9852d779..7cfaca6b 100644 --- a/client/src/locales/cs-CZ/core.js +++ b/client/src/locales/cs-CZ/core.js @@ -294,6 +294,8 @@ export default { typeNameToConfirm: 'Zadejte název pro potvrzení.', typeTitleToConfirm: 'Zadejte titulek pro potvrzení.', unsavedChanges: 'Neuložené změny', + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: 'Nahrané obrázky', url: null, userActions_title: 'Akce uživatele', diff --git a/client/src/locales/da-DK/core.js b/client/src/locales/da-DK/core.js index fff2d3bb..dcd9fc82 100644 --- a/client/src/locales/da-DK/core.js +++ b/client/src/locales/da-DK/core.js @@ -301,6 +301,8 @@ export default { typeNameToConfirm: 'Skriv navnet for at bekræfte.', typeTitleToConfirm: 'Skriv overskriften for at bekræfte.', unsavedChanges: 'Ikke-gemte ændringer', + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: 'Uploadede billeder', url: null, userActions_title: 'Brugerhandlinger', diff --git a/client/src/locales/de-DE/core.js b/client/src/locales/de-DE/core.js index 45dbb647..fd1bbfcf 100644 --- a/client/src/locales/de-DE/core.js +++ b/client/src/locales/de-DE/core.js @@ -309,6 +309,8 @@ export default { typeNameToConfirm: 'Namen zur Bestätigung eingeben.', typeTitleToConfirm: 'Titel zur Bestätigung eingeben.', unsavedChanges: 'Ungespeicherte Änderungen', + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: 'Hochgeladene Bilder', url: null, userActions_title: 'Benutzeraktionen', diff --git a/client/src/locales/el-GR/core.js b/client/src/locales/el-GR/core.js index db58219b..3a2f9a5a 100644 --- a/client/src/locales/el-GR/core.js +++ b/client/src/locales/el-GR/core.js @@ -311,6 +311,8 @@ export default { typeNameToConfirm: 'Πληκτρολογήστε το όνομα για επιβεβαίωση.', typeTitleToConfirm: 'Πληκτρολογήστε τον τίτλο για επιβεβαίωση.', unsavedChanges: 'Μη αποθηκευμένες αλλαγές', + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: 'Μεταφορτωμένες εικόνες', url: null, userActions_title: 'Ενέργειες χρήστη', diff --git a/client/src/locales/en-GB/core.js b/client/src/locales/en-GB/core.js index d59aba7c..e9d2fd07 100644 --- a/client/src/locales/en-GB/core.js +++ b/client/src/locales/en-GB/core.js @@ -301,6 +301,8 @@ export default { typeNameToConfirm: 'Type the name to confirm.', typeTitleToConfirm: 'Type the title to confirm.', unsavedChanges: 'Unsaved changes', + uploadFailedFileIsTooBig: 'Upload failed: File is too big.', + uploadFailedNotEnoughStorageSpace: 'Upload failed: Not enough storage space.', uploadedImages: 'Uploaded images', url: 'URL', userActions_title: 'User Actions', diff --git a/client/src/locales/en-US/core.js b/client/src/locales/en-US/core.js index 863297a4..f96a1eb8 100644 --- a/client/src/locales/en-US/core.js +++ b/client/src/locales/en-US/core.js @@ -296,6 +296,8 @@ export default { typeNameToConfirm: 'Type the name to confirm.', typeTitleToConfirm: 'Type the title to confirm.', unsavedChanges: 'Unsaved changes', + uploadFailedFileIsTooBig: 'Upload failed: File is too big.', + uploadFailedNotEnoughStorageSpace: 'Upload failed: Not enough storage space.', uploadedImages: 'Uploaded images', url: 'URL', userActions_title: 'User Actions', diff --git a/client/src/locales/es-ES/core.js b/client/src/locales/es-ES/core.js index 4f13ff43..268b989f 100644 --- a/client/src/locales/es-ES/core.js +++ b/client/src/locales/es-ES/core.js @@ -301,6 +301,8 @@ export default { typeNameToConfirm: 'Escribe el nombre para confirmar', typeTitleToConfirm: 'Escribe el título para confirmar', unsavedChanges: 'Cambios no guardados', + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: 'Imágenes subidas', url: null, userActions_title: 'Acciones de Usuario', diff --git a/client/src/locales/et-EE/core.js b/client/src/locales/et-EE/core.js index 12f7b11a..c2f8cf71 100644 --- a/client/src/locales/et-EE/core.js +++ b/client/src/locales/et-EE/core.js @@ -299,6 +299,8 @@ export default { typeNameToConfirm: 'Sisestage nimi, et kinnitada.', typeTitleToConfirm: 'Sisestage pealkiri, et kinnitada.', unsavedChanges: 'Muudetud andmed', + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: 'Laaditud pildid', url: null, userActions_title: 'Kasutaja tegevused', diff --git a/client/src/locales/fa-IR/core.js b/client/src/locales/fa-IR/core.js index ee0e8006..28846d10 100644 --- a/client/src/locales/fa-IR/core.js +++ b/client/src/locales/fa-IR/core.js @@ -281,6 +281,8 @@ export default { typeNameToConfirm: null, typeTitleToConfirm: null, unsavedChanges: null, + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: null, url: null, userActions_title: 'اقدامات کاربر', diff --git a/client/src/locales/fi-FI/core.js b/client/src/locales/fi-FI/core.js index 44152972..4e159692 100644 --- a/client/src/locales/fi-FI/core.js +++ b/client/src/locales/fi-FI/core.js @@ -295,6 +295,8 @@ export default { typeNameToConfirm: 'Kirjoita nimi vahvistaaksesi.', typeTitleToConfirm: 'Kirjoita otsikko vahvistaaksesi.', unsavedChanges: 'Tallentamattomat muutokset', + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: 'Ladatut kuvat', url: null, userActions_title: 'Käyttäjän toiminnot', diff --git a/client/src/locales/fr-FR/core.js b/client/src/locales/fr-FR/core.js index b65bea62..6d2247df 100644 --- a/client/src/locales/fr-FR/core.js +++ b/client/src/locales/fr-FR/core.js @@ -304,6 +304,8 @@ export default { typeNameToConfirm: 'Saissir le nom pour confirmer.', typeTitleToConfirm: 'Saisir le titre pour confirmer.', unsavedChanges: 'Modifications non enregistrées', + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: 'Images téléchargées', url: 'URL', userActions_title: "Actions de l'utilisateur", diff --git a/client/src/locales/hu-HU/core.js b/client/src/locales/hu-HU/core.js index 09076565..20395dc2 100644 --- a/client/src/locales/hu-HU/core.js +++ b/client/src/locales/hu-HU/core.js @@ -293,6 +293,8 @@ export default { typeNameToConfirm: 'Írja be a nevet a megerősítéshez.', typeTitleToConfirm: 'Írja be a címet a megerősítéshez.', unsavedChanges: 'Mentetlen változtatások', + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: 'Feltöltött képek', url: 'URL', userActions_title: 'Felhasználói műveletek', diff --git a/client/src/locales/id-ID/core.js b/client/src/locales/id-ID/core.js index e48b08b9..2c3380b0 100644 --- a/client/src/locales/id-ID/core.js +++ b/client/src/locales/id-ID/core.js @@ -284,6 +284,8 @@ export default { typeNameToConfirm: null, typeTitleToConfirm: null, unsavedChanges: null, + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: null, url: null, userActions_title: 'Aksi Pengguna', diff --git a/client/src/locales/it-IT/core.js b/client/src/locales/it-IT/core.js index 550991b1..788118f7 100644 --- a/client/src/locales/it-IT/core.js +++ b/client/src/locales/it-IT/core.js @@ -302,6 +302,8 @@ export default { typeNameToConfirm: 'Digita il nome per confermare', typeTitleToConfirm: 'Digita il titolo per confermare', unsavedChanges: 'Modifiche non salvate', + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: 'Immagini caricate', url: 'URL', userActions_title: 'Azioni utente', diff --git a/client/src/locales/ja-JP/core.js b/client/src/locales/ja-JP/core.js index 530e3b8a..4e7b4c7c 100644 --- a/client/src/locales/ja-JP/core.js +++ b/client/src/locales/ja-JP/core.js @@ -283,6 +283,8 @@ export default { typeNameToConfirm: null, typeTitleToConfirm: null, unsavedChanges: null, + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: null, url: null, userActions_title: 'ユーザーのアクション', diff --git a/client/src/locales/ko-KR/core.js b/client/src/locales/ko-KR/core.js index ef217792..69bd9cbb 100644 --- a/client/src/locales/ko-KR/core.js +++ b/client/src/locales/ko-KR/core.js @@ -282,6 +282,8 @@ export default { typeNameToConfirm: null, typeTitleToConfirm: null, unsavedChanges: null, + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: null, url: null, userActions_title: '사용자 작업', diff --git a/client/src/locales/nl-NL/core.js b/client/src/locales/nl-NL/core.js index 36449e64..d44745ef 100644 --- a/client/src/locales/nl-NL/core.js +++ b/client/src/locales/nl-NL/core.js @@ -284,6 +284,8 @@ export default { typeNameToConfirm: null, typeTitleToConfirm: null, unsavedChanges: null, + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: null, url: null, userActions_title: 'Gebruikersacties', diff --git a/client/src/locales/pl-PL/core.js b/client/src/locales/pl-PL/core.js index 20e66c9a..4b2dce80 100644 --- a/client/src/locales/pl-PL/core.js +++ b/client/src/locales/pl-PL/core.js @@ -291,6 +291,8 @@ export default { typeNameToConfirm: 'Wpisz nazwę aby potwierdzić.', typeTitleToConfirm: 'Wpisz tytuł aby potwierdzić.', unsavedChanges: 'Niezapisane zmiany', + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: 'Wgrane obrazy', url: null, userActions_title: 'Akcje Użytkownika', diff --git a/client/src/locales/pt-BR/core.js b/client/src/locales/pt-BR/core.js index c5dc8bc4..8fd204e3 100644 --- a/client/src/locales/pt-BR/core.js +++ b/client/src/locales/pt-BR/core.js @@ -303,6 +303,8 @@ export default { typeNameToConfirm: 'Digite o nome para confirmar.', typeTitleToConfirm: 'Digite o título para confirmar.', unsavedChanges: 'Alterações não salvas', + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: 'Imagens enviadas', url: 'URL', userActions_title: 'Ações do Usuário', diff --git a/client/src/locales/pt-PT/core.js b/client/src/locales/pt-PT/core.js index 0e335bca..b295a2f7 100644 --- a/client/src/locales/pt-PT/core.js +++ b/client/src/locales/pt-PT/core.js @@ -285,6 +285,8 @@ export default { typeNameToConfirm: null, typeTitleToConfirm: null, unsavedChanges: null, + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: null, url: null, userActions_title: 'Ações do Utilizador', diff --git a/client/src/locales/ro-RO/core.js b/client/src/locales/ro-RO/core.js index ace1b52a..27eb9da9 100644 --- a/client/src/locales/ro-RO/core.js +++ b/client/src/locales/ro-RO/core.js @@ -284,6 +284,8 @@ export default { typeNameToConfirm: null, typeTitleToConfirm: null, unsavedChanges: null, + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: null, url: null, userActions_title: 'Acțiunile utilizatorului', diff --git a/client/src/locales/ru-RU/core.js b/client/src/locales/ru-RU/core.js index eb3572ab..6260b9d1 100644 --- a/client/src/locales/ru-RU/core.js +++ b/client/src/locales/ru-RU/core.js @@ -298,6 +298,8 @@ export default { typeNameToConfirm: 'Введите имя для подтверждения', typeTitleToConfirm: 'Введите название для подтверждения', unsavedChanges: 'Несохранённые изменения', + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: 'Загруженные изображения', url: null, userActions_title: 'Действия с пользователем', diff --git a/client/src/locales/sk-SK/core.js b/client/src/locales/sk-SK/core.js index 93a6b9dd..b87063bb 100644 --- a/client/src/locales/sk-SK/core.js +++ b/client/src/locales/sk-SK/core.js @@ -283,6 +283,8 @@ export default { typeNameToConfirm: null, typeTitleToConfirm: null, unsavedChanges: null, + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: null, url: null, userActions_title: 'Akcie na používateľovi', diff --git a/client/src/locales/sr-Cyrl-RS/core.js b/client/src/locales/sr-Cyrl-RS/core.js index 185a6daf..57e41c18 100644 --- a/client/src/locales/sr-Cyrl-RS/core.js +++ b/client/src/locales/sr-Cyrl-RS/core.js @@ -283,6 +283,8 @@ export default { typeNameToConfirm: null, typeTitleToConfirm: null, unsavedChanges: null, + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: null, url: null, userActions_title: 'Корисничке радње', diff --git a/client/src/locales/sr-Latn-RS/core.js b/client/src/locales/sr-Latn-RS/core.js index 549ceb1c..10868242 100644 --- a/client/src/locales/sr-Latn-RS/core.js +++ b/client/src/locales/sr-Latn-RS/core.js @@ -280,6 +280,8 @@ export default { typeNameToConfirm: null, typeTitleToConfirm: null, unsavedChanges: null, + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: null, url: null, userActions_title: 'Korisničke radnje', diff --git a/client/src/locales/sv-SE/core.js b/client/src/locales/sv-SE/core.js index 43598f17..9a56ad8b 100644 --- a/client/src/locales/sv-SE/core.js +++ b/client/src/locales/sv-SE/core.js @@ -282,6 +282,8 @@ export default { typeNameToConfirm: null, typeTitleToConfirm: null, unsavedChanges: null, + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: null, url: null, userActions_title: 'Användaråtgärder', diff --git a/client/src/locales/tr-TR/core.js b/client/src/locales/tr-TR/core.js index 327a48a7..75449e32 100644 --- a/client/src/locales/tr-TR/core.js +++ b/client/src/locales/tr-TR/core.js @@ -280,6 +280,8 @@ export default { typeNameToConfirm: null, typeTitleToConfirm: null, unsavedChanges: null, + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: null, url: null, userActions_title: 'Kullanıcı İşlemleri', diff --git a/client/src/locales/uk-UA/core.js b/client/src/locales/uk-UA/core.js index 73b361cc..1b37e2d8 100644 --- a/client/src/locales/uk-UA/core.js +++ b/client/src/locales/uk-UA/core.js @@ -297,6 +297,8 @@ export default { typeNameToConfirm: "Введіть ім'я для підтвердження.", typeTitleToConfirm: 'Введіть назву, щоб підтвердити.', unsavedChanges: 'Незбережені зміни', + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: 'Завантажені зображення', url: 'Посилання', userActions_title: 'Дії користувача', diff --git a/client/src/locales/uz-UZ/core.js b/client/src/locales/uz-UZ/core.js index e777ac5f..411f986e 100644 --- a/client/src/locales/uz-UZ/core.js +++ b/client/src/locales/uz-UZ/core.js @@ -279,6 +279,8 @@ export default { typeNameToConfirm: null, typeTitleToConfirm: null, unsavedChanges: null, + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: null, url: null, userActions_title: 'Foydalanuvchi Amallari', diff --git a/client/src/locales/zh-CN/core.js b/client/src/locales/zh-CN/core.js index 74b68c79..0919c2e7 100644 --- a/client/src/locales/zh-CN/core.js +++ b/client/src/locales/zh-CN/core.js @@ -281,6 +281,8 @@ export default { typeNameToConfirm: '输入名称以确认', typeTitleToConfirm: '输入标题以确认', unsavedChanges: '未保存的更改', + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: '已上传图片', url: '网址', userActions_title: '用户操作', diff --git a/client/src/locales/zh-TW/core.js b/client/src/locales/zh-TW/core.js index 3e6d7257..07a00f70 100644 --- a/client/src/locales/zh-TW/core.js +++ b/client/src/locales/zh-TW/core.js @@ -277,6 +277,8 @@ export default { typeNameToConfirm: null, typeTitleToConfirm: null, unsavedChanges: null, + uploadFailedFileIsTooBig: null, + uploadFailedNotEnoughStorageSpace: null, uploadedImages: null, url: null, userActions_title: '使用者操作', diff --git a/client/src/sagas/core/services/attachments.js b/client/src/sagas/core/services/attachments.js index 604594ed..6237aef8 100644 --- a/client/src/sagas/core/services/attachments.js +++ b/client/src/sagas/core/services/attachments.js @@ -6,6 +6,7 @@ import omit from 'lodash/omit'; import truncate from 'lodash/truncate'; import { call, put, select } from 'redux-saga/effects'; +import toast from 'react-hot-toast'; import request from '../request'; import selectors from '../../../selectors'; @@ -13,6 +14,7 @@ import actions from '../../../actions'; import api from '../../../api'; import { createLocalId } from '../../../utils/local-id'; import { AttachmentTypes } from '../../../constants/Enums'; +import ToastTypes from '../../../constants/ToastTypes'; export function* createAttachment(cardId, data) { const localId = yield call(createLocalId); @@ -41,6 +43,22 @@ export function* createAttachment(cardId, data) { : call(request, api.createAttachment, cardId, nextData)); } catch (error) { yield put(actions.createAttachment.failure(localId, error)); + + if (error.code === 'E_UNPROCESSABLE_ENTITY') { + let toastType; + if (error.message.startsWith('Upload limit')) { + toastType = ToastTypes.FILE_IS_TOO_BIG; + } else if (error.message === 'Storage limit reached') { + toastType = ToastTypes.NOT_ENOUGH_STORAGE; + } + + if (toastType) { + yield call(toast, { + type: toastType, + }); + } + } + return; } diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 8fbd0bb6..60341a02 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -19,6 +19,7 @@ services: # - LOG_LEVEL=warn # - TRUST_PROXY=true + # - MAX_UPLOAD_FILE_SIZE= # - TOKEN_EXPIRES_IN=365 # In days # related: https://github.com/knex/knex/issues/2354 @@ -39,6 +40,7 @@ services: # - DEFAULT_ADMIN_USERNAME=demo # - INTERNAL_ACCESS_TOKEN= + # - STORAGE_LIMIT= # - ACTIVE_USERS_LIMIT= # Set to true to show more detailed authentication error messages. diff --git a/docker-compose.yml b/docker-compose.yml index 199c5823..fe9960f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,7 @@ services: # - LOG_LEVEL=warn # - TRUST_PROXY=true + # - MAX_UPLOAD_FILE_SIZE= # - TOKEN_EXPIRES_IN=365 # In days # related: https://github.com/knex/knex/issues/2354 @@ -53,6 +54,7 @@ services: # - DEFAULT_ADMIN_USERNAME=demo # - INTERNAL_ACCESS_TOKEN= + # - STORAGE_LIMIT= # - ACTIVE_USERS_LIMIT= # Set to true to show more detailed authentication error messages. diff --git a/server/.env.sample b/server/.env.sample index a5a64d0e..2c64a033 100644 --- a/server/.env.sample +++ b/server/.env.sample @@ -10,6 +10,7 @@ SECRET_KEY=notsecretkey # LOG_FILE= # TRUST_PROXY=true +# MAX_UPLOAD_FILE_SIZE= # TOKEN_EXPIRES_IN=365 # In days # related: https://github.com/knex/knex/issues/2354 @@ -30,6 +31,7 @@ SECRET_KEY=notsecretkey # DEFAULT_ADMIN_USERNAME=demo # INTERNAL_ACCESS_TOKEN= +# STORAGE_LIMIT= # ACTIVE_USERS_LIMIT= # Set to true to show more detailed authentication error messages. diff --git a/server/api/controllers/access-tokens/accept-terms.js b/server/api/controllers/access-tokens/accept-terms.js index 19ec9776..3b616c4a 100644 --- a/server/api/controllers/access-tokens/accept-terms.js +++ b/server/api/controllers/access-tokens/accept-terms.js @@ -91,10 +91,10 @@ module.exports = { throw Errors.INVALID_SIGNATURE; } - user = await User.qm.updateOne(user.id, { + ({ user } = await User.qm.updateOne(user.id, { termsSignature, termsAcceptedAt: new Date().toISOString(), - }); + })); } const config = await Config.qm.getOneMain(); diff --git a/server/api/controllers/attachments/create.js b/server/api/controllers/attachments/create.js index 078805e6..eee4ea53 100644 --- a/server/api/controllers/attachments/create.js +++ b/server/api/controllers/attachments/create.js @@ -91,7 +91,7 @@ module.exports = { if (inputs.type === Attachment.Types.FILE) { let files; try { - files = await sails.helpers.utils.receiveFile('file', this.req); + files = await sails.helpers.utils.receiveFile(this.req.file('file')); } catch (error) { return exits.uploadError(error.message); // TODO: add error } diff --git a/server/api/controllers/background-images/create.js b/server/api/controllers/background-images/create.js index dcce064f..547758a6 100644 --- a/server/api/controllers/background-images/create.js +++ b/server/api/controllers/background-images/create.js @@ -62,7 +62,7 @@ module.exports = { let files; try { - files = await sails.helpers.utils.receiveFile('file', this.req); + files = await sails.helpers.utils.receiveFile(this.req.file('file')); } catch (error) { return exits.uploadError(error.message); // TODO: add error } diff --git a/server/api/controllers/boards/create.js b/server/api/controllers/boards/create.js index b868cd36..7a733e08 100755 --- a/server/api/controllers/boards/create.js +++ b/server/api/controllers/boards/create.js @@ -59,7 +59,7 @@ module.exports = { }, }, - async fn(inputs) { + async fn(inputs, exits) { const { currentUser } = this.req; const project = await Project.qm.getOneById(inputs.projectId); @@ -78,7 +78,7 @@ module.exports = { if (inputs.importType) { let files; try { - files = await sails.helpers.utils.receiveFile('importFile', this.req); + files = await sails.helpers.utils.receiveFile(this.req.file('importFile'), false); } catch (error) { return exits.uploadError(error.message); // TODO: add error } @@ -114,11 +114,11 @@ module.exports = { request: this.req, }); - return { + return exits.success({ item: board, included: { boardMemberships: [boardMembership], }, - }; + }); }, }; diff --git a/server/api/controllers/file-attachments/download-thumbnail.js b/server/api/controllers/file-attachments/download-thumbnail.js index 96db9161..077bb873 100644 --- a/server/api/controllers/file-attachments/download-thumbnail.js +++ b/server/api/controllers/file-attachments/download-thumbnail.js @@ -79,7 +79,7 @@ module.exports = { let readStream; try { readStream = await fileManager.read( - `${sails.config.custom.attachmentsPathSegment}/${attachment.data.fileReferenceId}/thumbnails/${inputs.fileName}.${inputs.fileExtension}`, + `${sails.config.custom.attachmentsPathSegment}/${attachment.data.uploadedFileId}/thumbnails/${inputs.fileName}.${inputs.fileExtension}`, ); } catch (error) { throw Errors.FILE_ATTACHMENT_NOT_FOUND; diff --git a/server/api/controllers/file-attachments/download.js b/server/api/controllers/file-attachments/download.js index 6c626e6b..8419bceb 100644 --- a/server/api/controllers/file-attachments/download.js +++ b/server/api/controllers/file-attachments/download.js @@ -72,7 +72,7 @@ module.exports = { let readStream; try { readStream = await fileManager.read( - `${sails.config.custom.attachmentsPathSegment}/${attachment.data.fileReferenceId}/${attachment.data.filename}`, + `${sails.config.custom.attachmentsPathSegment}/${attachment.data.uploadedFileId}/${attachment.data.filename}`, ); } catch (error) { throw Errors.FILE_ATTACHMENT_NOT_FOUND; diff --git a/server/api/controllers/users/update-avatar.js b/server/api/controllers/users/update-avatar.js index 1af1528f..c6d465d0 100755 --- a/server/api/controllers/users/update-avatar.js +++ b/server/api/controllers/users/update-avatar.js @@ -58,7 +58,7 @@ module.exports = { let files; try { - files = await sails.helpers.utils.receiveFile('file', this.req); + files = await sails.helpers.utils.receiveFile(this.req.file('file')); } catch (error) { return exits.uploadError(error.message); // TODO: add error } diff --git a/server/api/helpers/attachments/delete-one.js b/server/api/helpers/attachments/delete-one.js index 689cc634..50dde915 100644 --- a/server/api/helpers/attachments/delete-one.js +++ b/server/api/helpers/attachments/delete-one.js @@ -51,13 +51,11 @@ module.exports = { }); } - const { attachment, fileReference } = await Attachment.qm.deleteOne(inputs.record.id, { - isFile: inputs.record.type === Attachment.Types.FILE, - }); + const { attachment, uploadedFile } = await Attachment.qm.deleteOne(inputs.record.id); if (attachment) { - if (fileReference) { - sails.helpers.attachments.removeUnreferencedFiles(fileReference); + if (uploadedFile) { + sails.helpers.utils.removeUnreferencedUploadedFiles(uploadedFile); } sails.sockets.broadcast( diff --git a/server/api/helpers/attachments/present-one.js b/server/api/helpers/attachments/present-one.js index d0a3b004..db94b9c2 100644 --- a/server/api/helpers/attachments/present-one.js +++ b/server/api/helpers/attachments/present-one.js @@ -20,7 +20,7 @@ module.exports = { ...inputs.record, data: { ..._.omit(inputs.record.data, [ - 'fileReferenceId', + 'uploadedFileId', 'filename', 'image.thumbnailsExtension', ]), diff --git a/server/api/helpers/attachments/process-uploaded-file.js b/server/api/helpers/attachments/process-uploaded-file.js index 8f1489de..52d48830 100644 --- a/server/api/helpers/attachments/process-uploaded-file.js +++ b/server/api/helpers/attachments/process-uploaded-file.js @@ -10,7 +10,7 @@ const mime = require('mime'); const sharp = require('sharp'); const filenamify = require('../../../utils/filenamify'); -const { MAX_SIZE_IN_BYTES_TO_GET_ENCODING } = require('../../../constants'); +const { MAX_SIZE_TO_GET_ENCODING } = require('../../../constants'); module.exports = { inputs: { @@ -23,17 +23,22 @@ module.exports = { async fn(inputs) { const fileManager = sails.hooks['file-manager'].getInstance(); - const { id: fileReferenceId } = await FileReference.create().fetch(); - const dirPathSegment = `${sails.config.custom.attachmentsPathSegment}/${fileReferenceId}`; const filename = filenamify(inputs.file.filename); - const mimeType = mime.getType(filename); - const sizeInBytes = inputs.file.size; + const { size } = inputs.file; + + const { id: uploadedFileId } = await UploadedFile.qm.createOne({ + mimeType, + size, + type: UploadedFile.Types.ATTACHMENT, + }); + + const dirPathSegment = `${sails.config.custom.attachmentsPathSegment}/${uploadedFileId}`; let buffer; let encoding = null; - if (sizeInBytes <= MAX_SIZE_IN_BYTES_TO_GET_ENCODING) { + if (size <= MAX_SIZE_TO_GET_ENCODING) { try { buffer = await fsPromises.readFile(inputs.file.fd); } catch (error) { @@ -52,10 +57,10 @@ module.exports = { ); const data = { - fileReferenceId, + uploadedFileId, filename, mimeType, - sizeInBytes, + size, encoding, image: null, }; diff --git a/server/api/helpers/attachments/remove-unreferenced-files.js b/server/api/helpers/attachments/remove-unreferenced-files.js deleted file mode 100644 index d964537a..00000000 --- a/server/api/helpers/attachments/remove-unreferenced-files.js +++ /dev/null @@ -1,35 +0,0 @@ -/*! - * Copyright (c) 2024 PLANKA Software GmbH - * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md - */ - -module.exports = { - sync: true, - - inputs: { - fileReferenceOrFileReferences: { - type: 'ref', - required: true, - }, - }, - - fn(inputs) { - const fileReferences = _.isPlainObject(inputs.fileReferenceOrFileReferences) - ? [inputs.fileReferenceOrFileReferences] - : inputs.fileReferenceOrFileReferences; - - const fileManager = sails.hooks['file-manager'].getInstance(); - - fileReferences.forEach(async (fileReference) => { - if (fileReference.total !== null) { - return; - } - - await fileManager.deleteDir( - `${sails.config.custom.attachmentsPathSegment}/${fileReference.id}`, - ); - - await FileReference.destroyOne(fileReference.id); - }); - }, -}; diff --git a/server/api/helpers/background-images/delete-one.js b/server/api/helpers/background-images/delete-one.js index ab839550..f1d62438 100644 --- a/server/api/helpers/background-images/delete-one.js +++ b/server/api/helpers/background-images/delete-one.js @@ -43,10 +43,10 @@ module.exports = { } } - const backgroundImage = await BackgroundImage.qm.deleteOne(inputs.record.id); + const { backgroundImage, uploadedFile } = await BackgroundImage.qm.deleteOne(inputs.record.id); if (backgroundImage) { - sails.helpers.backgroundImages.removeRelatedFiles(backgroundImage); + sails.helpers.utils.removeUnreferencedUploadedFiles(uploadedFile); const projectRelatedUserIds = await scoper.getProjectRelatedUserIds(); diff --git a/server/api/helpers/background-images/present-one.js b/server/api/helpers/background-images/present-one.js index 97c5b6a9..8d81ce67 100644 --- a/server/api/helpers/background-images/present-one.js +++ b/server/api/helpers/background-images/present-one.js @@ -17,10 +17,10 @@ module.exports = { const fileManager = sails.hooks['file-manager'].getInstance(); return { - ..._.omit(inputs.record, ['dirname', 'extension']), - url: `${fileManager.buildUrl(`${sails.config.custom.backgroundImagesPathSegment}/${inputs.record.dirname}/original.${inputs.record.extension}`)}`, + ..._.omit(inputs.record, ['uploadedFileId', 'extension']), + url: `${fileManager.buildUrl(`${sails.config.custom.backgroundImagesPathSegment}/${inputs.record.uploadedFileId}/original.${inputs.record.extension}`)}`, thumbnailUrls: { - outside360: `${fileManager.buildUrl(`${sails.config.custom.backgroundImagesPathSegment}/${inputs.record.dirname}/outside-360.${inputs.record.extension}`)}`, + outside360: `${fileManager.buildUrl(`${sails.config.custom.backgroundImagesPathSegment}/${inputs.record.uploadedFileId}/outside-360.${inputs.record.extension}`)}`, }, }; }, diff --git a/server/api/helpers/background-images/process-uploaded-file.js b/server/api/helpers/background-images/process-uploaded-file.js index ff5838e0..803d123a 100644 --- a/server/api/helpers/background-images/process-uploaded-file.js +++ b/server/api/helpers/background-images/process-uploaded-file.js @@ -3,9 +3,9 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ +const { v4: uuid } = require('uuid'); const { rimraf } = require('rimraf'); const mime = require('mime'); -const { v4: uuid } = require('uuid'); const sharp = require('sharp'); module.exports = { @@ -32,8 +32,16 @@ module.exports = { }); let metadata; + let originalBuffer; + try { metadata = await image.metadata(); + + if (metadata.orientation && metadata.orientation > 4) { + image = image.rotate(); + } + + originalBuffer = await image.toBuffer(); } catch (error) { await rimraf(inputs.file.fd); throw 'fileIsNotImage'; @@ -41,20 +49,19 @@ module.exports = { const fileManager = sails.hooks['file-manager'].getInstance(); - const dirname = uuid(); - const dirPathSegment = `${sails.config.custom.backgroundImagesPathSegment}/${dirname}`; - - if (metadata.orientation && metadata.orientation > 4) { - image = image.rotate(); - } - const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format; + const size = originalBuffer.length; + + const { id: uploadedFileId } = await UploadedFile.qm.createOne({ + mimeType, + size, + id: uuid(), + type: UploadedFile.Types.BACKGROUND_IMAGE, + }); + + const dirPathSegment = `${sails.config.custom.backgroundImagesPathSegment}/${uploadedFileId}`; - let sizeInBytes; try { - const originalBuffer = await image.toBuffer(); - sizeInBytes = originalBuffer.length; - await fileManager.save( `${dirPathSegment}/original.${extension}`, originalBuffer, @@ -82,6 +89,7 @@ module.exports = { await fileManager.deleteDir(dirPathSegment); await rimraf(inputs.file.fd); + await UploadedFile.qm.deleteOne(uploadedFileId); throw 'fileIsNotImage'; } @@ -89,9 +97,9 @@ module.exports = { await rimraf(inputs.file.fd); return { - dirname, + uploadedFileId, extension, - sizeInBytes, + size, }; }, }; diff --git a/server/api/helpers/background-images/remove-related-files.js b/server/api/helpers/background-images/remove-related-files.js deleted file mode 100644 index c788f793..00000000 --- a/server/api/helpers/background-images/remove-related-files.js +++ /dev/null @@ -1,29 +0,0 @@ -/*! - * Copyright (c) 2024 PLANKA Software GmbH - * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md - */ - -module.exports = { - sync: true, - - inputs: { - recordOrRecords: { - type: 'ref', - required: true, - }, - }, - - fn(inputs) { - const backgroundImages = _.isPlainObject(inputs.recordOrRecords) - ? [inputs.recordOrRecords] - : inputs.recordOrRecords; - - const fileManager = sails.hooks['file-manager'].getInstance(); - - backgroundImages.forEach(async (backgroundImage) => { - await fileManager.deleteDir( - `${sails.config.custom.backgroundImagesPathSegment}/${backgroundImage.dirname}`, - ); - }); - }, -}; diff --git a/server/api/helpers/cards/delete-related.js b/server/api/helpers/cards/delete-related.js index aa303d0e..10ad3135 100644 --- a/server/api/helpers/cards/delete-related.js +++ b/server/api/helpers/cards/delete-related.js @@ -48,11 +48,11 @@ module.exports = { }, ); - const { fileReferences } = await Attachment.qm.delete({ + const { uploadedFiles } = await Attachment.qm.delete({ cardId: cardIdOrIds, }); - sails.helpers.attachments.removeUnreferencedFiles(fileReferences); + sails.helpers.utils.removeUnreferencedUploadedFiles(uploadedFiles); const customFieldGroups = await CustomFieldGroup.qm.delete({ cardId: cardIdOrIds, diff --git a/server/api/helpers/projects/delete-related.js b/server/api/helpers/projects/delete-related.js index 22986978..43174f1a 100644 --- a/server/api/helpers/projects/delete-related.js +++ b/server/api/helpers/projects/delete-related.js @@ -29,11 +29,11 @@ module.exports = { projectId: projectIdOrIds, }); - const backgroundImages = await BackgroundImage.qm.delete({ + const { uploadedFiles } = await BackgroundImage.qm.delete({ projectId: projectIdOrIds, }); - sails.helpers.backgroundImages.removeRelatedFiles(backgroundImages); + sails.helpers.utils.removeUnreferencedUploadedFiles(uploadedFiles); const baseCustomFieldGroups = await BaseCustomFieldGroup.qm.delete({ projectId: projectIdOrIds, diff --git a/server/api/helpers/users/delete-one.js b/server/api/helpers/users/delete-one.js index 9e891072..8d989837 100644 --- a/server/api/helpers/users/delete-one.js +++ b/server/api/helpers/users/delete-one.js @@ -23,10 +23,12 @@ module.exports = { inputs.record, ); - const user = await User.qm.deleteOne(inputs.record.id); + const { user, uploadedFile } = await User.qm.deleteOne(inputs.record.id); if (user) { - sails.helpers.users.removeRelatedFiles(user); + if (uploadedFile) { + sails.helpers.utils.removeUnreferencedUploadedFiles(uploadedFile); + } const scoper = sails.helpers.users.makeScoper(user); scoper.boardMemberships = boardMemberships; diff --git a/server/api/helpers/users/present-one.js b/server/api/helpers/users/present-one.js index c6ce6ef9..856d1d20 100644 --- a/server/api/helpers/users/present-one.js +++ b/server/api/helpers/users/present-one.js @@ -28,9 +28,9 @@ module.exports = { 'termsAcceptedAt', ]), avatar: inputs.record.avatar && { - url: `${fileManager.buildUrl(`${sails.config.custom.userAvatarsPathSegment}/${inputs.record.avatar.dirname}/original.${inputs.record.avatar.extension}`)}`, + url: `${fileManager.buildUrl(`${sails.config.custom.userAvatarsPathSegment}/${inputs.record.avatar.uploadedFileId}/original.${inputs.record.avatar.extension}`)}`, thumbnailUrls: { - cover180: `${fileManager.buildUrl(`${sails.config.custom.userAvatarsPathSegment}/${inputs.record.avatar.dirname}/cover-180.${inputs.record.avatar.extension}`)}`, + cover180: `${fileManager.buildUrl(`${sails.config.custom.userAvatarsPathSegment}/${inputs.record.avatar.uploadedFileId}/cover-180.${inputs.record.avatar.extension}`)}`, }, }, termsType: sails.hooks.terms.getTypeByUserRole(inputs.record.role), diff --git a/server/api/helpers/users/process-uploaded-avatar-file.js b/server/api/helpers/users/process-uploaded-avatar-file.js index 9ca225d4..f71c4787 100644 --- a/server/api/helpers/users/process-uploaded-avatar-file.js +++ b/server/api/helpers/users/process-uploaded-avatar-file.js @@ -3,9 +3,9 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ +const { v4: uuid } = require('uuid'); const { rimraf } = require('rimraf'); const mime = require('mime'); -const { v4: uuid } = require('uuid'); const sharp = require('sharp'); module.exports = { @@ -32,8 +32,16 @@ module.exports = { }); let metadata; + let originalBuffer; + try { metadata = await image.metadata(); + + if (metadata.orientation && metadata.orientation > 4) { + image = image.rotate(); + } + + originalBuffer = await image.toBuffer(); } catch (error) { await rimraf(inputs.file.fd); throw 'fileIsNotImage'; @@ -41,20 +49,19 @@ module.exports = { const fileManager = sails.hooks['file-manager'].getInstance(); - const dirname = uuid(); - const dirPathSegment = `${sails.config.custom.userAvatarsPathSegment}/${dirname}`; - - if (metadata.orientation && metadata.orientation > 4) { - image = image.rotate(); - } - const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format; + const size = originalBuffer.length; + + const { id: uploadedFileId } = await UploadedFile.qm.createOne({ + mimeType, + size, + id: uuid(), + type: UploadedFile.Types.USER_AVATAR, + }); + + const dirPathSegment = `${sails.config.custom.userAvatarsPathSegment}/${uploadedFileId}`; - let sizeInBytes; try { - const originalBuffer = await image.toBuffer(); - sizeInBytes = originalBuffer.length; - await fileManager.save( `${dirPathSegment}/original.${extension}`, originalBuffer, @@ -81,6 +88,7 @@ module.exports = { await fileManager.deleteDir(dirPathSegment); await rimraf(inputs.file.fd); + await UploadedFile.qm.deleteOne(uploadedFileId); throw 'fileIsNotImage'; } @@ -88,9 +96,9 @@ module.exports = { await rimraf(inputs.file.fd); return { - dirname, + uploadedFileId, extension, - sizeInBytes, + size, }; }, }; diff --git a/server/api/helpers/users/update-one.js b/server/api/helpers/users/update-one.js index c4098065..edd5f9d5 100644 --- a/server/api/helpers/users/update-one.js +++ b/server/api/helpers/users/update-one.js @@ -69,8 +69,10 @@ module.exports = { } let user; + let uploadedFile; + try { - user = await User.qm.updateOne(inputs.record.id, values); + ({ user, uploadedFile } = await User.qm.updateOne(inputs.record.id, values)); } catch (error) { if (error.code === 'E_UNIQUE') { throw 'emailAlreadyInUse'; @@ -91,10 +93,8 @@ module.exports = { } if (user) { - if (inputs.record.avatar) { - if (!user.avatar || user.avatar.dirname !== inputs.record.avatar.dirname) { - sails.helpers.users.removeRelatedFiles(inputs.record); - } + if (uploadedFile) { + sails.helpers.utils.removeUnreferencedUploadedFiles(uploadedFile); } if (!_.isUndefined(values.password) || isDeactivatedChangeToTrue) { diff --git a/server/api/helpers/utils/download-favicon.js b/server/api/helpers/utils/download-favicon.js index 3849f39f..a907f68b 100644 --- a/server/api/helpers/utils/download-favicon.js +++ b/server/api/helpers/utils/download-favicon.js @@ -8,7 +8,7 @@ const icoToPng = require('ico-to-png'); const sharp = require('sharp'); const FETCH_TIMEOUT = 4000; -const MAX_RESPONSE_LENGTH_IN_BYTES = 1024 * 1024; +const MAX_RESPONSE_LENGTH = 1024 * 1024; const FAVICON_TAGS_REGEX = /]*rel="([^"]* )?icon( [^"]*)?"[^>]*>/gi; const HREF_REGEX = /href="(.*?)"/i; @@ -39,7 +39,7 @@ const readResponse = async (response) => { chunks.push(value); receivedLength += value.length; - if (receivedLength > MAX_RESPONSE_LENGTH_IN_BYTES) { + if (receivedLength > MAX_RESPONSE_LENGTH) { reader.cancel(); return { @@ -133,6 +133,12 @@ module.exports = { return; } + const availableStorage = await sails.helpers.utils.getAvailableStorage(); + + if (availableStorage !== null && readedResponse.buffer.length >= availableStorage) { + return; + } + let image = sharp(readedResponse.buffer); let metadata; diff --git a/server/api/helpers/utils/get-available-storage.js b/server/api/helpers/utils/get-available-storage.js new file mode 100644 index 00000000..c09bce07 --- /dev/null +++ b/server/api/helpers/utils/get-available-storage.js @@ -0,0 +1,17 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +module.exports = { + async fn() { + const { storageLimit } = sails.config.custom; + + if (_.isNil(storageLimit)) { + return null; + } + + const storageUsage = await StorageUsage.qm.getOneMain(); + return BigInt(storageLimit) - BigInt(storageUsage.total); + }, +}; diff --git a/server/api/helpers/utils/receive-file.js b/server/api/helpers/utils/receive-file.js index 2602740e..0fde3812 100644 --- a/server/api/helpers/utils/receive-file.js +++ b/server/api/helpers/utils/receive-file.js @@ -4,37 +4,55 @@ */ const util = require('util'); -const { v4: uuid } = require('uuid'); module.exports = { - friendlyName: 'Receive uploaded file from request', - - description: - 'Store a file uploaded from a MIME-multipart request part. The resulting file will have a unique UUID-based name with the same extension.', - inputs: { - paramName: { - type: 'string', - required: true, - description: 'The MIME multi-part parameter containing the file to receive.', - }, - req: { + file: { type: 'ref', required: true, - description: 'The request to receive the file from.', + }, + enforceStorageLimit: { + type: 'boolean', + defaultsTo: true, }, }, async fn(inputs, exits) { + const { maxUploadFileSize } = sails.config.custom; + + let availableStorage = null; + if (inputs.enforceStorageLimit) { + availableStorage = await sails.helpers.utils.getAvailableStorage(); + } + + let maxBytes = _.isNil(maxUploadFileSize) ? null : maxUploadFileSize; + if (availableStorage !== null) { + if (maxBytes) { + maxBytes = availableStorage < maxBytes ? availableStorage : maxBytes; + } else { + maxBytes = availableStorage; + } + } + const upload = util.promisify((options, callback) => - inputs.req.file(inputs.paramName).upload(options, (error, files) => callback(error, files)), + inputs.file.upload(options, (error, files) => { + if ( + error && + error.code === 'E_EXCEEDS_UPLOAD_LIMIT' && + availableStorage !== null && + (_.isNil(maxUploadFileSize) || error.maxBytes < maxUploadFileSize) + ) { + return callback(new Error('Storage limit reached'), files); + } + + return callback(error, files); + }), ); return exits.success( await upload({ + maxBytes, dirname: sails.config.custom.uploadsTempPath, - saveAs: uuid(), - maxBytes: null, }), ); }, diff --git a/server/api/helpers/utils/remove-unreferenced-uploaded-files.js b/server/api/helpers/utils/remove-unreferenced-uploaded-files.js new file mode 100644 index 00000000..f2164713 --- /dev/null +++ b/server/api/helpers/utils/remove-unreferenced-uploaded-files.js @@ -0,0 +1,41 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +const { Types } = require('../../models/UploadedFile'); + +const PATH_SEGMENT_BY_TYPE = { + [Types.USER_AVATAR]: sails.config.custom.userAvatarsPathSegment, + [Types.BACKGROUND_IMAGE]: sails.config.custom.backgroundImagesPathSegment, + [Types.ATTACHMENT]: sails.config.custom.attachmentsPathSegment, +}; + +module.exports = { + sync: true, + + inputs: { + uploadedFileOrUploadedFiles: { + type: 'ref', + required: true, + }, + }, + + fn(inputs) { + const uploadedFiles = _.isPlainObject(inputs.uploadedFileOrUploadedFiles) + ? [inputs.uploadedFileOrUploadedFiles] + : inputs.uploadedFileOrUploadedFiles; + + const fileManager = sails.hooks['file-manager'].getInstance(); + + // TODO: optimize? + uploadedFiles.forEach(async (uploadedFile) => { + if (uploadedFile.referencesTotal !== null) { + return; + } + + await fileManager.deleteDir(`${PATH_SEGMENT_BY_TYPE[uploadedFile.type]}/${uploadedFile.id}`); + await UploadedFile.qm.deleteOne(uploadedFile.id); + }); + }, +}; diff --git a/server/api/hooks/file-manager/LocalFileManager.js b/server/api/hooks/file-manager/LocalFileManager.js index b5053fcc..2b4d7f21 100644 --- a/server/api/hooks/file-manager/LocalFileManager.js +++ b/server/api/hooks/file-manager/LocalFileManager.js @@ -44,7 +44,7 @@ class LocalFileManager { } // eslint-disable-next-line class-methods-use-this - async getSizeInBytes(filePathSegment) { + async getSize(filePathSegment) { let result; try { result = await fs.promises.stat(buildPath(filePathSegment)); diff --git a/server/api/hooks/file-manager/S3FileManager.js b/server/api/hooks/file-manager/S3FileManager.js index a4e59e92..50738736 100644 --- a/server/api/hooks/file-manager/S3FileManager.js +++ b/server/api/hooks/file-manager/S3FileManager.js @@ -52,7 +52,7 @@ class S3FileManager { return result.Body; } - async getSizeInBytes(filePathSegment) { + async getSize(filePathSegment) { const headObjectCommand = new HeadObjectCommand({ Bucket: sails.config.custom.s3Bucket, Key: filePathSegment, diff --git a/server/api/hooks/query-methods/helpers.js b/server/api/hooks/query-methods/helpers.js new file mode 100644 index 00000000..3597db73 --- /dev/null +++ b/server/api/hooks/query-methods/helpers.js @@ -0,0 +1,36 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +const makeWhereQueryBuilder = (Model) => (criteria) => { + if (_.isPlainObject(criteria)) { + if (Object.keys(criteria).length === 0) { + throw new Error('Empty criteria'); + } + + const parts = []; + const values = []; + + // eslint-disable-next-line no-restricted-syntax + for (const [key, value] of Object.entries(criteria)) { + // eslint-disable-next-line no-underscore-dangle + const columnName = Model._transformer._transformations[key]; + + if (!columnName) { + throw new Error('Unknown column'); + } + + parts.push(`${columnName} = $${index + 1}`); + values.push(value); + } + + return [parts.join(' AND '), values]; + } + + return ['id = $1', [criteria]]; +}; + +module.exports = { + makeWhereQueryBuilder, +}; diff --git a/server/api/hooks/query-methods/models/Attachment.js b/server/api/hooks/query-methods/models/Attachment.js index 67852597..af668a8e 100644 --- a/server/api/hooks/query-methods/models/Attachment.js +++ b/server/api/hooks/query-methods/models/Attachment.js @@ -13,25 +13,24 @@ const create = (arrayOfValues) => { const arrayOfFileValues = arrayOfValues.filter(({ type }) => type === Attachment.Types.FILE); if (arrayOfFileValues.length > 0) { - const arrayOfValuesByFileReferenceId = _.groupBy(arrayOfFileValues, 'data.fileReferenceId'); + const arrayOfValuesByUploadedFileId = _.groupBy(arrayOfFileValues, 'data.uploadedFileId'); + const uploadedFileIds = Object.keys(arrayOfValuesByUploadedFileId); - const fileReferenceIds = Object.keys(arrayOfValuesByFileReferenceId); - - const fileReferenceIdsByTotal = Object.entries(arrayOfValuesByFileReferenceId).reduce( - (result, [fileReferenceId, arrayOfValuesItem]) => ({ + const uploadedFileIdsByTotal = Object.entries(arrayOfValuesByUploadedFileId).reduce( + (result, [uploadedFileId, arrayOfValuesItem]) => ({ ...result, - [arrayOfValuesItem.length]: [...(result[arrayOfValuesItem.length] || []), fileReferenceId], + [arrayOfValuesItem.length]: [...(result[arrayOfValuesItem.length] || []), uploadedFileId], }), {}, ); return sails.getDatastore().transaction(async (db) => { const queryValues = []; - let query = `UPDATE file_reference SET total = total + CASE `; + let query = `UPDATE uploaded_file SET references_total = references_total + CASE `; - Object.entries(fileReferenceIdsByTotal).forEach(([total, fileReferenceIdsItem]) => { - const inValues = fileReferenceIdsItem.map((fileReferenceId) => { - queryValues.push(fileReferenceId); + Object.entries(uploadedFileIdsByTotal).forEach(([total, uploadedFileIdsItem]) => { + const inValues = uploadedFileIdsItem.map((uploadedFileId) => { + queryValues.push(uploadedFileId); return `$${queryValues.length}`; }); @@ -39,25 +38,25 @@ const create = (arrayOfValues) => { query += `WHEN id IN (${inValues.join(', ')}) THEN $${queryValues.length}::int `; }); - const inValues = fileReferenceIds.map((fileReferenceId) => { - queryValues.push(fileReferenceId); + const inValues = uploadedFileIds.map((uploadedFileId) => { + queryValues.push(uploadedFileId); return `$${queryValues.length}`; }); queryValues.push(new Date().toISOString()); - query += `END, updated_at = $${queryValues.length} WHERE id IN (${inValues.join(', ')}) AND total IS NOT NULL RETURNING id`; + query += `END, updated_at = $${queryValues.length} WHERE id IN (${inValues.join(', ')}) AND references_total IS NOT NULL RETURNING id`; const queryResult = await sails.sendNativeQuery(query, queryValues).usingConnection(db); - const nextFileReferenceIds = sails.helpers.utils.mapRecords(queryResult.rows); + const nextUploadedFileIds = sails.helpers.utils.mapRecords(queryResult.rows); - if (nextFileReferenceIds.length < fileReferenceIds.length) { - const nextFileReferenceIdsSet = new Set(nextFileReferenceIds); + if (nextUploadedFileIds.length < uploadedFileIds.length) { + const nextUploadedFileIdsSet = new Set(nextUploadedFileIds); // eslint-disable-next-line no-param-reassign arrayOfValues = arrayOfValues.filter( (values) => values.type !== Attachment.Types.FILE || - nextFileReferenceIdsSet.has(values.data.fileReferenceId), + nextUploadedFileIdsSet.has(values.data.uploadedFileId), ); } @@ -70,8 +69,6 @@ const create = (arrayOfValues) => { const createOne = (values) => { if (values.type === Attachment.Types.FILE) { - const { fileReferenceId } = values.data; - return sails.getDatastore().transaction(async (db) => { const attachment = await Attachment.create({ ...values }) .fetch() @@ -79,13 +76,13 @@ const createOne = (values) => { const queryResult = await sails .sendNativeQuery( - 'UPDATE file_reference SET total = total + 1, updated_at = $1 WHERE id = $2 AND total IS NOT NULL', - [new Date().toISOString(), fileReferenceId], + 'UPDATE uploaded_file SET references_total = references_total + 1, updated_at = $1 WHERE id = $2 AND references_total IS NOT NULL', + [new Date().toISOString(), values.data.uploadedFileId], ) .usingConnection(db); if (queryResult.rowCount === 0) { - throw 'fileReferenceNotFound'; + throw 'uploadedFileNotFound'; } return attachment; @@ -129,24 +126,24 @@ const delete_ = (criteria) => const attachments = await Attachment.destroy(criteria).fetch().usingConnection(db); const fileAttachments = attachments.filter(({ type }) => type === Attachment.Types.FILE); - let fileReferences = []; + let uploadedFiles = []; if (fileAttachments.length > 0) { - const attachmentsByFileReferenceId = _.groupBy(fileAttachments, 'data.fileReferenceId'); + const attachmentsByUploadedFileId = _.groupBy(fileAttachments, 'data.uploadedFileId'); - const fileReferenceIdsByTotal = Object.entries(attachmentsByFileReferenceId).reduce( - (result, [fileReferenceId, attachmentsItem]) => ({ + const uploadedFileIdsByTotal = Object.entries(attachmentsByUploadedFileId).reduce( + (result, [uploadedFileId, attachmentsItem]) => ({ ...result, - [attachmentsItem.length]: [...(result[attachmentsItem.length] || []), fileReferenceId], + [attachmentsItem.length]: [...(result[attachmentsItem.length] || []), uploadedFileId], }), {}, ); const queryValues = []; - let query = 'UPDATE file_reference SET total = CASE WHEN total = CASE '; + let query = 'UPDATE uploaded_file SET references_total = CASE WHEN references_total = CASE '; - Object.entries(fileReferenceIdsByTotal).forEach(([total, fileReferenceIds]) => { - const inValues = fileReferenceIds.map((fileReferenceId) => { - queryValues.push(fileReferenceId); + Object.entries(uploadedFileIdsByTotal).forEach(([total, uploadedFileIds]) => { + const inValues = uploadedFileIds.map((uploadedFileId) => { + queryValues.push(uploadedFileId); return `$${queryValues.length}`; }); @@ -154,11 +151,11 @@ const delete_ = (criteria) => query += `WHEN id IN (${inValues.join(', ')}) THEN $${queryValues.length}::int `; }); - query += 'END THEN NULL ELSE total - CASE '; + query += 'END THEN NULL ELSE references_total - CASE '; - Object.entries(fileReferenceIdsByTotal).forEach(([total, fileReferenceIds]) => { - const inValues = fileReferenceIds.map((fileReferenceId) => { - queryValues.push(fileReferenceId); + Object.entries(uploadedFileIdsByTotal).forEach(([total, uploadedFileIds]) => { + const inValues = uploadedFileIds.map((uploadedFileId) => { + queryValues.push(uploadedFileId); return `$${queryValues.length}`; }); @@ -166,56 +163,58 @@ const delete_ = (criteria) => query += `WHEN id IN (${inValues.join(', ')}) THEN $${queryValues.length}::int `; }); - const inValues = Object.keys(attachmentsByFileReferenceId).map((fileReferenceId) => { - queryValues.push(fileReferenceId); + const inValues = Object.keys(attachmentsByUploadedFileId).map((uploadedFileId) => { + queryValues.push(uploadedFileId); return `$${queryValues.length}`; }); queryValues.push(new Date().toISOString()); - query += `END END, updated_at = $${queryValues.length} WHERE id IN (${inValues.join(', ')}) AND total IS NOT NULL RETURNING id, total`; + query += `END END, updated_at = $${queryValues.length} WHERE id IN (${inValues.join(', ')}) AND references_total IS NOT NULL RETURNING *`; const queryResult = await sails.sendNativeQuery(query, queryValues).usingConnection(db); - fileReferences = queryResult.rows; + + uploadedFiles = queryResult.rows.map((row) => ({ + id: row.id, + type: row.type, + mimeType: row.mime_type, + size: row.size, + referencesTotal: row.references_total, + createdAt: row.created_at, + updatedAt: row.updated_at, + })); } - return { - attachments, - fileReferences, - }; + return { attachments, uploadedFiles }; }); -const deleteOne = async (criteria, { isFile } = {}) => { - let fileReference = null; +const deleteOne = (criteria) => + sails.getDatastore().transaction(async (db) => { + const attachment = await Attachment.destroyOne(criteria).usingConnection(db); - if (isFile) { - return sails.getDatastore().transaction(async (db) => { - const attachment = await Attachment.destroyOne(criteria).usingConnection(db); + let uploadedFile; + if (attachment.type === Attachment.Types.FILE) { + const queryResult = await sails + .sendNativeQuery( + 'UPDATE uploaded_file SET references_total = CASE WHEN references_total > 1 THEN references_total - 1 END, updated_at = $1 WHERE id = $2 RETURNING *', + [new Date().toISOString(), attachment.data.uploadedFileId], + ) + .usingConnection(db); - if (attachment.type === Attachment.Types.FILE) { - const queryResult = await sails - .sendNativeQuery( - 'UPDATE file_reference SET total = CASE WHEN total > 1 THEN total - 1 END, updated_at = $1 WHERE id = $2 RETURNING id, total', - [new Date().toISOString(), attachment.data.fileReferenceId], - ) - .usingConnection(db); + const [row] = queryResult.rows; - [fileReference] = queryResult.rows; - } - - return { - attachment, - fileReference, + uploadedFile = { + id: row.id, + type: row.type, + mimeType: row.mime_type, + size: row.size, + referencesTotal: row.references_total, + createdAt: row.created_at, + updatedAt: row.updated_at, }; - }); - } + } - const attachment = await Attachment.destroyOne(criteria); - - return { - attachment, - fileReference, - }; -}; + return { attachment, uploadedFile }; + }); module.exports = { create, diff --git a/server/api/hooks/query-methods/models/BackgroundImage.js b/server/api/hooks/query-methods/models/BackgroundImage.js index 94c26f1c..8003f54d 100644 --- a/server/api/hooks/query-methods/models/BackgroundImage.js +++ b/server/api/hooks/query-methods/models/BackgroundImage.js @@ -7,7 +7,25 @@ const defaultFind = (criteria) => BackgroundImage.find(criteria).sort('id'); /* Query methods */ -const createOne = (values) => BackgroundImage.create({ ...values }).fetch(); +const createOne = (values) => + sails.getDatastore().transaction(async (db) => { + const backgroundImage = await BackgroundImage.create({ ...values }) + .fetch() + .usingConnection(db); + + const queryResult = await sails + .sendNativeQuery( + 'UPDATE uploaded_file SET references_total = references_total + 1, updated_at = $1 WHERE id = $2 AND references_total IS NOT NULL', + [new Date().toISOString(), values.uploadedFileId], + ) + .usingConnection(db); + + if (queryResult.rowCount === 0) { + throw 'uploadedFileNotFound'; + } + + return backgroundImage; + }); const getByIds = (ids) => defaultFind(ids); @@ -34,9 +52,99 @@ const getOneById = (id, { projectId } = {}) => { }; // eslint-disable-next-line no-underscore-dangle -const delete_ = (criteria) => BackgroundImage.destroy(criteria).fetch(); +const delete_ = (criteria) => + sails.getDatastore().transaction(async (db) => { + const backgroundImages = await BackgroundImage.destroy(criteria).fetch().usingConnection(db); -const deleteOne = (criteria) => BackgroundImage.destroyOne(criteria); + let uploadedFiles = []; + if (backgroundImages.length > 0) { + const backgroundImagesByUploadedFileId = _.groupBy(backgroundImages, 'uploadedFileId'); + + const uploadedFileIdsByTotal = Object.entries(backgroundImagesByUploadedFileId).reduce( + (result, [uploadedFileId, backgroundImagesItem]) => ({ + ...result, + [backgroundImagesItem.length]: [ + ...(result[backgroundImagesItem.length] || []), + uploadedFileId, + ], + }), + {}, + ); + + const queryValues = []; + let query = 'UPDATE uploaded_file SET references_total = CASE WHEN references_total = CASE '; + + Object.entries(uploadedFileIdsByTotal).forEach(([total, uploadedFileIds]) => { + const inValues = uploadedFileIds.map((uploadedFileId) => { + queryValues.push(uploadedFileId); + return `$${queryValues.length}`; + }); + + queryValues.push(total); + query += `WHEN id IN (${inValues.join(', ')}) THEN $${queryValues.length}::int `; + }); + + query += 'END THEN NULL ELSE references_total - CASE '; + + Object.entries(uploadedFileIdsByTotal).forEach(([total, uploadedFileIds]) => { + const inValues = uploadedFileIds.map((uploadedFileId) => { + queryValues.push(uploadedFileId); + return `$${queryValues.length}`; + }); + + queryValues.push(total); + query += `WHEN id IN (${inValues.join(', ')}) THEN $${queryValues.length}::int `; + }); + + const inValues = Object.keys(backgroundImagesByUploadedFileId).map((uploadedFileId) => { + queryValues.push(uploadedFileId); + return `$${queryValues.length}`; + }); + + queryValues.push(new Date().toISOString()); + query += `END END, updated_at = $${queryValues.length} WHERE id IN (${inValues.join(', ')}) AND references_total IS NOT NULL RETURNING *`; + + const queryResult = await sails.sendNativeQuery(query, queryValues).usingConnection(db); + + uploadedFiles = queryResult.rows.map((row) => ({ + id: row.id, + type: row.type, + mimeType: row.mime_type, + size: row.size, + referencesTotal: row.references_total, + createdAt: row.created_at, + updatedAt: row.updated_at, + })); + } + + return { backgroundImages, uploadedFiles }; + }); + +const deleteOne = (criteria) => + sails.getDatastore().transaction(async (db) => { + const backgroundImage = await BackgroundImage.destroyOne(criteria).usingConnection(db); + + const queryResult = await sails + .sendNativeQuery( + 'UPDATE uploaded_file SET references_total = CASE WHEN references_total > 1 THEN references_total - 1 END, updated_at = $1 WHERE id = $2 RETURNING *', + [new Date().toISOString(), backgroundImage.uploadedFileId], + ) + .usingConnection(db); + + const [row] = queryResult.rows; + + uploadedFile = { + id: row.id, + type: row.type, + mimeType: row.mime_type, + size: row.size, + referencesTotal: row.references_total, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + + return { backgroundImage, uploadedFile }; + }); module.exports = { createOne, diff --git a/server/api/hooks/query-methods/models/Card.js b/server/api/hooks/query-methods/models/Card.js index 9bd20e6b..03012ce0 100644 --- a/server/api/hooks/query-methods/models/Card.js +++ b/server/api/hooks/query-methods/models/Card.js @@ -217,18 +217,12 @@ const update = async (criteria, values) => { .usingConnection(db); } - return { - cards, - tasks, - }; + return { cards, tasks }; }); } const cards = await Card.update(criteria).set(values).fetch(); - - return { - cards, - }; + return { cards }; }; const updateOne = async (criteria, values) => { @@ -250,18 +244,12 @@ const updateOne = async (criteria, values) => { .usingConnection(db); } - return { - card, - tasks, - }; + return { card, tasks }; }); } const card = await Card.updateOne(criteria).set({ ...values }); - - return { - card, - }; + return { card }; }; // eslint-disable-next-line no-underscore-dangle diff --git a/server/api/hooks/query-methods/models/CustomFieldValue.js b/server/api/hooks/query-methods/models/CustomFieldValue.js index 12c7b23e..df1ea10c 100644 --- a/server/api/hooks/query-methods/models/CustomFieldValue.js +++ b/server/api/hooks/query-methods/models/CustomFieldValue.js @@ -26,16 +26,16 @@ const createOrUpdateOne = async (values) => { new Date().toISOString(), ]); - const [customFieldValue] = queryResult.rows; + const [row] = queryResult.rows; return { - id: customFieldValue.id, - cardId: customFieldValue.card_id, - customFieldGroupId: customFieldValue.custom_field_group_id, - customFieldId: customFieldValue.custom_field_id, - content: customFieldValue.content, - createdAt: customFieldValue.created_at, - updatedAt: customFieldValue.updated_at, + id: row.id, + cardId: row.card_id, + customFieldGroupId: row.custom_field_group_id, + customFieldId: row.custom_field_id, + content: row.content, + createdAt: row.created_at, + updatedAt: row.updated_at, }; }; diff --git a/server/api/hooks/query-methods/models/List.js b/server/api/hooks/query-methods/models/List.js index 655c36d3..b333c015 100644 --- a/server/api/hooks/query-methods/models/List.js +++ b/server/api/hooks/query-methods/models/List.js @@ -3,6 +3,10 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ +const { makeWhereQueryBuilder } = require('../helpers'); + +const buildWhereQuery = makeWhereQueryBuilder(List); + const defaultFind = (criteria, { sort = 'id' } = {}) => List.find(criteria).sort(sort); /* Query methods */ @@ -48,8 +52,20 @@ const getOneTrashByBoardId = (boardId) => }); const updateOne = async (criteria, values) => { - if (values.type) { + if (!_.isUndefined(values.type)) { return sails.getDatastore().transaction(async (db) => { + const [whereQuery, whereQueryValues] = buildWhereQuery(criteria); + + const queryResult = await sails + .sendNativeQuery(`SELECT type FROM list WHERE ${whereQuery} FOR UPDATE`, whereQueryValues) + .usingConnection(db); + + if (queryResult.rowCount === 0) { + return { list: null }; + } + + const [{ type: prevType }] = queryResult.rows; + const list = await List.updateOne(criteria) .set({ ...values }) .usingConnection(db); @@ -58,7 +74,7 @@ const updateOne = async (criteria, values) => { let tasks = []; if (list) { - const prevTypeState = List.TYPE_STATE_BY_TYPE[prevList.type]; + const prevTypeState = List.TYPE_STATE_BY_TYPE[prevType]; const typeState = List.TYPE_STATE_BY_TYPE[list.type]; let isClosed; @@ -94,19 +110,12 @@ const updateOne = async (criteria, values) => { } } - return { - list, - cards, - tasks, - }; + return { list, cards, tasks }; }); } const list = await List.updateOne(criteria).set({ ...values }); - - return { - list, - }; + return { list }; }; // eslint-disable-next-line no-underscore-dangle diff --git a/server/api/hooks/query-methods/models/StorageUsage.js b/server/api/hooks/query-methods/models/StorageUsage.js new file mode 100644 index 00000000..1a4def1c --- /dev/null +++ b/server/api/hooks/query-methods/models/StorageUsage.js @@ -0,0 +1,12 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +/* Query methods */ + +const getOneMain = () => StorageUsage.findOne(StorageUsage.MAIN_ID); + +module.exports = { + getOneMain, +}; diff --git a/server/api/hooks/query-methods/models/UploadedFile.js b/server/api/hooks/query-methods/models/UploadedFile.js new file mode 100644 index 00000000..2d973c2a --- /dev/null +++ b/server/api/hooks/query-methods/models/UploadedFile.js @@ -0,0 +1,50 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +const COLUMN_NAME_BY_TYPE = { + [UploadedFile.Types.USER_AVATAR]: 'user_avatars', + [UploadedFile.Types.BACKGROUND_IMAGE]: 'background_images', + [UploadedFile.Types.ATTACHMENT]: 'attachments', +}; + +/* Query methods */ + +const createOne = (values) => + sails.getDatastore().transaction(async (db) => { + const uploadedFile = await UploadedFile.create({ ...values }) + .fetch() + .usingConnection(db); + + const columnName = COLUMN_NAME_BY_TYPE[uploadedFile.type]; + + await sails + .sendNativeQuery( + `UPDATE storage_usage SET total = total + $1, ${columnName} = ${columnName} + $1, updated_at = $2 WHERE id = $3`, + [uploadedFile.size, new Date().toISOString(), StorageUsage.MAIN_ID], + ) + .usingConnection(db); + + return uploadedFile; + }); + +const deleteOne = (criteria) => + sails.getDatastore().transaction(async (db) => { + const uploadedFile = await UploadedFile.destroyOne(criteria).usingConnection(db); + const columnName = COLUMN_NAME_BY_TYPE[uploadedFile.type]; + + await sails + .sendNativeQuery( + `UPDATE storage_usage SET total = total - $1, ${columnName} = ${columnName} - $1, updated_at = $2 WHERE id = $3`, + [uploadedFile.size, new Date().toISOString(), StorageUsage.MAIN_ID], + ) + .usingConnection(db); + + return uploadedFile; + }); + +module.exports = { + createOne, + deleteOne, +}; diff --git a/server/api/hooks/query-methods/models/User.js b/server/api/hooks/query-methods/models/User.js index 02e8f988..8f9f0a17 100644 --- a/server/api/hooks/query-methods/models/User.js +++ b/server/api/hooks/query-methods/models/User.js @@ -3,12 +3,28 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ +const { makeWhereQueryBuilder } = require('../helpers'); + +const hasAvatarChanged = (avatar, prevAvatar) => { + if (!avatar && !prevAvatar) { + return false; + } + + if (!avatar || !prevAvatar) { + return true; + } + + return avatar.uploadedFileId !== prevAvatar.uploadedFileId; +}; + +const buildWhereQuery = makeWhereQueryBuilder(User); + const defaultFind = (criteria) => User.find(criteria).sort('id'); /* Query methods */ const createOne = (values) => { - if (sails.config.custom.activeUsersLimit) { + if (!_.isNil(sails.config.custom.activeUsersLimit)) { return sails.getDatastore().transaction(async (db) => { const queryResult = await sails .sendNativeQuery('SELECT NULL FROM user_account WHERE is_deactivated = $1 FOR UPDATE', [ @@ -62,29 +78,119 @@ const getOneActiveByEmailOrUsername = (emailOrUsername) => { }); }; -const updateOne = (criteria, values) => { - if (values.isDeactivated === false && sails.config.custom.activeUsersLimit) { - return sails.getDatastore().transaction(async (db) => { - const queryResult = await sails - .sendNativeQuery('SELECT NULL FROM user_account WHERE is_deactivated = $1 FOR UPDATE', [ - false, - ]) - .usingConnection(db); +const updateOne = async (criteria, values) => { + const enforceActiveLimit = + values.isDeactivated === false && !_.isNil(sails.config.custom.activeUsersLimit); - if (queryResult.rowCount >= sails.config.custom.activeUsersLimit) { - throw 'activeLimitReached'; + if (!_.isUndefined(values.avatar) || enforceActiveLimit) { + return sails.getDatastore().transaction(async (db) => { + if (enforceActiveLimit) { + const queryResult = await sails + .sendNativeQuery('SELECT NULL FROM user_account WHERE is_deactivated = $1 FOR UPDATE', [ + false, + ]) + .usingConnection(db); + + if (queryResult.rowCount >= sails.config.custom.activeUsersLimit) { + throw 'activeLimitReached'; + } } - return User.updateOne(criteria) + let prevAvatar; + if (!_.isUndefined(values.avatar)) { + const [whereQuery, whereQueryValues] = buildWhereQuery(criteria); + + const queryResult = await sails + .sendNativeQuery( + `SELECT avatar FROM user_account WHERE ${whereQuery} FOR UPDATE`, + whereQueryValues, + ) + .usingConnection(db); + + if (queryResult.rowCount === 0) { + return { user: null }; + } + + [{ avatar: prevAvatar }] = queryResult.rows; + } + + const user = await User.updateOne(criteria) .set({ ...values }) .usingConnection(db); + + let uploadedFile; + if (hasAvatarChanged(user.avatar, prevAvatar)) { + if (prevAvatar) { + const queryResult = await sails + .sendNativeQuery( + 'UPDATE uploaded_file SET references_total = CASE WHEN references_total > 1 THEN references_total - 1 END, updated_at = $1 WHERE id = $2 RETURNING *', + [new Date().toISOString(), prevAvatar.uploadedFileId], + ) + .usingConnection(db); + + const [row] = queryResult.rows; + + uploadedFile = { + id: row.id, + type: row.type, + mimeType: row.mime_type, + size: row.size, + referencesTotal: row.references_total, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + if (user.avatar) { + const queryResult = await sails + .sendNativeQuery( + 'UPDATE uploaded_file SET references_total = references_total + 1, updated_at = $1 WHERE id = $2 AND references_total IS NOT NULL', + [new Date().toISOString(), user.avatar.uploadedFileId], + ) + .usingConnection(db); + + if (queryResult.rowCount === 0) { + throw 'uploadedFileNotFound'; + } + } + } + + return { user, uploadedFile }; }); } - return User.updateOne(criteria).set({ ...values }); + const user = await User.updateOne(criteria).set({ ...values }); + return { user }; }; -const deleteOne = (criteria) => User.destroyOne(criteria); +const deleteOne = (criteria) => + sails.getDatastore().transaction(async (db) => { + const user = await User.destroyOne(criteria).usingConnection(db); + + let uploadedFile; + if (user.avatar) { + const queryResult = await sails + .sendNativeQuery( + 'UPDATE uploaded_file SET references_total = CASE WHEN references_total > 1 THEN references_total - 1 END, updated_at = $1 WHERE id = $2 RETURNING *', + [new Date().toISOString(), user.avatar.uploadedFileId], + ) + .usingConnection(db); + + const [row] = queryResult.rows; + + uploadedFile = { + id: row.id, + type: row.type, + mimeType: row.mime_type, + size: row.size, + referencesTotal: row.references_total, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + return { user, uploadedFile }; + }); module.exports = { createOne, diff --git a/server/api/models/BackgroundImage.js b/server/api/models/BackgroundImage.js index 6153653b..a5b49397 100644 --- a/server/api/models/BackgroundImage.js +++ b/server/api/models/BackgroundImage.js @@ -16,18 +16,13 @@ module.exports = { // ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗ // ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝ - dirname: { - type: 'string', - required: true, - }, extension: { type: 'string', required: true, }, - sizeInBytes: { - type: 'string', // TODO: should be number somehow + size: { + type: 'string', required: true, - columnName: 'size_in_bytes', }, // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ @@ -38,6 +33,11 @@ module.exports = { // ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗ // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ + uploadedFileId: { + model: 'UploadedFile', + required: true, + columnName: 'uploaded_file_id', + }, projectId: { model: 'Project', required: true, diff --git a/server/api/models/FileReference.js b/server/api/models/StorageUsage.js similarity index 70% rename from server/api/models/FileReference.js rename to server/api/models/StorageUsage.js index 7abcee8a..4ea8b961 100644 --- a/server/api/models/FileReference.js +++ b/server/api/models/StorageUsage.js @@ -4,22 +4,40 @@ */ /** - * FileReference.js + * StorageUsage.js * * @description :: A model definition represents a database table/collection. * @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models */ +const MAIN_ID = '1'; + module.exports = { + MAIN_ID, + attributes: { // ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗ // ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗ // ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝ total: { - type: 'number', - allowNull: true, - defaultsTo: 0, + type: 'string', + required: true, + }, + userAvatars: { + type: 'string', + required: true, + columnName: 'user_avatars', + }, + backgroundImages: { + type: 'string', + required: true, + columnName: 'background_images', + }, + attachments: { + type: 'string', + required: true, + columnName: 'attachments', }, // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ @@ -31,5 +49,5 @@ module.exports = { // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ }, - tableName: 'file_reference', + tableName: 'storage_usage', }; diff --git a/server/api/models/UploadedFile.js b/server/api/models/UploadedFile.js new file mode 100644 index 00000000..7da826ba --- /dev/null +++ b/server/api/models/UploadedFile.js @@ -0,0 +1,59 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +/** + * UploadedFile.js + * + * @description :: A model definition represents a database table/collection. + * @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models + */ + +const Types = { + USER_AVATAR: 'userAvatar', + BACKGROUND_IMAGE: 'backgroundImage', + ATTACHMENT: 'attachment', +}; + +module.exports = { + Types, + + attributes: { + // ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗ + // ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗ + // ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝ + + type: { + type: 'string', + isIn: Object.values(Types), + required: true, + }, + referencesTotal: { + type: 'number', + allowNull: true, + defaultsTo: 0, + columnName: 'references_total', + }, + mimeType: { + type: 'string', + isNotEmptyString: true, + allowNull: true, + columnName: 'mime_type', + }, + size: { + type: 'string', + required: true, + }, + + // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ + // ║╣ ║║║╠╩╗║╣ ║║╚═╗ + // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝ + + // ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ + // ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗ + // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ + }, + + tableName: 'uploaded_file', +}; diff --git a/server/config/custom.js b/server/config/custom.js index 3ca0ee8b..92f65f74 100644 --- a/server/config/custom.js +++ b/server/config/custom.js @@ -9,15 +9,22 @@ */ const { URL } = require('url'); +const bytes = require('bytes'); const sails = require('sails'); const version = require('../version'); const envToNumber = (value) => { + if (!value) { + return value; + } + const number = parseInt(value, 10); return Number.isNaN(number) ? null : number; }; +const envToBytes = (value) => value && bytes(value); + const envToArray = (value) => (value ? value.split(',') : []); const parsedBasedUrl = new URL(process.env.BASE_URL); @@ -35,6 +42,7 @@ module.exports.custom = { baseUrlPath: parsedBasedUrl.pathname, baseUrlSecure: parsedBasedUrl.protocol === 'https:', + maxUploadFileSize: envToBytes(process.env.MAX_UPLOAD_FILE_SIZE), tokenExpiresIn: (parseInt(process.env.TOKEN_EXPIRES_IN, 10) || 365) * 24 * 60 * 60, // Location to receive uploaded files in. Default (non-string value) is a Sails-specific location. @@ -51,7 +59,9 @@ module.exports.custom = { process.env.DEFAULT_ADMIN_EMAIL && process.env.DEFAULT_ADMIN_EMAIL.toLowerCase(), internalAccessToken: process.env.INTERNAL_ACCESS_TOKEN, + storageLimit: envToBytes(process.env.STORAGE_LIMIT), activeUsersLimit: envToNumber(process.env.ACTIVE_USERS_LIMIT), + showDetailedAuthErrors: process.env.SHOW_DETAILED_AUTH_ERRORS === 'true', s3Endpoint: process.env.S3_ENDPOINT, diff --git a/server/constants.js b/server/constants.js index 4f25a1aa..7505dc4a 100644 --- a/server/constants.js +++ b/server/constants.js @@ -4,10 +4,10 @@ const AccessTokenSteps = { const POSITION_GAP = 65536; -const MAX_SIZE_IN_BYTES_TO_GET_ENCODING = 8 * 1024 * 1024; +const MAX_SIZE_TO_GET_ENCODING = 8 * 1024 * 1024; module.exports = { AccessTokenSteps, POSITION_GAP, - MAX_SIZE_IN_BYTES_TO_GET_ENCODING, + MAX_SIZE_TO_GET_ENCODING, }; diff --git a/server/db/migrations/20250820144730_track_storage_usage.js b/server/db/migrations/20250820144730_track_storage_usage.js new file mode 100644 index 00000000..65623409 --- /dev/null +++ b/server/db/migrations/20250820144730_track_storage_usage.js @@ -0,0 +1,161 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +const mime = require('mime'); + +exports.up = async (knex) => { + await knex.schema.createTable('storage_usage', (table) => { + /* Columns */ + + table.bigInteger('id').primary().defaultTo(knex.raw('next_id()')); + + table.bigInteger('total').notNullable(); + table.bigInteger('user_avatars').notNullable(); + table.bigInteger('background_images').notNullable(); + table.bigInteger('attachments').notNullable(); + + table.timestamp('created_at', true); + table.timestamp('updated_at', true); + }); + + await knex.schema.alterTable('file_reference', (table) => { + table.dropPrimary(); + table.dropIndex('total'); + }); + + await knex.schema.renameTable('file_reference', 'uploaded_file'); + + await knex.schema.alterTable('uploaded_file', (table) => { + /* Columns */ + + table.text('type').notNullable().defaultTo('attachment'); + table.text('mime_type'); + table.bigInteger('size').notNullable().defaultTo(0); + + /* Modifications */ + + table.text('id').primary().defaultTo(knex.raw('next_id()')).alter(); + table.renameColumn('total', 'references_total'); + + /* Indexes */ + + table.index('type'); + table.index('references_total'); + }); + + await knex.schema.alterTable('uploaded_file', (table) => { + table.text('type').notNullable().alter(); + table.bigInteger('size').notNullable().alter(); + }); + + await knex.raw(` + UPDATE user_account + SET avatar = avatar - 'dirname' - 'sizeInBytes' || jsonb_build_object('uploadedFileId', avatar->'dirname', 'size', avatar->'sizeInBytes') + WHERE avatar IS NOT NULL; + `); + + await knex.schema.alterTable('background_image', (table) => { + table.renameColumn('dirname', 'uploaded_file_id'); + table.renameColumn('size_in_bytes', 'size'); + }); + + await knex.raw(` + UPDATE attachment + SET data = data - 'fileReferenceId' - 'sizeInBytes' || jsonb_build_object('uploadedFileId', data->'fileReferenceId', 'size', data->'sizeInBytes') + WHERE type = 'file'; + `); + + await knex.raw(` + UPDATE uploaded_file + SET + type = 'attachment', + mime_type = attachment.data->>'mimeType', + size = (attachment.data->>'size')::bigint + FROM attachment + WHERE (attachment.data->>'uploadedFileId')::text = uploaded_file.id AND attachment.type = 'file'; + `); + + const users = await knex('user_account').whereNotNull('avatar'); + const createdAt = new Date().toISOString(); + + await knex.batchInsert( + 'uploaded_file', + users.map(({ avatar }) => ({ + createdAt, + id: avatar.uploadedFileId, + type: 'userAvatar', + referencesTotal: 1, + mimeType: mime.getType(avatar.extension), + size: avatar.size, + })), + ); + + const backgroundImages = await knex('background_image'); + + await knex.batchInsert( + 'uploaded_file', + backgroundImages.map((backgroundImage) => ({ + id: backgroundImage.uploaded_file_id, + type: 'backgroundImage', + referencesTotal: 1, + mimeType: mime.getType(backgroundImage.extension), + size: backgroundImage.size, + createdAt: backgroundImage.created_at, + })), + ); + + return knex.raw(` + INSERT INTO storage_usage (id, total, user_avatars, background_images, attachments, created_at) + SELECT + 1 AS id, + COALESCE(SUM(size), 0) AS total, + COALESCE(SUM(CASE WHEN type = 'userAvatar' THEN size ELSE 0 END), 0) AS user_avatars, + COALESCE(SUM(CASE WHEN type = 'backgroundImage' THEN size ELSE 0 END), 0) AS background_images, + COALESCE(SUM(CASE WHEN type = 'attachment' THEN size ELSE 0 END), 0) AS attachments, + timezone('UTC', now()) AS created_at + FROM uploaded_file; + `); +}; + +exports.down = async (knex) => { + await knex.schema.dropTable('storage_usage'); + + await knex('uploaded_file').delete().whereNot('type', 'attachment'); + + await knex.schema.alterTable('uploaded_file', (table) => { + table.dropPrimary(); + table.dropIndex('references_total'); + }); + + await knex.schema.renameTable('uploaded_file', 'file_reference'); + + await knex.schema.alterTable('file_reference', (table) => { + table.dropColumn('type'); + table.dropColumn('mime_type'); + table.dropColumn('size'); + + table.bigInteger('id').primary().defaultTo(knex.raw('next_id()')).alter(); + table.renameColumn('references_total', 'total'); + + table.index('total'); + }); + + await knex.raw(` + UPDATE user_account + SET avatar = avatar - 'uploadedFileId' - 'size' || jsonb_build_object('dirname', avatar->'uploadedFileId', 'sizeInBytes', avatar->'size') + WHERE avatar IS NOT NULL; + `); + + await knex.schema.alterTable('background_image', (table) => { + table.renameColumn('uploaded_file_id', 'dirname'); + table.renameColumn('size', 'size_in_bytes'); + }); + + return knex.raw(` + UPDATE attachment + SET data = data - 'uploadedFileId' - 'size' || jsonb_build_object('fileReferenceId', data->'uploadedFileId', 'sizeInBytes', data->'size') + WHERE type = 'file'; + `); +}; diff --git a/server/db/upgrade.js b/server/db/upgrade.js index 51eaf8d4..1d70fe69 100644 --- a/server/db/upgrade.js +++ b/server/db/upgrade.js @@ -18,7 +18,7 @@ const rc = require('sails/accessible/rc'); const _ = require('lodash'); const knexfile = require('./knexfile'); -const { MAX_SIZE_IN_BYTES_TO_GET_ENCODING, POSITION_GAP } = require('../constants'); +const { MAX_SIZE_TO_GET_ENCODING, POSITION_GAP } = require('../constants'); const PrevActionTypes = { COMMENT_CARD: 'commentCard', @@ -611,7 +611,7 @@ const upgradeUserAvatars = async () => { const dirPathSegment = `${sails.config.custom.userAvatarsPathSegment}/${dirname}`; if (user) { - const sizeInBytes = await fileManager.getSizeInBytes( + const size = await fileManager.getSize( `${dirPathSegment}/original.${user.avatar.extension}`, ); @@ -619,7 +619,7 @@ const upgradeUserAvatars = async () => { .update({ avatar: knex.raw("?? || jsonb_build_object('sizeInBytes', ?::bigint)", [ 'avatar', - sizeInBytes, + size, ]), }) .where('id', user.id); @@ -690,13 +690,13 @@ const upgradeBackgroundImages = async () => { const dirPathSegment = `${sails.config.custom.backgroundImagesPathSegment}/${dirname}`; if (backgroundImage) { - const sizeInBytes = await fileManager.getSizeInBytes( + const size = await fileManager.getSize( `${dirPathSegment}/original.${backgroundImage.extension}`, ); await knex('background_image') .update({ - size_in_bytes: sizeInBytes, + size_in_bytes: size, }) .where('id', backgroundImage.id); } else { @@ -777,12 +777,10 @@ const upgradeFileAttachments = async () => { 'id', ); - const sizeInBytes = await fileManager.getSizeInBytes( - `${dirPathSegment}/${attachment.data.filename}`, - ); + const size = await fileManager.getSize(`${dirPathSegment}/${attachment.data.filename}`); let encoding = null; - if (sizeInBytes && sizeInBytes <= MAX_SIZE_IN_BYTES_TO_GET_ENCODING) { + if (size && size <= MAX_SIZE_TO_GET_ENCODING) { const readStream = await fileManager.read( `${dirPathSegment}/${attachment.data.filename}`, ); @@ -795,7 +793,7 @@ const upgradeFileAttachments = async () => { .update({ data: trx.raw( "?? || jsonb_build_object('fileReferenceId', ?::text, 'sizeInBytes', ?::bigint, 'encoding', ?::text)", - ['data', id, sizeInBytes, encoding], + ['data', id, size, encoding], ), }) .where('id', attachment.id); diff --git a/server/package-lock.json b/server/package-lock.json index 72e6eca5..daa4b35f 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,6 +9,7 @@ "dependencies": { "@aws-sdk/client-s3": "3.726.1", "bcrypt": "^5.1.1", + "bytes": "^3.1.2", "cross-env": "^7.0.3", "dotenv": "^16.6.1", "dotenv-cli": "^7.4.4", diff --git a/server/package.json b/server/package.json index a23f0747..ca4a7d57 100644 --- a/server/package.json +++ b/server/package.json @@ -44,6 +44,7 @@ "dependencies": { "@aws-sdk/client-s3": "3.726.1", "bcrypt": "^5.1.1", + "bytes": "^3.1.2", "cross-env": "^7.0.3", "dotenv": "^16.6.1", "dotenv-cli": "^7.4.4", diff --git a/server/patches/skipper-disk+0.5.12.patch b/server/patches/skipper-disk+0.5.12.patch new file mode 100644 index 00000000..96d50fae --- /dev/null +++ b/server/patches/skipper-disk+0.5.12.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/skipper-disk/standalone/build-progress-stream.js b/node_modules/skipper-disk/standalone/build-progress-stream.js +index ed048dc..3cca22e 100644 +--- a/node_modules/skipper-disk/standalone/build-progress-stream.js ++++ b/node_modules/skipper-disk/standalone/build-progress-stream.js +@@ -110,7 +110,7 @@ module.exports = function buildProgressStream (options, __newFile, receiver__, o + receiver__.emit('progress', currentFileProgress); + + // and then enforce its `maxBytes`. +- if (options.maxBytes && totalBytesWritten >= options.maxBytes) { ++ if (!_.isNull(options.maxBytes) && totalBytesWritten >= options.maxBytes) { + + var err = new Error(); + err.code = 'E_EXCEEDS_UPLOAD_LIMIT';