mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-07 17:36:22 +08:00
This change extends the upload type site setting to support non-image files by adding two new optional attributes: - `authorized_extensions`: pipe-separated list of allowed extensions (e.g., "txt|json"). When not specified, only images are allowed. - `max_file_size_kb`: maximum file size in KB. When not specified, uses max_image_size_kb or max_attachment_size_kb based on file type. The upload component has been rewritten as a Glimmer component with: - Separate UI for image files (preview with lightbox) and non-image files (file info with download link) - Display of upload restrictions when configured - Drag and drop support - Progress bar during upload Example usage in site_settings.yml: ```yaml llms_txt: default: "" type: upload authorized_extensions: "txt|md" max_file_size_kb: 512 ``` Ref - t/162690 Related #36939 **Empty site setting** <img width="1762" height="1229" alt="2026-01-08 @ 09 32 13" src="https://github.com/user-attachments/assets/8f1bad3a-7070-4313-b82f-66335e9dbaff" /> **Site setting with a file uploaded** <img width="1762" height="1229" alt="2026-01-08 @ 09 32 26" src="https://github.com/user-attachments/assets/f20cc78b-8e81-451b-b334-8f3dada213b1" /> --------- Co-authored-by: chapoi <101828855+chapoi@users.noreply.github.com>
230 lines
6.7 KiB
Ruby
230 lines
6.7 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "file_helper"
|
|
|
|
class UploadValidator < ActiveModel::Validator
|
|
def validate(upload)
|
|
# staff can upload any file in PM
|
|
if (upload.for_private_message && SiteSetting.allow_staff_to_upload_any_file_in_pm)
|
|
return true if upload.user&.staff?
|
|
end
|
|
|
|
# check the attachment blocklist
|
|
if upload.for_group_message && SiteSetting.allow_all_attachments_for_group_messages
|
|
return upload.original_filename =~ SiteSetting.blocked_attachment_filenames_regex
|
|
end
|
|
|
|
extension = File.extname(upload.original_filename)[1..-1] || ""
|
|
|
|
return validate_site_setting_upload(upload, extension) if upload.for_site_setting
|
|
|
|
if upload.for_gravatar && FileHelper.supported_gravatar_extensions.include?(extension)
|
|
maximum_image_file_size(upload)
|
|
return true
|
|
end
|
|
|
|
return true if changing_upload_security?(upload)
|
|
|
|
if is_authorized?(upload, extension)
|
|
if FileHelper.is_supported_image?(upload.original_filename)
|
|
authorized_image_extension(upload, extension)
|
|
maximum_image_file_size(upload)
|
|
else
|
|
authorized_attachment_extension(upload, extension)
|
|
maximum_attachment_file_size(upload)
|
|
end
|
|
end
|
|
end
|
|
|
|
def validate_site_setting_upload(upload, extension)
|
|
unless upload.user&.staff?
|
|
upload.errors.add(:base, I18n.t("upload.unauthorized"))
|
|
return false
|
|
end
|
|
|
|
setting_opts = site_setting_type_hash(upload.site_setting_name)
|
|
authorized_extensions = setting_opts[:authorized_extensions]
|
|
|
|
if authorized_extensions.present?
|
|
unless extension_allowed_for_site_setting?(extension, authorized_extensions)
|
|
upload.errors.add(
|
|
:original_filename,
|
|
I18n.t("upload.unauthorized", authorized_extensions: authorized_extensions),
|
|
)
|
|
return false
|
|
end
|
|
|
|
validate_site_setting_file_size(upload, setting_opts)
|
|
else
|
|
unless FileHelper.is_supported_image?(upload.original_filename)
|
|
upload.errors.add(:original_filename, I18n.t("upload.images_only"))
|
|
return false
|
|
end
|
|
|
|
validate_site_setting_file_size(upload, setting_opts)
|
|
end
|
|
|
|
upload.errors.empty?
|
|
end
|
|
|
|
# this should only be run on existing records, and covers cases of
|
|
# upload.update_secure_status being run outside of the creation flow,
|
|
# where some cases e.g. have exemptions on the extension enforcement
|
|
def changing_upload_security?(upload)
|
|
!upload.new_record? &&
|
|
upload.changed_attributes.keys.all? do |attribute|
|
|
%w[secure security_last_changed_at security_last_changed_reason].include?(attribute)
|
|
end
|
|
end
|
|
|
|
def is_authorized?(upload, extension)
|
|
extension_authorized?(upload, extension, authorized_extensions(upload))
|
|
end
|
|
|
|
def authorized_image_extension(upload, extension)
|
|
extension_authorized?(upload, extension, authorized_images(upload))
|
|
end
|
|
|
|
def maximum_image_file_size(upload)
|
|
maximum_file_size(upload, "image")
|
|
end
|
|
|
|
def authorized_attachment_extension(upload, extension)
|
|
extension_authorized?(upload, extension, authorized_attachments(upload))
|
|
end
|
|
|
|
def maximum_attachment_file_size(upload)
|
|
maximum_file_size(upload, "attachment")
|
|
end
|
|
|
|
private
|
|
|
|
def site_setting_type_hash(setting_name)
|
|
return {} if setting_name.blank?
|
|
SiteSetting.type_supervisor.type_hash(setting_name.to_sym)
|
|
end
|
|
|
|
def validate_site_setting_file_size(upload, setting_opts)
|
|
return if !upload.validate_file_size
|
|
|
|
max_size_kb = setting_opts[:max_file_size_kb]
|
|
|
|
if max_size_kb.present?
|
|
max_size_bytes = max_size_kb.to_i.kilobytes
|
|
if upload.filesize > max_size_bytes
|
|
upload.errors.add(
|
|
:filesize,
|
|
I18n.t(
|
|
"upload.attachments.too_large_humanized",
|
|
max_size: ActiveSupport::NumberHelper.number_to_human_size(max_size_bytes),
|
|
),
|
|
)
|
|
end
|
|
elsif FileHelper.is_supported_image?(upload.original_filename)
|
|
maximum_image_file_size(upload)
|
|
else
|
|
maximum_attachment_file_size(upload)
|
|
end
|
|
end
|
|
|
|
def extension_allowed_for_site_setting?(extension, authorized_extensions)
|
|
extensions_to_set(authorized_extensions).include?(extension.downcase)
|
|
end
|
|
|
|
def extensions_to_set(exts)
|
|
extensions = Set.new
|
|
|
|
exts
|
|
.gsub(/[\s\.]+/, "")
|
|
.downcase
|
|
.split("|")
|
|
.each { |extension| extensions << extension if extension.exclude?("*") }
|
|
|
|
extensions
|
|
end
|
|
|
|
def authorized_extensions(upload)
|
|
extensions =
|
|
if upload.for_theme
|
|
SiteSetting.theme_authorized_extensions
|
|
elsif upload.for_export
|
|
SiteSetting.export_authorized_extensions
|
|
else
|
|
SiteSetting.authorized_extensions
|
|
end
|
|
extensions_to_set(extensions)
|
|
end
|
|
|
|
def authorized_images(upload)
|
|
authorized_extensions(upload) & FileHelper.supported_images
|
|
end
|
|
|
|
def authorized_attachments(upload)
|
|
authorized_extensions(upload) - FileHelper.supported_images
|
|
end
|
|
|
|
def authorizes_all_extensions?(upload)
|
|
if upload.user&.staff?
|
|
return true if SiteSetting.authorized_extensions_for_staff.include?("*")
|
|
end
|
|
extensions =
|
|
if upload.for_theme
|
|
SiteSetting.theme_authorized_extensions
|
|
elsif upload.for_export
|
|
SiteSetting.export_authorized_extensions
|
|
else
|
|
SiteSetting.authorized_extensions
|
|
end
|
|
extensions.include?("*")
|
|
end
|
|
|
|
def extension_authorized?(upload, extension, extensions)
|
|
return true if authorizes_all_extensions?(upload)
|
|
|
|
staff_extensions = Set.new
|
|
if upload.user&.staff?
|
|
staff_extensions = extensions_to_set(SiteSetting.authorized_extensions_for_staff)
|
|
return true if staff_extensions.include?(extension.downcase)
|
|
end
|
|
|
|
unless authorized = extensions.include?(extension.downcase)
|
|
message =
|
|
I18n.t(
|
|
"upload.unauthorized",
|
|
authorized_extensions: (extensions | staff_extensions).to_a.join(", "),
|
|
)
|
|
upload.errors.add(:original_filename, message)
|
|
end
|
|
|
|
authorized
|
|
end
|
|
|
|
def maximum_file_size(upload, type)
|
|
return if !upload.validate_file_size
|
|
|
|
max_size_kb =
|
|
if upload.for_export
|
|
SiteSetting.max_export_file_size_kb
|
|
else
|
|
if upload.user&.id == Discourse::SYSTEM_USER_ID && type == "attachment"
|
|
[
|
|
SiteSetting.get("system_user_max_attachment_size_kb"),
|
|
SiteSetting.get("max_attachment_size_kb"),
|
|
].max
|
|
else
|
|
SiteSetting.get("max_#{type}_size_kb")
|
|
end
|
|
end
|
|
|
|
max_size_bytes = max_size_kb.kilobytes
|
|
|
|
if upload.filesize > max_size_bytes
|
|
message =
|
|
I18n.t(
|
|
"upload.#{type}s.too_large_humanized",
|
|
max_size: ActiveSupport::NumberHelper.number_to_human_size(max_size_bytes),
|
|
)
|
|
upload.errors.add(:filesize, message)
|
|
end
|
|
end
|
|
end
|