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 <iamsivin@gmail.com>
This commit is contained in:
Sojan Jose
2025-11-17 14:03:08 -08:00
committed by GitHub
parent 93374f4327
commit bf806f0c28
16 changed files with 204 additions and 46 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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];
});

View File

@@ -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 =>

View File

@@ -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(

View File

@@ -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({

View File

@@ -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/*,' +

View File

@@ -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;
};

View File

@@ -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);
});
});
});

View File

@@ -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,

View File

@@ -1,11 +1,11 @@
<script>
import FileUpload from 'vue-upload-component';
import Spinner from 'shared/components/Spinner.vue';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import {
MAXIMUM_FILE_UPLOAD_SIZE,
ALLOWED_FILE_TYPES,
} from 'shared/constants/messages';
checkFileSizeLimit,
resolveMaximumFileUploadSize,
} from 'shared/helpers/FileHelper';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { DirectUpload } from 'activestorage';
@@ -29,7 +29,9 @@ export default {
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
}),
fileUploadSizeLimit() {
return MAXIMUM_FILE_UPLOAD_SIZE;
return resolveMaximumFileUploadSize(
this.globalConfig.maximumFileUploadSize
);
},
allowedFileTypes() {
return ALLOWED_FILE_TYPES;
@@ -73,7 +75,7 @@ export default {
}
this.isUploading = true;
try {
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
if (checkFileSizeLimit(file, this.fileUploadSizeLimit)) {
const { websiteToken } = window.chatwootWebChannel;
const upload = new DirectUpload(
file.file,
@@ -115,7 +117,7 @@ export default {
}
this.isUploading = true;
try {
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
if (checkFileSizeLimit(file, this.fileUploadSizeLimit)) {
await this.onAttach({
file: file.file,
...this.getLocalFileAttributes(file),

View File

@@ -166,7 +166,10 @@ class Attachment < ApplicationRecord
end
def validate_file_size(byte_size)
errors.add(:file, 'size is too big') if byte_size > 40.megabytes
limit_mb = GlobalConfigService.load('MAXIMUM_FILE_UPLOAD_SIZE', 40).to_i
limit_mb = 40 if limit_mb <= 0
errors.add(:file, 'size is too big') if byte_size > limit_mb.megabytes
end
def media_file?(file_content_type)

View File

@@ -84,6 +84,11 @@
value: false
description: 'Enable direct uploads to cloud storage'
locked: false
- name: MAXIMUM_FILE_UPLOAD_SIZE
value: 40
display_title: 'Attachment size limit (MB)'
description: 'Maximum attachment size in MB allowed for uploads'
locked: false
# ------- End of Account Related Config ------- #
# ------- Email Related Config ------- #

View File

@@ -154,4 +154,39 @@ RSpec.describe Attachment do
end
end
end
describe 'file size validation' do
let(:attachment) { message.attachments.new(account_id: message.account_id, file_type: :image) }
before do
allow(GlobalConfigService).to receive(:load).and_call_original
end
it 'respects configured limit' do
allow(GlobalConfigService).to receive(:load)
.with('MAXIMUM_FILE_UPLOAD_SIZE', 40)
.and_return('5')
attachment.errors.clear
attachment.send(:validate_file_size, 4.megabytes)
expect(attachment.errors[:file]).to be_empty
attachment.errors.clear
attachment.send(:validate_file_size, 6.megabytes)
expect(attachment.errors[:file]).to include('size is too big')
end
it 'falls back to default when configured limit is invalid' do
allow(GlobalConfigService).to receive(:load)
.with('MAXIMUM_FILE_UPLOAD_SIZE', 40)
.and_return('-10')
attachment.errors.clear
attachment.send(:validate_file_size, 41.megabytes)
expect(attachment.errors[:file]).to include('size is too big')
end
end
end