discourse/app/models/remote_theme.rb
Kris dbcbd0d896
FEATURE: add modifier to restrict theme color schemes (#38796)
This allows themes to set a modifier in their `about.json` that
restricts a theme's color palettes to only what's defined within the
theme.

```json
"modifiers": {
   "only_theme_color_schemes": true
}
```

Once set, a theme's color palette settings will only list the palettes
included in about.json. A banner is shown indicating that the theme
restricts palettes.

<img width="700" alt="image"
src="https://github.com/user-attachments/assets/cfb4433c-c7c9-4923-a121-fddd572c29ea"
/>

<img width="300" alt="image"
src="https://github.com/user-attachments/assets/67ec1f72-d408-4e3b-911f-a2d0e4db9a37"
/>


If there's only 1 palette defined by the theme, it will be set to both
light and dark settings.

If there are more than 1 palettes defined, we check to see if one is
dark and will use the first dark palette for the dark setting.

If there are no color palettes defined by the theme, the color selection
will be unrestricted.

---------

Co-authored-by: Gabriel Grubba <70247653+Grubba27@users.noreply.github.com>
2026-03-26 09:03:11 -04:00

595 lines
17 KiB
Ruby
Vendored

# frozen_string_literal: true
class RemoteTheme < ActiveRecord::Base
METADATA_PROPERTIES = %i[
license_url
about_url
authors
theme_version
minimum_discourse_version
maximum_discourse_version
]
class ImportError < StandardError
end
ALLOWED_FIELDS = %w[
scss
embedded_scss
embedded_header
head_tag
header
after_header
body_tag
footer
]
GITHUB_REGEXP = %r{\Ahttps?://github\.com/}
GITHUB_SSH_REGEXP = %r{\Assh://git@github\.com:}
MAX_METADATA_FILE_SIZE = Discourse::MAX_METADATA_FILE_SIZE
MAX_ASSET_FILE_SIZE = 8.megabytes
MAX_THEME_FILE_COUNT = 1024
MAX_THEME_SIZE = 256.megabytes
has_one :theme, autosave: false
scope :joined_remotes,
-> do
joins("JOIN themes ON themes.remote_theme_id = remote_themes.id").where.not(
remote_url: "",
)
end
validates :minimum_discourse_version,
:maximum_discourse_version,
format: {
with: Discourse::VERSION_REGEXP,
allow_nil: true,
}
def self.extract_theme_info(importer)
if importer.file_size("about.json") > MAX_METADATA_FILE_SIZE
raise ImportError.new I18n.t(
"themes.import_error.about_json_too_big",
limit:
ActiveSupport::NumberHelper.number_to_human_size(
MAX_METADATA_FILE_SIZE,
),
)
end
begin
json = JSON.parse(importer["about.json"])
json.fetch("name")
json
rescue TypeError, JSON::ParserError, KeyError
raise ImportError.new I18n.t("themes.import_error.about_json")
end
end
def self.update_zipped_theme(
filename,
original_filename,
user: Discourse.system_user,
theme_id: nil,
update_components: nil,
run_migrations: true
)
update_theme(
ThemeStore::ZipImporter.new(filename, original_filename),
user:,
theme_id:,
update_components:,
run_migrations:,
)
end
def self.import_theme_from_directory(
directory,
theme_id: nil,
allow_out_of_sequence_migration: false
)
update_theme(
ThemeStore::DirectoryImporter.new(directory),
update_components: "none",
theme_id: theme_id,
allow_out_of_sequence_migration: allow_out_of_sequence_migration,
)
end
def self.update_theme(
importer,
user: Discourse.system_user,
theme_id: nil,
update_components: nil,
run_migrations: true,
allow_out_of_sequence_migration: false
)
importer.import!
theme_info = RemoteTheme.extract_theme_info(importer)
theme = Theme.find_by(id: theme_id) if theme_id # New theme CLI method
existing = true
if theme.blank?
theme =
Theme.new(
id: theme_id,
user_id: user&.id || -1,
name: theme_info["name"],
auto_update: false,
)
existing = false
end
theme.component = theme_info["component"].to_s == "true"
theme.child_components = child_components = theme_info["components"].presence || []
theme.skip_child_components_update = true if update_components == "none"
remote_theme = new
remote_theme.theme = theme
remote_theme.remote_url = ""
do_update_child_components = false
theme.transaction do
remote_theme.update_from_remote(
importer,
skip_update: true,
already_in_transaction: true,
run_migrations:,
allow_out_of_sequence_migration:,
)
if existing && update_components.present? && update_components != "none"
child_components = child_components.map { |url| ThemeStore::GitImporter.new(url.strip).url }
if update_components == "sync"
ChildTheme
.joins(child_theme: :remote_theme)
.where.not(remote_themes: { remote_url: child_components })
.delete_all
end
child_components -=
theme
.child_themes
.joins(:remote_theme)
.where("remote_themes.remote_url IN (?)", child_components)
.pluck("remote_themes.remote_url")
theme.child_components = child_components
do_update_child_components = true
end
end
theme.update_child_components if do_update_child_components
theme
ensure
begin
importer.cleanup!
rescue => e
Rails.logger.warn("Failed cleanup remote path #{e}")
end
end
private_class_method :update_theme
def self.import_theme(url, user = Discourse.system_user, private_key: nil, branch: nil)
importer = ThemeStore::GitImporter.new(url.strip, private_key: private_key, branch: branch)
importer.import!
theme_info = RemoteTheme.extract_theme_info(importer)
component = [true, "true"].include?(theme_info["component"])
theme = Theme.new(user_id: user&.id || -1, name: theme_info["name"], component: component)
theme.child_components = theme_info["components"].presence || []
remote_theme = new
theme.remote_theme = remote_theme
remote_theme.private_key = private_key
remote_theme.branch = branch
remote_theme.remote_url = importer.url
remote_theme.update_from_remote(importer)
theme
ensure
begin
importer.cleanup!
rescue => e
Rails.logger.warn("Failed cleanup remote git #{e}")
end
end
def self.out_of_date_themes
self
.joined_remotes
.where("commits_behind > 0 OR remote_version <> local_version")
.where(themes: { enabled: true })
.pluck("themes.name", "themes.id")
end
def self.unreachable_themes
self.joined_remotes.where.not(last_error_text: nil).pluck("themes.name", "themes.id")
end
def out_of_date?
commits_behind > 0 || remote_version != local_version
end
def update_remote_version
return unless is_git?
importer = ThemeStore::GitImporter.new(remote_url, private_key: private_key, branch: branch)
begin
importer.import!
rescue RemoteTheme::ImportError => err
self.last_error_text = err.message
else
self.updated_at = Time.zone.now
self.remote_version, self.commits_behind = importer.commits_since(local_version)
self.last_error_text = nil
ensure
self.save!
begin
importer.cleanup!
rescue => e
Rails.logger.warn("Failed cleanup remote git #{e}")
end
end
end
def update_from_remote(
importer = nil,
skip_update: false,
raise_if_theme_save_fails: true,
already_in_transaction: false,
run_migrations: true,
allow_out_of_sequence_migration: false
)
cleanup = false
unless importer
cleanup = true
importer = ThemeStore::GitImporter.new(remote_url, private_key: private_key, branch: branch)
begin
importer.import!
rescue RemoteTheme::ImportError => err
self.last_error_text = err.message
self.save!
return self
else
self.last_error_text = nil
end
end
theme_info = RemoteTheme.extract_theme_info(importer)
updated_fields = []
theme_info["assets"]&.each do |name, relative_path|
if path = importer.real_path(relative_path)
upload = RemoteTheme.create_upload(theme: theme, path: path, relative_path: relative_path)
if !upload.errors.empty?
raise ImportError,
I18n.t(
"themes.import_error.upload",
name: name,
errors: upload.errors.full_messages.join(","),
)
end
updated_fields << theme.set_field(
target: :common,
name: name,
type: :theme_upload_var,
upload_id: upload.id,
)
end
end
begin
updated_fields.concat(
ThemeScreenshotsHandler.new(theme).parse_screenshots_as_theme_fields!(
theme_info["screenshots"],
importer,
),
)
rescue ThemeScreenshotsHandler::ThemeScreenshotError => err
raise ImportError, err.message
end
# Update all theme attributes if this is just a placeholder
if self.remote_url.present? && !self.local_version && !self.commits_behind
self.theme.name = theme_info["name"]
self.theme.component = [true, "true"].include?(theme_info["component"])
self.theme.child_components = theme_info["components"].presence || []
end
METADATA_PROPERTIES.each do |property|
self.public_send(:"#{property}=", theme_info[property.to_s])
end
if !self.valid?
raise ImportError,
I18n.t(
"themes.import_error.about_json_values",
errors: self.errors.full_messages.join(","),
)
end
ThemeModifierSet.modifiers.keys.each do |modifier_name|
value = theme_info.dig("modifiers", modifier_name.to_s)
if Hash === value && value["type"] == "setting"
theme.theme_modifier_set.add_theme_setting_modifier(modifier_name, value["value"])
else
theme.theme_modifier_set.public_send(:"#{modifier_name}=", value)
end
end
if !theme.theme_modifier_set.valid?
raise ImportError,
I18n.t(
"themes.import_error.modifier_values",
errors: theme.theme_modifier_set.errors.full_messages.join(","),
)
end
all_files = importer.all_files
if all_files.size > MAX_THEME_FILE_COUNT
raise ImportError,
I18n.t(
"themes.import_error.too_many_files",
count: all_files.size,
limit: MAX_THEME_FILE_COUNT,
)
end
theme_size = 0
all_files.each do |filename|
next unless opts = ThemeField.opts_from_file_path(filename)
file_size = importer.file_size(filename)
if file_size > MAX_ASSET_FILE_SIZE
raise ImportError,
I18n.t(
"themes.import_error.asset_too_big",
filename: filename,
limit: ActiveSupport::NumberHelper.number_to_human_size(MAX_ASSET_FILE_SIZE),
)
end
theme_size += file_size
if theme_size > MAX_THEME_SIZE
raise ImportError,
I18n.t(
"themes.import_error.theme_too_big",
limit: ActiveSupport::NumberHelper.number_to_human_size(MAX_THEME_SIZE),
)
end
value = importer[filename]
updated_fields << theme.set_field(**opts.merge(value: value))
end
if !skip_update
self.remote_updated_at = Time.zone.now
self.remote_version = importer.version
self.local_version = importer.version
self.commits_behind = 0
end
transaction_block = ->(*) do
# Destroy fields that no longer exist in the remote theme
field_ids_to_destroy = theme.theme_fields.pluck(:id) - updated_fields.map { |tf| tf&.id }
ThemeField.where(id: field_ids_to_destroy).destroy_all
update_theme_color_schemes(theme, theme_info["color_schemes"]) unless theme.component
self.save!
if raise_if_theme_save_fails
theme.save!
else
raise ActiveRecord::Rollback if !theme.save
end
create_theme_site_settings(theme, theme_info["theme_site_settings"])
if run_migrations
theme.migrate_settings(
start_transaction: false,
allow_out_of_sequence_migration: allow_out_of_sequence_migration,
)
end
end
if already_in_transaction
transaction_block.call
else
self.transaction(&transaction_block)
end
theme.theme_modifier_set.save! if theme.theme_modifier_set.refresh_theme_setting_modifiers
self
ensure
begin
importer.cleanup! if cleanup
rescue => e
Rails.logger.warn("Failed cleanup remote git #{e}")
end
end
def normalize_override(hex)
return unless hex
override = hex.downcase
override = nil if override !~ /\A[0-9a-f]{6}\z/
override
end
def update_theme_color_schemes(theme, schemes)
existing_schemes =
if theme.id
ColorScheme.unscoped.where(theme_id: theme.id)
else
[]
end
missing_scheme_names =
existing_schemes.reduce({}) do |hash, cs|
hash[cs.name] = cs if !cs.remote_copy
hash
end
ordered_schemes = []
schemes&.each do |name, colors|
missing_scheme_names.delete(name)
scheme = existing_schemes.find { |cs| cs.name == name && cs.remote_copy }
scheme ||= existing_schemes.find { |cs| cs.name == name }
scheme ||= theme.color_schemes.build(name: name)
# Update main colors
ColorScheme.base.colors_hashes.each do |color|
override = normalize_override(colors[color[:name]])
color_scheme_color =
scheme.color_scheme_colors.to_a.find { |c| c.name == color[:name] } ||
scheme.color_scheme_colors.build(name: color[:name])
color_scheme_color.hex = override || color[:hex]
theme.notify_color_change(color_scheme_color) if color_scheme_color.hex_changed?
end
# Update advanced colors
ColorScheme.color_transformation_variables.each do |variable_name|
override = normalize_override(colors[variable_name])
color_scheme_color = scheme.color_scheme_colors.to_a.find { |c| c.name == variable_name }
if override
color_scheme_color ||= scheme.color_scheme_colors.build(name: variable_name)
color_scheme_color.hex = override
theme.notify_color_change(color_scheme_color) if color_scheme_color.hex_changed?
elsif color_scheme_color # No longer specified in about.json, delete record
scheme.color_scheme_colors.delete(color_scheme_color)
theme.notify_color_change(nil, scheme: scheme)
end
end
ordered_schemes << scheme
end
if missing_scheme_names.length > 0
to_be_deleted_ids = []
missing_scheme_names.values.each do |cs|
if (base = existing_schemes.find { |s| s.id == cs.base_scheme_id && s.remote_copy })
to_be_deleted_ids << cs.base_scheme_id
else
to_be_deleted_ids << cs.id
end
end
ColorScheme.unscoped.where(id: to_be_deleted_ids).destroy_all
end
if theme.new_record? && ordered_schemes.present?
if theme.theme_modifier_set.only_theme_color_schemes
light = ordered_schemes.find { |s| !s.is_dark? } || ordered_schemes.first
dark = ordered_schemes.find { |s| s.is_dark? } || ordered_schemes.first
theme.color_scheme = light
theme.dark_color_scheme = dark
else
theme.color_scheme = ordered_schemes.first
end
end
end
def create_theme_site_settings(theme, theme_site_settings)
theme_site_settings ||= {}
existing_theme_site_settings =
theme.theme_site_settings.where(name: theme_site_settings.keys).to_a
theme_site_settings.each do |setting, value|
next if !SiteSetting.themeable[setting.to_sym]
# If there is an existing theme site setting, then don't touch it,
# we don't want to mess with site owner's changes.
existing_theme_site_setting =
existing_theme_site_settings.find do |theme_site_setting|
theme_site_setting.name == setting
end
next if existing_theme_site_setting.present?
# The manager handles creating the theme site setting record
# if it does not exist.
Themes::ThemeSiteSettingManager.call(
params: {
theme_id: theme.id,
name: setting,
value: value,
},
guardian: Discourse.system_user.guardian,
)
end
SiteSetting.refresh!(refresh_site_settings: false, refresh_theme_site_settings: true)
end
def github_diff_link
if github_repo_url.present? && local_version != remote_version
"#{github_repo_url.gsub(/\.git\z/, "")}/compare/#{local_version}...#{remote_version}"
end
end
def github_repo_url
url = remote_url.strip
return url if url.match?(GITHUB_REGEXP)
if url.match?(GITHUB_SSH_REGEXP)
org_repo = url.gsub(GITHUB_SSH_REGEXP, "")
"https://github.com/#{org_repo}"
end
end
def is_git?
remote_url.present?
end
def self.create_upload(theme:, path:, relative_path:, skip_validations: false)
new_path = "#{File.dirname(path)}/#{SecureRandom.hex}#{File.extname(path)}"
# OptimizedImage has strict file name restrictions, so rename temporarily
File.rename(path, new_path)
UploadCreator.new(
File.open(new_path),
File.basename(relative_path),
for_theme: true,
skip_validations: skip_validations,
).create_for(theme.user_id)
end
end
# == Schema Information
#
# Table name: remote_themes
#
# id :integer not null, primary key
# remote_url :string not null
# remote_version :string
# local_version :string
# about_url :string
# license_url :string
# commits_behind :integer
# remote_updated_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
# private_key :text
# branch :string
# last_error_text :text
# authors :string
# theme_version :string
# minimum_discourse_version :string
# maximum_discourse_version :string
#