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:
@@ -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];
|
||||
});
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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/*,' +
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user