discourse/lib/validators/upload_validator.rb
Régis Hanol 6212b0b736
FEATURE: Support non-image file uploads in site settings (#37005)
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>
2026-01-19 22:37:38 +01:00

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