diff --git a/.travis.yml b/.travis.yml index 2c4b28cbb8a..6ff906b3d68 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,8 @@ language: ruby +git: + depth: false + branches: only: - master diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 index 38d73d534f6..f572813c8a0 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 @@ -8,7 +8,7 @@ import { THEMES, COMPONENTS } from "admin/models/theme"; const THEME_UPLOAD_VAR = 2; export default Ember.Controller.extend({ - downloadUrl: url("model.id", "/admin/themes/%@"), + downloadUrl: url("model.id", "/admin/customize/themes/%@/export"), previewUrl: url("model.id", "/admin/themes/%@/preview"), addButtonDisabled: Ember.computed.empty("selectedChildThemeId"), editRouteName: "adminCustomizeThemes.edit", @@ -203,7 +203,7 @@ export default Ember.Controller.extend({ }, editTheme() { - if (this.get("model.remote_theme")) { + if (this.get("model.remote_theme.is_git")) { bootbox.confirm( I18n.t("admin.customize.theme.edit_confirm"), result => { diff --git a/app/assets/javascripts/admin/templates/customize-themes-show.hbs b/app/assets/javascripts/admin/templates/customize-themes-show.hbs index 47dab4dda6b..5ca9c901c29 100644 --- a/app/assets/javascripts/admin/templates/customize-themes-show.hbs +++ b/app/assets/javascripts/admin/templates/customize-themes-show.hbs @@ -75,7 +75,7 @@ {{/if}} - {{#if model.remote_theme}} + {{#if model.remote_theme.is_git}} {{#if model.remote_theme.commits_behind}} {{#d-button action=(action "updateToLatest") icon="download" class='btn-primary'}}{{i18n "admin.customize.theme.update_to_latest"}}{{/d-button}} {{else}} @@ -84,7 +84,7 @@ {{/if}} {{#d-button action=(action "editTheme") class="btn btn-default edit"}}{{i18n 'admin.customize.theme.edit_css_html'}}{{/d-button}} - {{#if model.remote_theme}} + {{#if model.remote_theme.is_git}} {{#if updatingRemote}} {{i18n 'admin.customize.theme.updating'}} @@ -111,6 +111,10 @@ {{model.remoteError}} {{/if}} + {{else if model.remote_theme}} + + {{d-icon "info-circle"}} {{i18n "admin.customize.theme.imported_from_archive"}} + {{/if}} diff --git a/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs b/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs index e2526385891..9d7c9a6db0a 100644 --- a/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs +++ b/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs @@ -4,7 +4,7 @@ {{#if local}}
-
+
{{i18n 'admin.customize.theme.import_file_tip'}}
{{/if}} @@ -19,7 +19,7 @@ {{i18n 'admin.customize.theme.import_web_tip'}}
- {{input value=branch placeholder="beta"}} + {{input value=branch placeholder="master"}} {{i18n 'admin.customize.theme.remote_branch'}}
diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb index 45600be14c3..4ee50cfba49 100644 --- a/app/controllers/admin/themes_controller.rb +++ b/app/controllers/admin/themes_controller.rb @@ -1,9 +1,10 @@ require_dependency 'upload_creator' +require_dependency 'theme_store/tgz_exporter' require 'base64' class Admin::ThemesController < Admin::AdminController - skip_before_action :check_xhr, only: [:show, :preview] + skip_before_action :check_xhr, only: [:show, :preview, :export] def preview @theme = Theme.find(params[:id]) @@ -38,7 +39,8 @@ class Admin::ThemesController < Admin::AdminController def import @theme = nil - if params[:theme] + if params[:theme] && params[:theme].content_type == "application/json" + # .dcstyle.json import. Deprecated, but still available to allow conversion json = JSON::parse(params[:theme].read) theme = json['theme'] @@ -79,19 +81,21 @@ class Admin::ThemesController < Admin::AdminController branch = params[:branch] ? params[:branch] : nil @theme = RemoteTheme.import_theme(params[:remote], current_user, private_key: params[:private_key], branch: branch) render json: @theme, status: :created - rescue RuntimeError => e - Discourse.warn_exception(e, message: "Error importing theme") - render_json_error I18n.t('themes.error_importing') + rescue RemoteTheme::ImportError => e + render_json_error e.message end - elsif params[:bundle] + elsif params[:bundle] || params[:theme] && params[:theme].content_type == "application/x-gzip" + # params[:bundle] used by theme CLI. params[:theme] used by admin UI + bundle = params[:bundle] || params[:theme] begin - @theme = RemoteTheme.update_tgz_theme(params[:bundle].path, user: current_user) + @theme = RemoteTheme.update_tgz_theme(bundle.path, match_theme: !!params[:bundle], user: current_user) + log_theme_change(nil, @theme) render json: @theme, status: :created - rescue RuntimeError - render_json_error I18n.t('themes.error_importing') + rescue RemoteTheme::ImportError => e + render_json_error e.message end else - render json: @theme.errors, status: :unprocessable_entity + render_json_error status: :unprocessable_entity end end @@ -217,22 +221,20 @@ class Admin::ThemesController < Admin::AdminController def show @theme = Theme.find(params[:id]) + render json: ThemeSerializer.new(@theme) + end - respond_to do |format| - format.json do - check_xhr - render json: ThemeSerializer.new(@theme) - end - - format.any(:html, :text) do - raise RenderEmpty.new if request.xhr? - - response.headers['Content-Disposition'] = "attachment; filename=#{@theme.name.parameterize}.dcstyle.json" - response.sending_file = true - render json: ::ThemeWithEmbeddedUploadsSerializer.new(@theme, root: 'theme') - end - end + def export + @theme = Theme.find(params[:id]) + exporter = ThemeStore::TgzExporter.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/x-gzip" + ensure + exporter.cleanup! end private diff --git a/app/models/remote_theme.rb b/app/models/remote_theme.rb index 6b2e84de72d..0f1a28af62c 100644 --- a/app/models/remote_theme.rb +++ b/app/models/remote_theme.rb @@ -4,6 +4,8 @@ require_dependency 'upload_creator' class RemoteTheme < ActiveRecord::Base + class ImportError < StandardError; end + ALLOWED_FIELDS = %w{scss embedded_scss head_tag header after_header body_tag footer} GITHUB_REGEXP = /^https?:\/\/github\.com\// @@ -14,15 +16,22 @@ class RemoteTheme < ActiveRecord::Base joins("JOIN themes ON themes.remote_theme_id = remote_themes.id").where.not(remote_url: "") } - def self.update_tgz_theme(filename, user: Discourse.system_user) + 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_tgz_theme(filename, match_theme: false, user: Discourse.system_user) importer = ThemeStore::TgzImporter.new(filename) importer.import! - theme_info = JSON.parse(importer["about.json"]) - - theme = Theme.find_by(name: theme_info["name"]) + theme_info = RemoteTheme.extract_theme_info(importer) + theme = Theme.find_by(name: theme_info["name"]) if match_theme theme ||= Theme.new(user_id: user&.id || -1, name: theme_info["name"]) + theme.component = theme_info["component"].to_s == "true" + remote_theme = new remote_theme.theme = theme remote_theme.remote_url = "" @@ -42,7 +51,7 @@ class RemoteTheme < ActiveRecord::Base importer = ThemeStore::GitImporter.new(url.strip, private_key: private_key, branch: branch) importer.import! - theme_info = JSON.parse(importer["about.json"]) + 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) @@ -74,10 +83,11 @@ class RemoteTheme < ActiveRecord::Base 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 ThemeStore::GitImporter::ImportFailed => err + rescue RemoteTheme::ImportError => err self.last_error_text = err.message else self.updated_at = Time.zone.now @@ -87,7 +97,6 @@ class RemoteTheme < ActiveRecord::Base end def update_from_remote(importer = nil, skip_update: false) - return unless remote_url cleanup = false unless importer @@ -95,7 +104,7 @@ class RemoteTheme < ActiveRecord::Base importer = ThemeStore::GitImporter.new(remote_url, private_key: private_key, branch: branch) begin importer.import! - rescue ThemeStore::GitImporter::ImportFailed => err + rescue RemoteTheme::ImportError => err self.last_error_text = err.message return self else @@ -103,7 +112,7 @@ class RemoteTheme < ActiveRecord::Base end end - theme_info = JSON.parse(importer["about.json"]) + theme_info = RemoteTheme.extract_theme_info(importer) theme_info["assets"]&.each do |name, relative_path| if path = importer.real_path(relative_path) @@ -114,34 +123,15 @@ class RemoteTheme < ActiveRecord::Base end end - Theme.targets.keys.each do |target| - next if target == :settings || target == :translations - ALLOWED_FIELDS.each do |field| - lookup = - if field == "scss" - "#{target}.scss" - elsif field == "embedded_scss" && target == :common - "embedded.scss" - else - "#{field}.html" - end - - value = importer["#{target}/#{lookup}"] - theme.set_field(target: target.to_sym, name: field, value: value) - end - end - - settings_yaml = importer["settings.yaml"] || importer["settings.yml"] - theme.set_field(target: :settings, name: "yaml", value: settings_yaml) - - I18n.available_locales.each do |locale| - value = importer["locales/#{locale}.yml"] - theme.set_field(target: :translations, name: locale, value: value) - end - self.license_url = theme_info["license_url"] self.about_url = theme_info["about_url"] + importer.all_files.each do |filename| + next unless opts = ThemeField.opts_from_file_path(filename) + value = importer[filename] + theme.set_field(opts.merge(value: value)) + end + if !skip_update self.remote_updated_at = Time.zone.now self.remote_version = importer.version @@ -214,6 +204,10 @@ class RemoteTheme < ActiveRecord::Base "https://github.com/#{org_repo}" end end + + def is_git? + remote_url.present? + end end # == Schema Information diff --git a/app/models/theme.rb b/app/models/theme.rb index 7db8dcf2231..45cbcb6882d 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -433,6 +433,28 @@ class Theme < ActiveRecord::Base end hash end + + def generate_metadata_hash + { + name: name, + about_url: remote_theme&.about_url, + license_url: remote_theme&.license_url, + component: component, + assets: {}.tap do |hash| + theme_fields.where(type_id: ThemeField.types[:theme_upload_var]).each do |field| + hash[field.name] = "assets/#{field.upload.original_filename}" + end + end, + color_schemes: {}.tap do |hash| + schemes = self.color_schemes + # The selected color scheme may not belong to the theme, so include it anyway + schemes = [self.color_scheme] + schemes if self.color_scheme + schemes.uniq.each do |scheme| + hash[scheme.name] = {}.tap { |colors| scheme.colors.each { |color| colors[color.name] = color.hex } } + end + end + } + end end # == Schema Information diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb index 5e0da4119ce..d7cd020b08f 100644 --- a/app/models/theme_field.rb +++ b/app/models/theme_field.rb @@ -277,6 +277,89 @@ class ThemeField < ActiveRecord::Base Theme.targets.invert[target_id].to_s end + class ThemeFileMatcher + OPTIONS = %i{name type target} + # regex: used to match file names to fields (import). + # can contain named capture groups for name/type/target + # canonical: a lambda which converts name/type/target + # to filename (export) + # targets/names/types: can be nil if any value is allowed + # single value + # array of allowed values + def initialize(regex:, canonical:, targets:, names:, types:) + @allowed_values = {} + @allowed_values[:names] = Array(names) if names + @allowed_values[:targets] = Array(targets) if targets + @allowed_values[:types] = Array(types) if types + @canonical = canonical + @regex = regex + end + + def opts_from_filename(filename) + match = @regex.match(filename) + return false unless match + hash = {} + OPTIONS.each do |option| + plural = :"#{option}s" + hash[option] = @allowed_values[plural][0] if @allowed_values[plural].length == 1 + hash[option] = match[option] if hash[option].nil? + end + hash + end + + def filename_from_opts(opts) + is_match = OPTIONS.all? do |option| + plural = :"#{option}s" + next true if @allowed_values[plural] == nil # Allows any value + next true if @allowed_values[plural].include?(opts[option]) # Value is allowed + end + is_match ? @canonical.call(opts) : nil + end + end + + FILE_MATCHERS = [ + ThemeFileMatcher.new(regex: /^(?(?:mobile|desktop|common))\/(?(?:head_tag|header|after_header|body_tag|footer))\.html$/, + targets: [:mobile, :desktop, :common], names: ["head_tag", "header", "after_header", "body_tag", "footer"], types: :html, + canonical: -> (h) { "#{h[:target]}/#{h[:name]}.html" }), + ThemeFileMatcher.new(regex: /^(?(?:mobile|desktop|common))\/(?:\k)\.scss$/, + targets: [:mobile, :desktop, :common], names: "scss", types: :scss, + canonical: -> (h) { "#{h[:target]}/#{h[:target]}.scss" }), + ThemeFileMatcher.new(regex: /^common\/embedded\.scss$/, + targets: :common, names: "embedded_scss", types: :scss, + canonical: -> (h) { "common/embedded.scss" }), + ThemeFileMatcher.new(regex: /^settings\.ya?ml$/, + names: "yaml", types: :yaml, targets: :settings, + canonical: -> (h) { "settings.yml" }), + ThemeFileMatcher.new(regex: /^locales\/(?(?:#{I18n.available_locales.join("|")}))\.yml$/, + names: I18n.available_locales.map(&:to_s), types: :yaml, targets: :translations, + canonical: -> (h) { "locales/#{h[:name]}.yml" }), + ThemeFileMatcher.new(regex: /(?!)/, # Never match uploads by filename, they must be named in about.json + names: nil, types: :theme_upload_var, targets: :common, + canonical: -> (h) { "assets/#{h[:filename]}" }), + ] + + # For now just work for standard fields + def file_path + FILE_MATCHERS.each do |matcher| + if filename = matcher.filename_from_opts(target: target_name.to_sym, + name: name, + type: ThemeField.types[type_id], + filename: upload&.original_filename) + return filename + end + end + nil # Not a file (e.g. a theme variable/color) + end + + def self.opts_from_file_path(filename) + FILE_MATCHERS.each do |matcher| + if opts = matcher.opts_from_filename(filename) + return opts + end + end + nil + end + before_save do validate_yaml! diff --git a/app/serializers/theme_serializer.rb b/app/serializers/theme_serializer.rb index 7e4110c72a4..fbd9b8d6ffb 100644 --- a/app/serializers/theme_serializer.rb +++ b/app/serializers/theme_serializer.rb @@ -47,7 +47,7 @@ end class RemoteThemeSerializer < ApplicationSerializer attributes :id, :remote_url, :remote_version, :local_version, :about_url, :license_url, :commits_behind, :remote_updated_at, :updated_at, - :github_diff_link, :last_error_text + :github_diff_link, :last_error_text, :is_git? # wow, AMS has some pretty nutty logic where it tries to find the path here # from action dispatch, tell it not to @@ -103,32 +103,3 @@ class ThemeSerializer < BasicThemeSerializer @errors.present? end end - -class ThemeFieldWithEmbeddedUploadsSerializer < ThemeFieldSerializer - attributes :raw_upload - - def include_raw_upload? - object.upload - end - - def raw_upload - filename = Discourse.store.path_for(object.upload) - raw = nil - - if filename - raw = File.read(filename) - else - raw = Discourse.store.download(object.upload).read - end - - Base64.encode64(raw) - end -end - -class ThemeWithEmbeddedUploadsSerializer < ThemeSerializer - has_many :theme_fields, serializer: ThemeFieldWithEmbeddedUploadsSerializer, embed: :objects - - def include_settings? - false - end -end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 2c227b8fbab..3227ab57ccc 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3382,7 +3382,7 @@ en: edit_css_html_help: "You have not edited any CSS or HTML" delete_upload_confirm: "Delete this upload? (Theme CSS may stop working!)" import_web_tip: "Repository containing theme" - import_file_tip: ".dcstyle.json file containing theme" + import_file_tip: ".tar.gz or .dcstyle.json file containing theme" is_private: "Theme is in a private git repository" remote_branch: "Branch name (optional)" public_key: "Grant the following public key access to the repo:" @@ -3403,6 +3403,7 @@ en: other: "Theme is {{count}} commits behind!" compare_commits: "(See new commits)" repo_unreachable: "Couldn't contact the Git repository of this theme. Error message:" + imported_from_archive: "This theme was imported from a .tar.gz file" scss: text: "CSS" title: "Enter custom CSS, we accept all valid CSS and SCSS styles" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index e812e23a62e..c81a52f0897 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -74,7 +74,11 @@ en: themes: bad_color_scheme: "Can not update theme, invalid color scheme" other_error: "Something went wrong updating theme" - error_importing: "Error cloning git repository, access is denied or repository is not found" + import_error: + generic: An error occured while importing that theme + about_json: "Import Error: about.json does not exist, or is invalid" + git: "Error cloning git repository, access is denied or repository is not found" + unpack_failed: "Failed to unpack file" errors: component_no_user_selectable: "Theme components can't be user-selectable" component_no_default: "Theme components can't be default theme" diff --git a/config/routes.rb b/config/routes.rb index 74c24af19bb..955d0b1c16a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -215,6 +215,7 @@ Discourse::Application.routes.draw do get 'themes/:id/:target/:field_name/edit' => 'themes#index' get 'themes/:id' => 'themes#index' + get "themes/:id/export" => "themes#export" # They have periods in their URLs often: get 'site_texts' => 'site_texts#index' diff --git a/lib/theme_store/git_importer.rb b/lib/theme_store/git_importer.rb index 423ffffe001..f622f70c106 100644 --- a/lib/theme_store/git_importer.rb +++ b/lib/theme_store/git_importer.rb @@ -2,7 +2,6 @@ module ThemeStore; end class ThemeStore::GitImporter - class ImportFailed < StandardError; end attr_reader :url def initialize(url, private_key: nil, branch: nil) @@ -58,6 +57,12 @@ class ThemeStore::GitImporter end end + def all_files + Dir.chdir(@temp_folder) do + Dir.glob("**/*").reject { |f| File.directory?(f) } + end + end + def [](value) fullpath = real_path(value) return nil unless fullpath @@ -73,8 +78,8 @@ class ThemeStore::GitImporter else Discourse::Utils.execute_command("git", "clone", @url, @temp_folder) end - rescue => err - raise ImportFailed.new(err.message) + rescue RuntimeError => err + raise RemoteTheme::ImportError.new(I18n.t("themes.import_error.git")) end end @@ -94,8 +99,8 @@ class ThemeStore::GitImporter else Discourse::Utils.execute_command(git_ssh_command, "git", "clone", @url, @temp_folder) end - rescue => err - raise ImportFailed.new(err.message) + rescue RuntimeError => err + raise RemoteTheme::ImportError.new(I18n.t("themes.import_error.git")) end ensure FileUtils.rm_rf ssh_folder diff --git a/lib/theme_store/tgz_exporter.rb b/lib/theme_store/tgz_exporter.rb new file mode 100644 index 00000000000..29083b79d19 --- /dev/null +++ b/lib/theme_store/tgz_exporter.rb @@ -0,0 +1,62 @@ +module ThemeStore; end + +class ThemeStore::TgzExporter + + def initialize(theme) + @theme = theme + @temp_folder = "#{Pathname.new(Dir.tmpdir).realpath}/discourse_theme_#{SecureRandom.hex}" + @export_name = "discourse-#{@theme.name.downcase.gsub(/[^0-9a-z.\-]/, '-')}-theme" + end + + def package_filename + export_package + end + + def cleanup! + FileUtils.rm_rf(@temp_folder) + end + + private + def export_to_folder + FileUtils.mkdir(@temp_folder) + + Dir.chdir(@temp_folder) do + FileUtils.mkdir(@export_name) + + @theme.theme_fields.each do |field| + next unless path = field.file_path + + # Belt and braces approach here. All the user input should already be + # sanitized, but check for attempts to leave the temp directory anyway + pathname = Pathname.new("#{@export_name}/#{path}") + folder_path = pathname.parent.realdirpath + raise RuntimeError.new("Theme exporter tried to leave directory") unless folder_path.to_s.starts_with?("#{@temp_folder}/#{@export_name}") + folder_path.mkpath + path = pathname.realdirpath + raise RuntimeError.new("Theme exporter tried to leave directory") unless path.to_s.starts_with?("#{@temp_folder}/#{@export_name}") + + if ThemeField.types[field.type_id] == :theme_upload_var + filename = Discourse.store.path_for(field.upload) + content = filename ? File.read(filename) : Discourse.store.download(object.upload).read + else + content = field.value + end + File.write(path, content) + end + + File.write("#{@export_name}/about.json", JSON.pretty_generate(@theme.generate_metadata_hash)) + end + @temp_folder + end + + def export_package + export_to_folder + Dir.chdir(@temp_folder) do + tar_filename = "#{@export_name}.tar" + Discourse::Utils.execute_command('tar', '--create', '--file', tar_filename, @export_name, failure_message: "Failed to tar theme.") + Discourse::Utils.execute_command('gzip', '-5', tar_filename, failure_message: "Failed to gzip archive.") + "#{@temp_folder}/#{tar_filename}.gz" + end + end + +end diff --git a/lib/theme_store/tgz_importer.rb b/lib/theme_store/tgz_importer.rb index 1c651744b9c..fa986de21aa 100644 --- a/lib/theme_store/tgz_importer.rb +++ b/lib/theme_store/tgz_importer.rb @@ -14,6 +14,8 @@ class ThemeStore::TgzImporter Dir.chdir(@temp_folder) do Discourse::Utils.execute_command("tar", "-xzvf", @filename, "--strip", "1") end + rescue RuntimeError + raise RemoteTheme::ImportError, I18n.t("themes.import_error.unpack_failed") end def cleanup! @@ -38,6 +40,12 @@ class ThemeStore::TgzImporter end end + def all_files + Dir.chdir(@temp_folder) do + Dir.glob("**/*").reject { |f| File.directory?(f) } + end + end + def [](value) fullpath = real_path(value) return nil unless fullpath diff --git a/spec/components/theme_store/tgz_exporter_spec.rb b/spec/components/theme_store/tgz_exporter_spec.rb new file mode 100644 index 00000000000..46da6defc13 --- /dev/null +++ b/spec/components/theme_store/tgz_exporter_spec.rb @@ -0,0 +1,106 @@ +require 'rails_helper' +require 'theme_store/tgz_exporter' + +describe ThemeStore::TgzExporter do + let(:theme) do + Fabricate(:theme, name: "Header Icons").tap do |theme| + theme.set_field(target: :common, name: :body_tag, value: "testtheme1") + theme.set_field(target: :settings, name: :yaml, value: "somesetting: test") + theme.set_field(target: :mobile, name: :scss, value: 'body {background-color: $background_color; font-size: $font-size}') + theme.set_field(target: :translations, name: :en, value: { en: { key: "value" } }.deep_stringify_keys.to_yaml) + image = file_from_fixtures("logo.png") + upload = UploadCreator.new(image, "logo.png").create_for(-1) + theme.set_field(target: :common, name: :logo, upload_id: upload.id, type: :theme_upload_var) + theme.build_remote_theme(remote_url: "", about_url: "abouturl", license_url: "licenseurl") + + cs1 = Fabricate(:color_scheme, name: 'Orphan Color Scheme', color_scheme_colors: [ + Fabricate(:color_scheme_color, name: 'header_primary', hex: 'F0F0F0'), + Fabricate(:color_scheme_color, name: 'header_background', hex: '1E1E1E'), + Fabricate(:color_scheme_color, name: 'tertiary', hex: '858585') + ]) + + cs2 = Fabricate(:color_scheme, name: 'Theme Color Scheme', color_scheme_colors: [ + Fabricate(:color_scheme_color, name: 'header_primary', hex: 'F0F0F0'), + Fabricate(:color_scheme_color, name: 'header_background', hex: '1E1E1E'), + Fabricate(:color_scheme_color, name: 'tertiary', hex: '858585') + ]) + + theme.color_scheme = cs1 + cs2.update(theme_id: theme.id) + + theme.save! + end + end + + let(:dir) do + tmpdir = Dir.tmpdir + dir = "#{tmpdir}/#{SecureRandom.hex}" + FileUtils.mkdir(dir) + dir + end + + after do + FileUtils.rm_rf(dir) + end + + let(:package) do + exporter = ThemeStore::TgzExporter.new(theme) + filename = exporter.package_filename + FileUtils.cp(filename, dir) + exporter.cleanup! + "#{dir}/discourse-header-icons-theme.tar.gz" + end + + it "exports the theme correctly" do + package + Dir.chdir("#{dir}") do + `tar -xzf discourse-header-icons-theme.tar.gz` + end + Dir.chdir("#{dir}/discourse-header-icons-theme") do + folders = Dir.glob("**/*").reject { |f| File.file?(f) } + expect(folders).to contain_exactly("assets", "common", "locales", "mobile") + + files = Dir.glob("**/*").reject { |f| File.directory?(f) } + expect(files).to contain_exactly("about.json", "assets/logo.png", "common/body_tag.html", "locales/en.yml", "mobile/mobile.scss", "settings.yml") + + expect(JSON.parse(File.read('about.json')).deep_symbolize_keys).to eq( + "name": "Header Icons", + "about_url": "abouturl", + "license_url": "licenseurl", + "component": false, + "assets": { + "logo": "assets/logo.png" + }, + "color_schemes": { + "Orphan Color Scheme": { + "header_primary": "F0F0F0", + "header_background": "1E1E1E", + "tertiary": "858585" + }, + "Theme Color Scheme": { + "header_primary": "F0F0F0", + "header_background": "1E1E1E", + "tertiary": "858585" + } + } + ) + + expect(File.read("common/body_tag.html")).to eq("testtheme1") + expect(File.read("mobile/mobile.scss")).to eq("body {background-color: $background_color; font-size: $font-size}") + expect(File.read("settings.yml")).to eq("somesetting: test") + expect(File.read("locales/en.yml")).to eq({ en: { key: "value" } }.deep_stringify_keys.to_yaml) + end + end + + it "has safeguards to prevent writing outside the temp directory" do + # Theme field names should be sanitized before writing to the database, + # but protection is in place 'just in case' + expect do + theme.set_field(target: :translations, name: "en", value: "hacked") + theme.theme_fields[0].stubs(:file_path).returns("../../malicious") + theme.save! + package + end.to raise_error(RuntimeError) + end + +end diff --git a/spec/fixtures/themes/discourse-test-theme.tar.gz b/spec/fixtures/themes/discourse-test-theme.tar.gz new file mode 100644 index 00000000000..1a85018ddd2 Binary files /dev/null and b/spec/fixtures/themes/discourse-test-theme.tar.gz differ diff --git a/spec/requests/admin/themes_controller_spec.rb b/spec/requests/admin/themes_controller_spec.rb index cdfbe248c3f..55204bf3705 100644 --- a/spec/requests/admin/themes_controller_spec.rb +++ b/spec/requests/admin/themes_controller_spec.rb @@ -38,44 +38,48 @@ describe Admin::ThemesController do end end + describe '#export' do + it "exports correctly" do + theme = Fabricate(:theme, name: "Awesome Theme") + theme.set_field(target: :common, name: :scss, value: '.body{color: black;}') + theme.set_field(target: :desktop, name: :after_header, value: 'test') + theme.save! + + get "/admin/customize/themes/#{theme.id}/export" + expect(response.status).to eq(200) + + # Save the output in a temp file (automatically cleaned up) + file = Tempfile.new('archive.tar.gz') + file.write(response.body) + file.rewind + uploaded_file = Rack::Test::UploadedFile.new(file.path, "application/x-gzip") + + # Now import it again + expect do + post "/admin/themes/import.json", params: { theme: uploaded_file } + expect(response.status).to eq(201) + end.to change { Theme.count }.by (1) + + json = ::JSON.parse(response.body) + + expect(json["theme"]["name"]).to eq("Awesome Theme") + expect(json["theme"]["theme_fields"].length).to eq(2) + end + end + describe '#import' do - let(:theme_file) do - Rack::Test::UploadedFile.new(file_from_fixtures("sam-s-simple-theme.dcstyle.json", "json")) + let(:theme_json_file) do + Rack::Test::UploadedFile.new(file_from_fixtures("sam-s-simple-theme.dcstyle.json", "json"), "application/json") + end + + let(:theme_archive) do + Rack::Test::UploadedFile.new(file_from_fixtures("discourse-test-theme.tar.gz", "themes"), "application/x-gzip") end let(:image) do file_from_fixtures("logo.png") end - it 'can import a theme with an upload' do - upload = Fabricate(:upload) - theme = Fabricate(:theme) - upload = UploadCreator.new(image, "logo.png").create_for(-1) - theme.set_field(target: :common, name: :logo, upload_id: upload.id, type: :theme_upload_var) - theme.save! - - json = ThemeWithEmbeddedUploadsSerializer.new(theme, root: 'theme').to_json - theme.destroy - - temp = Tempfile.new - temp.write(json) - temp.rewind - - uploaded_json = Rack::Test::UploadedFile.new(temp) - upload.destroy - - post "/admin/themes/import.json", params: { theme: uploaded_json } - expect(response.status).to eq(201) - temp.unlink - - theme = Theme.last - expect(theme.theme_fields.count).to eq(1) - expect(theme.theme_fields.first.upload).not_to eq(nil) - expect(theme.theme_fields.first.upload.filesize).to eq(upload.filesize) - expect(theme.theme_fields.first.upload.sha1).to eq(upload.sha1) - expect(theme.theme_fields.first.upload.original_filename).to eq(upload.original_filename) - end - it 'can import a theme from Git' do post "/admin/themes/import.json", params: { remote: ' https://github.com/discourse/discourse-brand-header ' @@ -85,7 +89,7 @@ describe Admin::ThemesController do end it 'imports a theme' do - post "/admin/themes/import.json", params: { theme: theme_file } + post "/admin/themes/import.json", params: { theme: theme_json_file } expect(response.status).to eq(201) json = ::JSON.parse(response.body) @@ -94,6 +98,34 @@ describe Admin::ThemesController do expect(json["theme"]["theme_fields"].length).to eq(2) expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) end + + it 'imports a theme from an archive' do + existing_theme = Fabricate(:theme, name: "Header Icons") + + expect do + post "/admin/themes/import.json", params: { theme: theme_archive } + end.to change { Theme.count }.by (1) + expect(response.status).to eq(201) + json = ::JSON.parse(response.body) + + expect(json["theme"]["name"]).to eq("Header Icons") + expect(json["theme"]["theme_fields"].length).to eq(5) + expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) + end + + it 'updates an existing theme from an archive' do + existing_theme = Fabricate(:theme, name: "Header Icons") + + expect do + post "/admin/themes/import.json", params: { bundle: theme_archive } + end.to change { Theme.count }.by (0) + expect(response.status).to eq(201) + json = ::JSON.parse(response.body) + + expect(json["theme"]["name"]).to eq("Header Icons") + expect(json["theme"]["theme_fields"].length).to eq(5) + expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) + end end describe '#index' do