# 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 #