mirror of
https://github.com/chatwoot/chatwoot.git
synced 2026-06-04 21:02:35 +08:00
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
229 lines
6.3 KiB
Ruby
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')
|