mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-06 19:53:42 +08:00
This fixes an issue identified in specs where the theme site setting
cache was not being set correctly when importing themes, leading
to an incomplete list of settings being sent down to the client
via the `themeSiteSettingOverrides` preload store.
For example, a theme might have something like this:
```
496=>{:enable_welcome_banner=>false}}
```
Which is missing the `search_experience` theme site setting.
We can address this properly by refreshing the SiteSetting theme
site setting cache after importing themes, ensuring that all
properties are correctly set.
549 lines
16 KiB
Ruby
549 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_format_of :minimum_discourse_version,
|
|
:maximum_discourse_version,
|
|
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("remote_themes.remote_url NOT IN (?)", 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("last_error_text IS NOT NULL").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)
|
|
missing_scheme_names = Hash[*theme.color_schemes.pluck(:name, :id).flatten]
|
|
ordered_schemes = []
|
|
|
|
schemes&.each do |name, colors|
|
|
missing_scheme_names.delete(name)
|
|
scheme = theme.color_schemes.find_by(name: name) || 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
|
|
ColorScheme.where(id: missing_scheme_names.values).delete_all
|
|
# we may have stuff pointed at the incorrect scheme?
|
|
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,
|
|
) { |result| }
|
|
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
|
|
#
|