562 lines
17 KiB
Ruby
562 lines
17 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
|
|
MAX_THEME_SCREENSHOT_FILE_SIZE = 1.megabyte
|
|
MAX_THEME_SCREENSHOT_DIMENSIONS = [3840, 2160] # 4K resolution
|
|
THEME_SCREENSHOT_ALLOWED_FILE_TYPES = %w[.jpg .jpeg .gif .png].freeze
|
|
|
|
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 = create_upload(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
|
|
theme_info["screenshots"] = Array.wrap(theme_info["screenshots"]).take(2)
|
|
theme_info["screenshots"].each_with_index do |relative_path, idx|
|
|
if path = importer.real_path(relative_path)
|
|
if !THEME_SCREENSHOT_ALLOWED_FILE_TYPES.include?(File.extname(path))
|
|
raise ImportError,
|
|
I18n.t(
|
|
"themes.import_error.screenshot_invalid_type",
|
|
file_name: File.basename(path),
|
|
accepted_formats: THEME_SCREENSHOT_ALLOWED_FILE_TYPES.join(","),
|
|
)
|
|
end
|
|
|
|
if File.size(path) > MAX_THEME_SCREENSHOT_FILE_SIZE
|
|
raise ImportError,
|
|
I18n.t(
|
|
"themes.import_error.screenshot_invalid_size",
|
|
file_name: File.basename(path),
|
|
max_size:
|
|
ActiveSupport::NumberHelper.number_to_human_size(
|
|
MAX_THEME_SCREENSHOT_FILE_SIZE,
|
|
),
|
|
)
|
|
end
|
|
|
|
screenshot_width, screenshot_height = FastImage.size(path)
|
|
if (screenshot_width.nil? || screenshot_height.nil?) ||
|
|
screenshot_width > MAX_THEME_SCREENSHOT_DIMENSIONS[0] ||
|
|
screenshot_height > MAX_THEME_SCREENSHOT_DIMENSIONS[1]
|
|
raise ImportError,
|
|
I18n.t(
|
|
"themes.import_error.screenshot_invalid_dimensions",
|
|
file_name: File.basename(path),
|
|
width: screenshot_width.to_i,
|
|
height: screenshot_height.to_i,
|
|
max_width: MAX_THEME_SCREENSHOT_DIMENSIONS[0],
|
|
max_height: MAX_THEME_SCREENSHOT_DIMENSIONS[1],
|
|
)
|
|
end
|
|
|
|
upload = create_upload(path, relative_path)
|
|
if !upload.errors.empty?
|
|
raise ImportError,
|
|
I18n.t(
|
|
"themes.import_error.screenshot",
|
|
errors: upload.errors.full_messages.join(","),
|
|
)
|
|
end
|
|
|
|
updated_fields << theme.set_field(
|
|
target: :common,
|
|
name: "screenshot_#{idx + 1}",
|
|
type: :theme_screenshot_upload_var,
|
|
upload_id: upload.id,
|
|
)
|
|
end
|
|
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 create_upload(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
|
|
#
|