From 3c67c415442ea115dc0b1bc142875ff43eb65695 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 22 May 2026 11:09:27 +0530 Subject: [PATCH] chore: support PFX filetype in attachment uploads (#14456) # Pull Request Template ## Description This PR expands the default upload rules to support PFX certificate files (`application/x-pkcs12`, `application/pkcs12`, `.pfx`) across private notes, Website, Email, and Telegram channels. Also adds `.xls` / `.xlsx` extension fallbacks for cases where browsers upload Excel files with an empty or generic MIME type. ### Utils Repo PR: https://github.com/chatwoot/utils/pull/61 Fixes https://linear.app/chatwoot/issue/CW-7085/support-more-file-types-in-private-notes-and-in-app ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Screenshots image ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: aakashb95 --- .../components-next/icon/FileIcon.vue | 1 + .../widgets/WootWriter/ReplyBottomPanel.vue | 10 +--------- app/javascript/shared/helpers/FileHelper.js | 4 +--- app/models/attachment.rb | 18 ++++++++++++++++-- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- theme/icons.js | 17 +++++++++++++++++ 7 files changed, 42 insertions(+), 20 deletions(-) diff --git a/app/javascript/dashboard/components-next/icon/FileIcon.vue b/app/javascript/dashboard/components-next/icon/FileIcon.vue index 8dd9e7ce1..d82be3e69 100644 --- a/app/javascript/dashboard/components-next/icon/FileIcon.vue +++ b/app/javascript/dashboard/components-next/icon/FileIcon.vue @@ -18,6 +18,7 @@ const fileTypeIcon = computed(() => { json: 'i-woot-file-txt', odt: 'i-woot-file-doc', pdf: 'i-woot-file-pdf', + pfx: 'i-woot-file-pfx', ppt: 'i-woot-file-ppt', pptx: 'i-woot-file-ppt', rar: 'i-woot-file-zip', diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index ff569d763..2e5a9aab2 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -7,7 +7,6 @@ import * as ActiveStorage from 'activestorage'; import inboxMixin from 'shared/mixins/inboxMixin'; import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { getAllowedFileTypesByChannel } from '@chatwoot/utils'; -import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; import VideoCallButton from '../VideoCallButton.vue'; import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { mapGetters } from 'vuex'; @@ -166,11 +165,6 @@ export default { uploadRef, }; }, - data() { - return { - ALLOWED_FILE_TYPES, - }; - }, computed: { ...mapGetters({ accountId: 'getCurrentAccountId', @@ -212,13 +206,11 @@ export default { return this.conversationType === 'instagram_direct_message'; }, allowedFileTypes() { - // Use default file types for private notes if (this.isOnPrivateNote) { - return this.ALLOWED_FILE_TYPES; + return getAllowedFileTypesByChannel(); } let channelType = this.channelType || this.inbox?.channel_type; - if (this.isAnInstagramChannel || this.isInstagramDM) { channelType = INBOX_TYPES.INSTAGRAM; } diff --git a/app/javascript/shared/helpers/FileHelper.js b/app/javascript/shared/helpers/FileHelper.js index 2616c868a..93c8a7156 100644 --- a/app/javascript/shared/helpers/FileHelper.js +++ b/app/javascript/shared/helpers/FileHelper.js @@ -1,6 +1,5 @@ import { getAllowedFileTypesByChannel } from '@chatwoot/utils'; import { INBOX_TYPES } from 'dashboard/helper/inbox'; -import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; export const DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE = 40; @@ -58,9 +57,8 @@ export const isFileTypeAllowedForChannel = (file, options = {}) => { isOnPrivateNote, } = options; - // Use broader file types for private notes (matches file picker behavior) const allowedFileTypes = isOnPrivateNote - ? ALLOWED_FILE_TYPES + ? getAllowedFileTypesByChannel() : getAllowedFileTypesByChannel({ channelType: isInstagramChannel || conversationType === 'instagram_direct_message' diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 2d46f3b7e..102d90beb 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -33,7 +33,10 @@ class Attachment < ApplicationRecord application/vnd.openxmlformats-officedocument.presentationml.presentation application/vnd.openxmlformats-officedocument.spreadsheetml.sheet application/vnd.openxmlformats-officedocument.wordprocessingml.document + application/x-pkcs12 application/pkcs12 ].freeze + ACCEPTABLE_FILE_EXTENSIONS = %w[pfx].freeze + GENERIC_FILE_CONTENT_TYPES = %w[application/octet-stream].freeze belongs_to :account belongs_to :message has_one_attached :file @@ -195,7 +198,10 @@ class Attachment < ApplicationRecord end def validate_file_content_type(file_content_type) - errors.add(:file, 'type not supported') unless media_file?(file_content_type) || ACCEPTABLE_FILE_TYPES.include?(file_content_type) + return if media_file?(file_content_type) || ACCEPTABLE_FILE_TYPES.include?(file_content_type) + return if generic_file_content_type?(file_content_type) && ACCEPTABLE_FILE_EXTENSIONS.include?(file_extension) + + errors.add(:file, 'type not supported') end def validate_file_size(byte_size) @@ -206,7 +212,15 @@ class Attachment < ApplicationRecord end def media_file?(file_content_type) - file_content_type.start_with?('image/', 'video/', 'audio/') + file_content_type.to_s.start_with?('image/', 'video/', 'audio/') + end + + def generic_file_content_type?(file_content_type) + file_content_type.blank? || GENERIC_FILE_CONTENT_TYPES.include?(file_content_type) + end + + def file_extension + File.extname(file.filename.to_s).delete_prefix('.').downcase end end diff --git a/package.json b/package.json index ed8bbf00f..1e506d6c5 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", "@chatwoot/prosemirror-schema": "1.3.13", - "@chatwoot/utils": "^0.0.52", + "@chatwoot/utils": "^0.0.55", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", "@hcaptcha/vue3-hcaptcha": "^1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f1e9e2cf..3a9efa7eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,8 +29,8 @@ importers: specifier: 1.3.13 version: 1.3.13 '@chatwoot/utils': - specifier: ^0.0.52 - version: 0.0.52 + specifier: ^0.0.55 + version: 0.0.55 '@formkit/core': specifier: ^1.7.2 version: 1.7.2 @@ -462,8 +462,8 @@ packages: '@chatwoot/prosemirror-schema@1.3.13': resolution: {integrity: sha512-T6FBUinMJbwDCD7975g8M/Tsn2+G3O2pTGIXdcLkMRpbAAC6mVdl4ZcZektlt5y/PVmPVqNHPsfee1XB/C3vAw==} - '@chatwoot/utils@0.0.52': - resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==} + '@chatwoot/utils@0.0.55': + resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==} engines: {node: '>=10'} '@codemirror/commands@6.7.0': @@ -5011,7 +5011,7 @@ snapshots: prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3) prosemirror-view: 1.34.1 - '@chatwoot/utils@0.0.52': + '@chatwoot/utils@0.0.55': dependencies: date-fns: 2.30.0 diff --git a/theme/icons.js b/theme/icons.js index 266c7ddfa..281e21c23 100644 --- a/theme/icons.js +++ b/theme/icons.js @@ -113,6 +113,23 @@ export const icons = { width: 16, height: 20, }, + 'file-pfx': { + body: ` + + + + + + + + + + + + `, + width: 16, + height: 20, + }, bin: { body: ``, width: 16,