# frozen_string_literal: true require "base64" class Admin::ThemesController < Admin::AdminController MAX_REMOTE_LENGTH = 10_000 skip_before_action :check_xhr, only: %i[show preview export] before_action :ensure_admin def preview theme = Theme.find_by(id: params[:id]) raise Discourse::InvalidParameters.new(:id) unless theme redirect_to path("/?preview_theme_id=#{theme.id}") end def upload_asset ban_in_allowlist_mode! path = params[:file].path hijack do File.open(path) do |file| filename = params[:file]&.original_filename || File.basename(path) upload = UploadCreator.new(file, filename, for_theme: true).create_for(theme_user.id) if upload.errors.count > 0 render_json_error upload else render json: { upload_id: upload.id }, status: :created end end end end def generate_key_pair require "sshkey" k = SSHKey.generate Discourse.redis.setex("ssh_key_#{k.ssh_public_key}", 1.hour, k.private_key) render json: { public_key: k.ssh_public_key } end THEME_CONTENT_TYPES ||= %w[ application/gzip application/x-gzip application/x-zip-compressed application/zip ] def import @theme = nil if params[:theme] && params[:theme].content_type == "application/json" ban_in_allowlist_mode! # .dcstyle.json import. Deprecated, but still available to allow conversion json = JSON.parse(params[:theme].read) theme = json["theme"] @theme = Theme.new(name: theme["name"], user_id: theme_user.id, auto_update: false) theme["theme_fields"]&.each do |field| if field["raw_upload"] begin tmp = Tempfile.new tmp.binmode file = Base64.decode64(field["raw_upload"]) tmp.write(file) tmp.rewind upload = UploadCreator.new(tmp, field["filename"]).create_for(theme_user.id) field["upload_id"] = upload.id ensure tmp.unlink end end @theme.set_field( target: field["target"], name: field["name"], value: field["value"], type_id: field["type_id"], upload_id: field["upload_id"], ) end if @theme.save log_theme_change(nil, @theme) render json: @theme, status: :created else render json: @theme.errors, status: :unprocessable_entity end elsif remote = params[:remote] if remote.length > MAX_REMOTE_LENGTH error = I18n.t("themes.import_error.not_allowed_theme", { repo: remote[0..MAX_REMOTE_LENGTH] }) return render_json_error(error, status: 422) end begin guardian.ensure_allowed_theme_repo_import!(remote.strip) rescue Discourse::InvalidAccess render_json_error I18n.t("themes.import_error.not_allowed_theme", { repo: remote.strip }), status: :forbidden return end hijack do begin branch = params[:branch] ? params[:branch] : nil private_key = params[:public_key] ? Discourse.redis.get("ssh_key_#{params[:public_key]}") : nil if params[:public_key].present? && private_key.blank? return render_json_error I18n.t("themes.import_error.ssh_key_gone") end @theme = RemoteTheme.import_theme(remote, theme_user, private_key: private_key, branch: branch) render json: @theme, status: :created rescue RemoteTheme::ImportError => e if params[:force] theme_name = params[:remote].gsub(/.git\z/, "").split("/").last remote_theme = RemoteTheme.new remote_theme.private_key = private_key remote_theme.branch = params[:branch] ? params[:branch] : nil remote_theme.remote_url = params[:remote] remote_theme.save! @theme = Theme.new(user_id: theme_user&.id || -1, name: theme_name) @theme.remote_theme = remote_theme @theme.save! render json: @theme, status: :created else render_json_error e.message end end end elsif params[:bundle] || (params[:theme] && THEME_CONTENT_TYPES.include?(params[:theme].content_type)) ban_in_allowlist_mode! # params[:bundle] used by theme CLI. params[:theme] used by admin UI bundle = params[:bundle] || params[:theme] theme_id = params[:theme_id] update_components = params[:components] run_migrations = !params[:skip_migrations] begin @theme = RemoteTheme.update_zipped_theme( bundle.path, bundle.original_filename, user: theme_user, theme_id:, update_components:, run_migrations:, ) log_theme_change(nil, @theme) render json: @theme, status: :created rescue RemoteTheme::ImportError => e render_json_error e.message end else render_json_error I18n.t("themes.import_error.unknown_file_type"), status: :unprocessable_entity end rescue Theme::SettingsMigrationError => err render_json_error err.message end def index @themes = Theme.include_relations.order(:name) @color_schemes = ColorScheme.all.includes(:theme, color_scheme_colors: :color_scheme).to_a payload = { themes: ActiveModel::ArraySerializer.new(@themes, each_serializer: ThemeSerializer), extras: { color_schemes: ActiveModel::ArraySerializer.new(@color_schemes, each_serializer: ColorSchemeSerializer), }, } respond_to { |format| format.json { render json: payload } } end def create ban_in_allowlist_mode! @theme = Theme.new( name: theme_params[:name], user_id: theme_user.id, user_selectable: theme_params[:user_selectable] || false, color_scheme_id: theme_params[:color_scheme_id], component: [true, "true"].include?(theme_params[:component]), ) set_fields respond_to do |format| if @theme.save update_default_theme log_theme_change(nil, @theme) format.json { render json: @theme, status: :created } else format.json { render json: @theme.errors, status: :unprocessable_entity } end end end def update @theme = Theme.include_relations.find_by(id: params[:id]) raise Discourse::InvalidParameters.new(:id) unless @theme original_json = ThemeSerializer.new(@theme, root: false).to_json disables_component = [false, "false"].include?(theme_params[:enabled]) enables_component = [true, "true"].include?(theme_params[:enabled]) %i[name color_scheme_id user_selectable enabled auto_update].each do |field| @theme.public_send("#{field}=", theme_params[field]) if theme_params.key?(field) end @theme.child_theme_ids = theme_params[:child_theme_ids] if theme_params.key?(:child_theme_ids) @theme.parent_theme_ids = theme_params[:parent_theme_ids] if theme_params.key?( :parent_theme_ids, ) set_fields update_settings update_translations handle_switch @theme.remote_theme.update_remote_version if params[:theme][:remote_check] if params[:theme][:remote_update] @theme.remote_theme.update_from_remote(raise_if_theme_save_fails: false) else @theme.save end respond_to do |format| if @theme.errors.blank? update_default_theme @theme = Theme.include_relations.find(@theme.id) if (!disables_component && !enables_component) || theme_params.keys.size > 1 log_theme_change(original_json, @theme) end log_theme_component_disabled if disables_component log_theme_component_enabled if enables_component format.json { render json: @theme, status: :ok } else format.json do error = @theme.errors.full_messages.join(", ").presence error = I18n.t("themes.bad_color_scheme") if @theme.errors[:color_scheme].present? error ||= I18n.t("themes.other_error") render json: { errors: [error] }, status: :unprocessable_entity end end end rescue RemoteTheme::ImportError => e render_json_error e.message rescue Theme::SettingsMigrationError => e render_json_error e.message end def destroy @theme = Theme.find_by(id: params[:id]) raise Discourse::InvalidParameters.new(:id) unless @theme StaffActionLogger.new(current_user).log_theme_destroy(@theme) @theme.destroy respond_to { |format| format.json { head :no_content } } end def bulk_destroy themes = Theme.where(id: params[:theme_ids]) raise Discourse::InvalidParameters.new(:id) unless themes.present? ActiveRecord::Base.transaction do themes.each { |theme| StaffActionLogger.new(current_user).log_theme_destroy(theme) } themes.destroy_all end respond_to { |format| format.json { head :no_content } } end def show @theme = Theme.include_relations.find_by(id: params[:id]) raise Discourse::InvalidParameters.new(:id) unless @theme render json: ThemeSerializer.new(@theme) end def export @theme = Theme.find_by(id: params[:id]) raise Discourse::InvalidParameters.new(:id) unless @theme exporter = ThemeStore::ZipExporter.new(@theme) file_path = exporter.package_filename headers["Content-Length"] = File.size(file_path).to_s send_data File.read(file_path), filename: File.basename(file_path), content_type: "application/zip" ensure exporter.cleanup! end def get_translations params.require(:locale) unless I18n.available_locales.include?(params[:locale].to_sym) raise Discourse::InvalidParameters.new(:locale) end I18n.locale = params[:locale] @theme = Theme.find_by(id: params[:id]) raise Discourse::InvalidParameters.new(:id) unless @theme translations = @theme.translations.map do |translation| { key: translation.key, value: translation.value, default: translation.default } end render json: { translations: translations }, status: :ok end def update_single_setting params.require("name") @theme = Theme.find_by(id: params[:id]) raise Discourse::InvalidParameters.new(:id) unless @theme setting_name = params[:name].to_sym new_value = params[:value] || nil previous_value = @theme.cached_settings[setting_name] @theme.update_setting(setting_name, new_value) @theme.save log_theme_setting_change(setting_name, previous_value, new_value) updated_setting = @theme.cached_settings.select { |key, val| key == setting_name } render json: updated_setting, status: :ok end def schema raise Discourse::InvalidAccess if !SiteSetting.experimental_objects_type_for_theme_settings end private def ban_in_allowlist_mode! raise Discourse::InvalidAccess if !Theme.allowed_remote_theme_ids.nil? end def ban_for_remote_theme! raise Discourse::InvalidAccess if @theme.remote_theme&.is_git? end def update_default_theme if theme_params.key?(:default) is_default = theme_params[:default].to_s == "true" if @theme.id == SiteSetting.default_theme_id && !is_default Theme.clear_default! elsif is_default @theme.set_default! end end end def theme_params @theme_params ||= begin # deep munge is a train wreck, work around it for now params[:theme][:child_theme_ids] ||= [] if params[:theme].key?(:child_theme_ids) params[:theme][:parent_theme_ids] ||= [] if params[:theme].key?(:parent_theme_ids) params.require(:theme).permit( :name, :color_scheme_id, :default, :user_selectable, :component, :enabled, :auto_update, :locale, settings: { }, translations: { }, theme_fields: %i[name target value upload_id type_id], child_theme_ids: [], parent_theme_ids: [], ) end end def set_fields return unless fields = theme_params[:theme_fields] ban_in_allowlist_mode! ban_for_remote_theme! fields.each do |field| @theme.set_field( target: field[:target], name: field[:name], value: field[:value], type_id: field[:type_id], upload_id: field[:upload_id], ) end end def update_settings return unless target_settings = theme_params[:settings] target_settings.each_pair do |setting_name, new_value| @theme.update_setting(setting_name.to_sym, new_value) end end def update_translations return unless target_translations = theme_params[:translations] locale = theme_params[:locale].presence if locale unless I18n.available_locales.include?(locale.to_sym) raise Discourse::InvalidParameters.new(:locale) end I18n.locale = locale end target_translations.each_pair do |translation_key, new_value| @theme.update_translation(translation_key, new_value) end end def log_theme_change(old_record, new_record) StaffActionLogger.new(current_user).log_theme_change(old_record, new_record) end def log_theme_setting_change(setting_name, previous_value, new_value) StaffActionLogger.new(current_user).log_theme_setting_change( setting_name, previous_value, new_value, @theme, ) end def log_theme_component_disabled StaffActionLogger.new(current_user).log_theme_component_disabled(@theme) end def log_theme_component_enabled StaffActionLogger.new(current_user).log_theme_component_enabled(@theme) end def handle_switch param = theme_params[:component] if param.to_s == "false" && @theme.component? if @theme.id == SiteSetting.default_theme_id raise Discourse::InvalidParameters.new(:component) end @theme.switch_to_theme! elsif param.to_s == "true" && !@theme.component? if @theme.id == SiteSetting.default_theme_id raise Discourse::InvalidParameters.new(:component) end @theme.switch_to_component! end end # Overridden by theme-creator plugin def theme_user current_user end end