mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-21 01:53:41 +08:00
Currently we allow for 2 theme screenshots to be specified, with a lightweight spec to allow both a light and dark version of the screenshot. However, we were not storing this screenshot name anywhere, so we would not be able to use it for light/dark switching. This commit fixes that issue, and also does some general refactoring around theme screenshots, and adds more tests.
512 lines
15 KiB
Ruby
Vendored
512 lines
15 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_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
|
|
|
|
# This is only used in the development and test environment and is currently not supported for other environments
|
|
if Rails.env.test? || Rails.env.development?
|
|
def self.import_theme_from_directory(directory)
|
|
update_theme(ThemeStore::DirectoryImporter.new(directory), update_components: "none")
|
|
end
|
|
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(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, 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
|
|
|
|
# TODO (martin): Until we are ready to roll this out more
|
|
# widely, let's avoid doing this work for most sites.
|
|
if SiteSetting.theme_download_screenshots
|
|
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
|
|
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
|
|
|
|
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 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)
|
|
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,
|
|
).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
|
|
#
|