discourse/app/models/remote_theme.rb
Martin Brennan 456fbb1dbf
FEATURE: Allow themes to define screenshots (#29079)
This commit allows themes to define up to 2 screenshots
in about.json. These should be paths within the theme's
git repository, images with a 1MB max file size and max width 3840x2160.

These screenshots will be downloaded and stored against a theme
field, and we will use these in the redesigned theme grid UI.

These screenshots will be updated when the theme is updated
in the same way the additional theme files are.

For now this is gated behind a hidden `theme_download_screenshots`
site setting, to allow us to test this on a small number of sites without
making other sites make unnecessary uploads.

**Future considerations:**

* We may want to have a specialized naming system for screenshots. E.g. having light.png/dark.png/some_palette.png
* We may want to show more than one screenshot for the theme, maybe in a carousel or reacting to dark mode or color palette changes
* We may want to allow clicking on the theme screenshot to show a lightbox
* We may want to make an optimized thumbnail image for the theme grid

---------

Co-authored-by: Ted Johansson <ted@discourse.org>
2024-10-28 10:10:20 +10:00

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
#