From bf806f0c284b5b407b0e180da335fb16f7829853 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 17 Nov 2025 14:03:08 -0800 Subject: [PATCH] feat: allow configuring attachment upload limit (#12835) ## Summary - add a configurable MAXIMUM_FILE_UPLOAD_SIZE installation setting and surface it through super admin and global config payloads - apply the configurable limit to attachment validations and shared upload helpers on dashboard and widget - introduce a reusable helper with unit tests for parsing the limit and extend attachment specs for configurability ------ [Codex Task](https://chatgpt.com/codex/tasks/task_e_6912644786b08326bc8dee9401af6d0a) --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin --- .../api/v1/widget/configs_controller.rb | 8 +++- app/controllers/dashboard_controller.rb | 45 +++++++++++-------- .../super_admin/app_configs_controller.rb | 5 ++- app/controllers/widgets_controller.rb | 9 +++- .../composables/spec/useFileUpload.spec.js | 14 ++++-- .../dashboard/composables/useFileUpload.js | 30 ++++++++++--- .../dashboard/mixins/fileUploadMixin.js | 31 ++++++++++--- .../mixins/specs/fileUploadMixin.spec.js | 3 ++ app/javascript/shared/constants/messages.js | 3 -- app/javascript/shared/helpers/FileHelper.js | 12 +++++ .../shared/helpers/specs/FileHelper.spec.js | 26 +++++++++++ app/javascript/shared/store/globalConfig.js | 3 ++ .../widget/components/ChatAttachment.vue | 16 ++++--- app/models/attachment.rb | 5 ++- config/installation_config.yml | 5 +++ spec/models/attachment_spec.rb | 35 +++++++++++++++ 16 files changed, 204 insertions(+), 46 deletions(-) diff --git a/app/controllers/api/v1/widget/configs_controller.rb b/app/controllers/api/v1/widget/configs_controller.rb index ecbddd905..458d0486c 100644 --- a/app/controllers/api/v1/widget/configs_controller.rb +++ b/app/controllers/api/v1/widget/configs_controller.rb @@ -9,7 +9,13 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController private def set_global_config - @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME') + @global_config = GlobalConfig.get( + 'LOGO_THUMBNAIL', + 'BRAND_NAME', + 'WIDGET_BRAND_URL', + 'MAXIMUM_FILE_UPLOAD_SIZE', + 'INSTALLATION_NAME' + ) end def set_contact diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index d81b4c9da..57e8af0c2 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,6 +1,31 @@ class DashboardController < ActionController::Base include SwitchLocale + GLOBAL_CONFIG_KEYS = %w[ + LOGO + LOGO_DARK + LOGO_THUMBNAIL + INSTALLATION_NAME + WIDGET_BRAND_URL + TERMS_URL + BRAND_URL + BRAND_NAME + PRIVACY_URL + DISPLAY_MANIFEST + CREATE_NEW_ACCOUNT_FROM_DASHBOARD + CHATWOOT_INBOX_TOKEN + API_CHANNEL_NAME + API_CHANNEL_THUMBNAIL + ANALYTICS_TOKEN + DIRECT_UPLOADS_ENABLED + MAXIMUM_FILE_UPLOAD_SIZE + HCAPTCHA_SITE_KEY + LOGOUT_REDIRECT_LINK + DISABLE_USER_PROFILE_UPDATE + DEPLOYMENT_ENV + INSTALLATION_PRICING_PLAN + ].freeze + before_action :set_application_pack before_action :set_global_config before_action :set_dashboard_scripts @@ -19,25 +44,7 @@ class DashboardController < ActionController::Base end def set_global_config - @global_config = GlobalConfig.get( - 'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL', - 'INSTALLATION_NAME', - 'WIDGET_BRAND_URL', 'TERMS_URL', - 'BRAND_URL', 'BRAND_NAME', - 'PRIVACY_URL', - 'DISPLAY_MANIFEST', - 'CREATE_NEW_ACCOUNT_FROM_DASHBOARD', - 'CHATWOOT_INBOX_TOKEN', - 'API_CHANNEL_NAME', - 'API_CHANNEL_THUMBNAIL', - 'ANALYTICS_TOKEN', - 'DIRECT_UPLOADS_ENABLED', - 'HCAPTCHA_SITE_KEY', - 'LOGOUT_REDIRECT_LINK', - 'DISABLE_USER_PROFILE_UPDATE', - 'DEPLOYMENT_ENV', - 'INSTALLATION_PRICING_PLAN' - ).merge(app_config) + @global_config = GlobalConfig.get(*GLOBAL_CONFIG_KEYS).merge(app_config) end def set_dashboard_scripts diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 5cf158b98..483c300bd 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -45,7 +45,10 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI] } - @allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]) + @allowed_configs = mapping.fetch( + @config, + %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS MAXIMUM_FILE_UPLOAD_SIZE] + ) end end diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index 9a6a376f7..7f45ce636 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -14,7 +14,14 @@ class WidgetsController < ActionController::Base private def set_global_config - @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED', 'INSTALLATION_NAME') + @global_config = GlobalConfig.get( + 'LOGO_THUMBNAIL', + 'BRAND_NAME', + 'WIDGET_BRAND_URL', + 'DIRECT_UPLOADS_ENABLED', + 'MAXIMUM_FILE_UPLOAD_SIZE', + 'INSTALLATION_NAME' + ) end def set_web_widget diff --git a/app/javascript/dashboard/composables/spec/useFileUpload.spec.js b/app/javascript/dashboard/composables/spec/useFileUpload.spec.js index 968a1b057..993eae2cc 100644 --- a/app/javascript/dashboard/composables/spec/useFileUpload.spec.js +++ b/app/javascript/dashboard/composables/spec/useFileUpload.spec.js @@ -12,7 +12,11 @@ vi.mock('dashboard/composables', () => ({ })); vi.mock('vue-i18n'); vi.mock('activestorage'); -vi.mock('shared/helpers/FileHelper'); +vi.mock('shared/helpers/FileHelper', () => ({ + checkFileSizeLimit: vi.fn(), + resolveMaximumFileUploadSize: vi.fn(value => Number(value) || 40), + DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE: 40, +})); vi.mock('@chatwoot/utils'); describe('useFileUpload', () => { @@ -36,7 +40,9 @@ describe('useFileUpload', () => { getCurrentAccountId: { value: '123' }, getCurrentUser: { value: { access_token: 'test-token' } }, getSelectedChat: { value: { id: '456' } }, - 'globalConfig/get': { value: { directUploadsEnabled: true } }, + 'globalConfig/get': { + value: { directUploadsEnabled: true, maximumFileUploadSize: 40 }, + }, }; return getterMap[getter]; }); @@ -86,7 +92,9 @@ describe('useFileUpload', () => { getCurrentAccountId: { value: '123' }, getCurrentUser: { value: { access_token: 'test-token' } }, getSelectedChat: { value: { id: '456' } }, - 'globalConfig/get': { value: { directUploadsEnabled: false } }, + 'globalConfig/get': { + value: { directUploadsEnabled: false, maximumFileUploadSize: 40 }, + }, }; return getterMap[getter]; }); diff --git a/app/javascript/dashboard/composables/useFileUpload.js b/app/javascript/dashboard/composables/useFileUpload.js index c0105d321..a0c7e3297 100644 --- a/app/javascript/dashboard/composables/useFileUpload.js +++ b/app/javascript/dashboard/composables/useFileUpload.js @@ -4,7 +4,11 @@ import { useI18n } from 'vue-i18n'; import { DirectUpload } from 'activestorage'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { getMaxUploadSizeByChannel } from '@chatwoot/utils'; -import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages'; +import { + DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE, + resolveMaximumFileUploadSize, +} from 'shared/helpers/FileHelper'; +import { INBOX_TYPES } from 'dashboard/helper/inbox'; /** * Composable for handling file uploads in conversations @@ -21,18 +25,34 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => { const currentChat = useMapGetter('getSelectedChat'); const globalConfig = useMapGetter('globalConfig/get'); + const installationLimit = resolveMaximumFileUploadSize( + globalConfig.value?.maximumFileUploadSize + ); + // helper: compute max upload size for a given file's mime const maxSizeFor = mime => { - // Use default file size limit for private notes + // Use default/installation limit for private notes if (isPrivateNote) { - return MAXIMUM_FILE_UPLOAD_SIZE; + return installationLimit; } - return getMaxUploadSizeByChannel({ - channelType: inbox?.channel_type, + const channelType = inbox?.channel_type; + + if (!channelType || channelType === INBOX_TYPES.WEB) { + return installationLimit; + } + + const channelLimit = getMaxUploadSizeByChannel({ + channelType, medium: inbox?.medium, // e.g. 'sms' | 'whatsapp' | etc. mime, // e.g. 'image/png' }); + + if (channelLimit === DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE) { + return installationLimit; + } + + return Math.min(channelLimit, installationLimit); }; const alertOverLimit = maxSizeMB => diff --git a/app/javascript/dashboard/mixins/fileUploadMixin.js b/app/javascript/dashboard/mixins/fileUploadMixin.js index 5313be812..965846401 100644 --- a/app/javascript/dashboard/mixins/fileUploadMixin.js +++ b/app/javascript/dashboard/mixins/fileUploadMixin.js @@ -3,27 +3,48 @@ import { useAlert } from 'dashboard/composables'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { getMaxUploadSizeByChannel } from '@chatwoot/utils'; import { DirectUpload } from 'activestorage'; -import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages'; +import { + DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE, + resolveMaximumFileUploadSize, +} from 'shared/helpers/FileHelper'; +import { INBOX_TYPES } from 'dashboard/helper/inbox'; export default { computed: { ...mapGetters({ accountId: 'getCurrentAccountId', }), + installationLimit() { + return resolveMaximumFileUploadSize( + this.globalConfig.maximumFileUploadSize + ); + }, }, methods: { maxSizeFor(mime) { - // Use default file size limit for private notes + // Use default/installation limit for private notes if (this.isOnPrivateNote) { - return MAXIMUM_FILE_UPLOAD_SIZE; + return this.installationLimit; } - return getMaxUploadSizeByChannel({ - channelType: this.inbox?.channel_type, + const channelType = this.inbox?.channel_type; + + if (!channelType || channelType === INBOX_TYPES.WEB) { + return this.installationLimit; + } + + const channelLimit = getMaxUploadSizeByChannel({ + channelType, medium: this.inbox?.medium, // e.g. 'sms' | 'whatsapp' mime, // e.g. 'image/png' }); + + if (channelLimit === DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE) { + return this.installationLimit; + } + + return Math.min(channelLimit, this.installationLimit); }, alertOverLimit(maxSizeMB) { useAlert( diff --git a/app/javascript/dashboard/mixins/specs/fileUploadMixin.spec.js b/app/javascript/dashboard/mixins/specs/fileUploadMixin.spec.js index a873b1859..273e89e97 100644 --- a/app/javascript/dashboard/mixins/specs/fileUploadMixin.spec.js +++ b/app/javascript/dashboard/mixins/specs/fileUploadMixin.spec.js @@ -6,6 +6,8 @@ import { reactive } from 'vue'; vi.mock('shared/helpers/FileHelper', () => ({ checkFileSizeLimit: vi.fn(), + resolveMaximumFileUploadSize: vi.fn(value => Number(value) || 40), + DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE: 40, })); vi.mock('activestorage', () => ({ @@ -27,6 +29,7 @@ describe('FileUploadMixin', () => { beforeEach(() => { mockGlobalConfig = reactive({ directUploadsEnabled: true, + maximumFileUploadSize: 40, }); mockCurrentChat = reactive({ diff --git a/app/javascript/shared/constants/messages.js b/app/javascript/shared/constants/messages.js index 47c1e8651..8e9d06beb 100644 --- a/app/javascript/shared/constants/messages.js +++ b/app/javascript/shared/constants/messages.js @@ -34,9 +34,6 @@ export const CONVERSATION_PRIORITY_ORDER = { low: 1, }; -// Size in mega bytes -export const MAXIMUM_FILE_UPLOAD_SIZE = 40; - export const ALLOWED_FILE_TYPES = 'image/*,' + 'audio/*,' + diff --git a/app/javascript/shared/helpers/FileHelper.js b/app/javascript/shared/helpers/FileHelper.js index d0d9daaaa..3866fb6b4 100644 --- a/app/javascript/shared/helpers/FileHelper.js +++ b/app/javascript/shared/helpers/FileHelper.js @@ -1,3 +1,5 @@ +export const DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE = 40; + export const formatBytes = (bytes, decimals = 2) => { if (bytes === 0) return '0 Bytes'; @@ -19,3 +21,13 @@ export const checkFileSizeLimit = (file, maximumUploadLimit) => { const fileSizeInMB = fileSizeInMegaBytes(fileSize); return fileSizeInMB <= maximumUploadLimit; }; + +export const resolveMaximumFileUploadSize = value => { + const parsedValue = Number(value); + + if (!Number.isFinite(parsedValue) || parsedValue <= 0) { + return DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE; + } + + return parsedValue; +}; diff --git a/app/javascript/shared/helpers/specs/FileHelper.spec.js b/app/javascript/shared/helpers/specs/FileHelper.spec.js index 750eab52c..e8abe506a 100644 --- a/app/javascript/shared/helpers/specs/FileHelper.spec.js +++ b/app/javascript/shared/helpers/specs/FileHelper.spec.js @@ -1,7 +1,9 @@ import { + DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE, formatBytes, fileSizeInMegaBytes, checkFileSizeLimit, + resolveMaximumFileUploadSize, } from '../FileHelper'; describe('#File Helpers', () => { @@ -19,6 +21,7 @@ describe('#File Helpers', () => { expect(formatBytes(10000000)).toBe('9.54 MB'); }); }); + describe('fileSizeInMegaBytes', () => { it('should return zero if 0 is passed', () => { expect(fileSizeInMegaBytes(0)).toBe(0); @@ -27,6 +30,7 @@ describe('#File Helpers', () => { expect(fileSizeInMegaBytes(20000000)).toBeCloseTo(19.07, 2); }); }); + describe('checkFileSizeLimit', () => { it('should return false if file with size 62208194 and file size limit 40 are passed', () => { expect(checkFileSizeLimit({ file: { size: 62208194 } }, 40)).toBe(false); @@ -35,4 +39,26 @@ describe('#File Helpers', () => { expect(checkFileSizeLimit({ file: { size: 199154 } }, 40)).toBe(true); }); }); + + describe('resolveMaximumFileUploadSize', () => { + it('should return default when value is undefined', () => { + expect(resolveMaximumFileUploadSize(undefined)).toBe( + DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE + ); + }); + + it('should return default when value is not a positive number', () => { + expect(resolveMaximumFileUploadSize('not-a-number')).toBe( + DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE + ); + expect(resolveMaximumFileUploadSize(-5)).toBe( + DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE + ); + }); + + it('should parse numeric strings and numbers', () => { + expect(resolveMaximumFileUploadSize('50')).toBe(50); + expect(resolveMaximumFileUploadSize(75)).toBe(75); + }); + }); }); diff --git a/app/javascript/shared/store/globalConfig.js b/app/javascript/shared/store/globalConfig.js index 8abeba123..6b4c9a5ee 100644 --- a/app/javascript/shared/store/globalConfig.js +++ b/app/javascript/shared/store/globalConfig.js @@ -1,4 +1,5 @@ import { parseBoolean } from '@chatwoot/utils'; +import { resolveMaximumFileUploadSize } from 'shared/helpers/FileHelper'; const { API_CHANNEL_NAME: apiChannelName, @@ -11,6 +12,7 @@ const { DIRECT_UPLOADS_ENABLED: directUploadsEnabled, DISPLAY_MANIFEST: displayManifest, GIT_SHA: gitSha, + MAXIMUM_FILE_UPLOAD_SIZE: maximumFileUploadSize, HCAPTCHA_SITE_KEY: hCaptchaSiteKey, INSTALLATION_NAME: installationName, LOGO_THUMBNAIL: logoThumbnail, @@ -37,6 +39,7 @@ const state = { disableUserProfileUpdate: parseBoolean(disableUserProfileUpdate), displayManifest, gitSha, + maximumFileUploadSize: resolveMaximumFileUploadSize(maximumFileUploadSize), hCaptchaSiteKey, installationName, logo, diff --git a/app/javascript/widget/components/ChatAttachment.vue b/app/javascript/widget/components/ChatAttachment.vue index 64a20bcc7..169749527 100755 --- a/app/javascript/widget/components/ChatAttachment.vue +++ b/app/javascript/widget/components/ChatAttachment.vue @@ -1,11 +1,11 @@