chatwoot/app/models/attachment.rb
Aakash Bakhle bef25781de
feat(attachments): add XML and PFX file support (#14539)
Update frontend allowed file types and FileIcon mapping, and backend
Attachment constants to accept .xml and .pfx files

# Pull Request Template

## Description
Customer also wanted XML support along with .pfx

Following up on #14456

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally

<img width="864" height="512" alt="CleanShot 2026-05-22 at 11 43 20@2x"
src="https://github.com/user-attachments/assets/4cbf65d4-b919-4a4b-bf75-a4f2e8690586"
/>

<img width="870" height="1440" alt="CleanShot 2026-05-22 at 11 44 03@2x"
src="https://github.com/user-attachments/assets/e763b49d-4365-4c45-9b43-b0c39af87656"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] 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
- [x] 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
- [x] Any dependent changes have been merged and published in downstream
modules
2026-05-22 11:55:16 +05:30

229 lines
6.3 KiB
Ruby

# == Schema Information
#
# Table name: attachments
#
# id :integer not null, primary key
# coordinates_lat :float default(0.0)
# coordinates_long :float default(0.0)
# extension :string
# external_url :string
# fallback_title :string
# file_type :integer default("image")
# meta :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# message_id :integer not null
#
# Indexes
#
# index_attachments_on_account_id (account_id)
# index_attachments_on_message_id (message_id)
#
class Attachment < ApplicationRecord
include Rails.application.routes.url_helpers
ACCEPTABLE_FILE_TYPES = %w[
text/csv text/plain text/rtf text/xml
application/json application/pdf
application/xml
application/zip application/x-7z-compressed application/vnd.rar application/x-tar
application/msword application/vnd.ms-excel application/vnd.ms-powerpoint application/rtf
application/vnd.oasis.opendocument.text
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 xml].freeze
GENERIC_FILE_CONTENT_TYPES = %w[application/octet-stream].freeze
belongs_to :account
belongs_to :message
has_one_attached :file
before_save :set_extension
validate :acceptable_file
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
:contact => 8, :ig_reel => 9, :ig_post => 10, :ig_story => 11, :embed => 12 }
def push_event_data
return unless file_type
base_data.merge(metadata_for_file_type)
end
# NOTE: the URl returned does a 301 redirect to the actual file
def file_url
file.attached? ? url_for(file) : ''
end
# NOTE: for External services use this methods since redirect doesn't work effectively in a lot of cases
def download_url
ActiveStorage::Current.url_options = Rails.application.routes.default_url_options if ActiveStorage::Current.url_options.blank?
file.attached? ? file.blob.url : ''
end
def thumb_url
return '' unless file.attached? && image?
begin
url_for(file.representation(resize_to_fill: [250, nil]))
rescue ActiveStorage::UnrepresentableError => e
Rails.logger.warn "Unrepresentable image attachment: #{id} (#{file.filename}) - #{e.message}"
''
end
end
def with_attached_file?
[:image, :audio, :video, :file].include?(file_type.to_sym)
end
private
def metadata_for_file_type
case file_type.to_sym
when :location
location_metadata
when :fallback
fallback_data
when :contact
contact_metadata
when :audio
audio_metadata
when :embed
embed_data
else
file.attached? ? file_metadata : { data_url: external_url, thumb_url: '' }
end
end
def embed_data
{
data_url: external_url
}
end
def audio_metadata
audio_file_data = base_data.merge(file_metadata)
audio_file_data.merge(
{
# Keep audio playback inline while avoiding the ActiveStorage proxy path.
data_url: inline_audio_url,
transcribed_text: meta&.[]('transcribed_text') || ''
}
)
end
def inline_audio_url
return '' unless file.attached?
Rails.application.routes.url_helpers.rails_storage_redirect_url(file, disposition: 'inline')
end
def file_metadata
metadata = {
extension: extension,
content_type: file.content_type,
data_url: file_url,
thumb_url: thumb_url,
file_size: file.byte_size,
width: file.metadata[:width],
height: file.metadata[:height]
}
metadata[:data_url] = metadata[:thumb_url] = external_url if instagram_incoming_message?
metadata
end
def location_metadata
{
coordinates_lat: coordinates_lat,
coordinates_long: coordinates_long,
fallback_title: fallback_title,
data_url: external_url
}
end
def fallback_data
{
fallback_title: fallback_title,
data_url: external_url
}
end
def base_data
{
id: id,
message_id: message_id,
file_type: file_type,
account_id: account_id
}
end
def contact_metadata
{
fallback_title: fallback_title,
meta: meta || {}
}
end
def instagram_incoming_message?
return false unless message.incoming?
return true if message.inbox.instagram_direct?
message.inbox.instagram? && message.conversation&.additional_attributes&.dig('type') == 'instagram_direct_message'
end
def set_extension
return unless file.attached?
return if extension.present?
self.extension = File.extname(file.filename.to_s).delete_prefix('.').presence
end
def should_validate_file?
return unless file.attached?
# we are only limiting attachment types in case of website widget
return unless message.inbox.channel_type == 'Channel::WebWidget'
true
end
def acceptable_file
return unless should_validate_file?
validate_file_size(file.byte_size)
validate_file_content_type(file.content_type)
end
def validate_file_content_type(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)
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)
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
Attachment.include_mod_with('Concerns::Attachment')