2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-03-04 01:15:08 +08:00
discourse/app/models/remote_theme.rb
Krzysztof Kotlarek c02445a2ca
FIX: Prevent deletion of custom color palettes when installing themes (#35754)
When installing a new theme, custom color palettes (with theme_id = nil)
were being incorrectly deleted. This occurred because the theme
installation process called `update_theme_color_schemes` before the new
theme was persisted to the database.

Since the theme's ID was nil, the query
`ColorScheme.unscoped.where(theme_id: nil)` would match ALL custom color
schemes, causing them to be deleted during the cleanup phase.

Bug introduced in this PR -
https://github.com/discourse/discourse/pull/34722
2025-11-03 14:09:46 +08:00

573 lines
16 KiB
Ruby

# 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)
update_theme(
ThemeStore::DirectoryImporter.new(directory),
update_components: "none",
theme_id: theme_id,
)
end
def self.update_theme(
importer,
user: Discourse.system_user,
theme_id: nil,
update_components: nil,
run_migrations: true
)
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:,
)
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
)
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"])
theme.migrate_settings(start_transaction: false) if run_migrations
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
theme.color_scheme = ordered_schemes.first if theme.new_record?
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
#