FEATURE: Import and export themes in a .tar.gz format (#6916)

This commit is contained in:
David Taylor 2019-01-23 14:40:21 +00:00 committed by GitHub
parent d0129b85f4
commit afd449089f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 431 additions and 133 deletions

View File

@ -1,5 +1,8 @@
language: ruby
git:
depth: false
branches:
only:
- master

View File

@ -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 => {

View File

@ -75,7 +75,7 @@
</div>
{{/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}}
<span class='status-message'>
{{#if updatingRemote}}
{{i18n 'admin.customize.theme.updating'}}
@ -111,6 +111,10 @@
<code>{{model.remoteError}}</code>
</div>
{{/if}}
{{else if model.remote_theme}}
<span class='status-message'>
{{d-icon "info-circle"}} {{i18n "admin.customize.theme.imported_from_archive"}}
</span>
{{/if}}
</div>

View File

@ -4,7 +4,7 @@
<label class="radio" for="local">{{i18n 'upload_selector.from_my_computer'}}</label>
{{#if local}}
<div class="inputs">
<input onchange={{action "uploadLocaleFile"}} type="file" id="file-input" accept='.dcstyle.json,application/json'><br>
<input onchange={{action "uploadLocaleFile"}} type="file" id="file-input" accept='.dcstyle.json,application/json,.tar.gz,application/x-gzip'><br>
<span class="description">{{i18n 'admin.customize.theme.import_file_tip'}}</span>
</div>
{{/if}}
@ -19,7 +19,7 @@
<span class="description">{{i18n 'admin.customize.theme.import_web_tip'}}</span>
</div>
<div class='branch'>
{{input value=branch placeholder="beta"}}
{{input value=branch placeholder="master"}}
<span class="description">{{i18n 'admin.customize.theme.remote_branch'}}</span>
</div>
<div class='check-private'>

View File

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

View File

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

View File

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

View File

@ -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: /^(?<target>(?:mobile|desktop|common))\/(?<name>(?: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: /^(?<target>(?:mobile|desktop|common))\/(?:\k<target>)\.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\/(?<name>(?:#{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!

View File

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

View File

@ -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"

View File

@ -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"

View File

@ -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'

View File

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

View File

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

View File

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

View File

@ -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: "<b>testtheme1</b>")
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("<b>testtheme1</b>")
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

Binary file not shown.

View File

@ -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: '<b>test</b>')
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