mirror of
https://github.com/discourse/discourse.git
synced 2025-01-22 13:43:17 +08:00
FEATURE: Import and export themes in a .tar.gz format (#6916)
This commit is contained in:
parent
d0129b85f4
commit
afd449089f
|
@ -1,5 +1,8 @@
|
|||
language: ruby
|
||||
|
||||
git:
|
||||
depth: false
|
||||
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
62
lib/theme_store/tgz_exporter.rb
Normal file
62
lib/theme_store/tgz_exporter.rb
Normal 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
|
|
@ -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
|
||||
|
|
106
spec/components/theme_store/tgz_exporter_spec.rb
Normal file
106
spec/components/theme_store/tgz_exporter_spec.rb
Normal 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
|
BIN
spec/fixtures/themes/discourse-test-theme.tar.gz
vendored
Normal file
BIN
spec/fixtures/themes/discourse-test-theme.tar.gz
vendored
Normal file
Binary file not shown.
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue
Block a user