discourse/app/models/remote_theme.rb
Bianca Nenciu e029a9b36c
FEATURE: Allow private themes to be partially installed (#17644)
A public key must be added to GitHub when installing private themes.
When the process happens asynchronously (for example if the admin does
not have admin permissions to the GitHub repository), installing
private themes becomes very difficult.

In this case, the Discourse admin can partially install the theme by
letting Discourse save the private key, create a placeholder theme and
give the admin a public key to be used as a deploy key. After the key
is installed, the admin can finish theme installation by pressing a
button on the theme page.
2022-08-10 13:30:18 +03:00

323 lines
11 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 head_tag header after_header body_tag footer}
GITHUB_REGEXP = /^https?:\/\/github\.com\//
GITHUB_SSH_REGEXP = /^git@github\.com:/
has_one :theme, autosave: false
scope :joined_remotes, -> {
joins("JOIN themes ON themes.remote_theme_id = remote_themes.id").where.not(remote_url: "")
}
validates_format_of :minimum_discourse_version, :maximum_discourse_version, with: Discourse::VERSION_REGEXP, allow_nil: true
def self.extract_theme_info(importer)
JSON.parse(importer["about.json"])
rescue TypeError, JSON::ParserError
raise ImportError.new I18n.t("themes.import_error.about_json")
end
def self.update_zipped_theme(filename, original_filename, match_theme: false, user: Discourse.system_user, theme_id: nil, update_components: nil)
importer = ThemeStore::ZipImporter.new(filename, original_filename)
importer.import!
theme_info = RemoteTheme.extract_theme_info(importer)
theme = Theme.find_by(name: theme_info["name"]) if match_theme # Old theme CLI method, remove Jan 2020
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 || []
remote_theme = new
remote_theme.theme = theme
remote_theme.remote_url = ""
remote_theme.update_from_remote(importer, skip_update: true)
theme.save!
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
theme.update_child_components
end
theme
ensure
begin
importer.cleanup!
rescue => e
Rails.logger.warn("Failed cleanup remote path #{e}")
end
end
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.save!
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)
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)
new_path = "#{File.dirname(path)}/#{SecureRandom.hex}#{File.extname(path)}"
File.rename(path, new_path) # OptimizedImage has strict file name restrictions, so rename temporarily
upload = UploadCreator.new(File.open(new_path), File.basename(relative_path), for_theme: true).create_for(theme.user_id)
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
# 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|
theme.theme_modifier_set.public_send(:"#{modifier_name}=", theme_info.dig("modifiers", modifier_name.to_s))
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
importer.all_files.each do |filename|
next unless opts = ThemeField.opts_from_file_path(filename)
value = importer[filename]
updated_fields << theme.set_field(**opts.merge(value: value))
end
theme.convert_settings
# 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
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
update_theme_color_schemes(theme, theme_info["color_schemes"]) unless theme.component
self.save!
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
if override !~ /\A[0-9a-f]{6}\z/
override = nil
end
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
if theme.new_record?
theme.color_scheme = ordered_schemes.first
end
end
def github_diff_link
if github_repo_url.present? && local_version != remote_version
"#{github_repo_url.gsub(/\.git$/, "")}/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
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
#