diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb index df533989c5c..77fb2009d1c 100644 --- a/app/controllers/admin/themes_controller.rb +++ b/app/controllers/admin/themes_controller.rb @@ -82,6 +82,13 @@ class Admin::ThemesController < Admin::AdminController rescue RuntimeError render_json_error I18n.t('themes.error_importing') end + elsif params[:bundle] + begin + @theme = RemoteTheme.update_tgz_theme(params[:bundle].path, user: current_user) + render json: @theme, status: :created + rescue RuntimeError + render_json_error I18n.t('themes.error_importing') + end else render json: @theme.errors, status: :unprocessable_entity end diff --git a/app/models/remote_theme.rb b/app/models/remote_theme.rb index d2bad885253..664b23ff99a 100644 --- a/app/models/remote_theme.rb +++ b/app/models/remote_theme.rb @@ -1,4 +1,5 @@ -require_dependency 'git_importer' +require_dependency 'theme_store/git_importer' +require_dependency 'theme_store/tgz_importer' require_dependency 'upload_creator' class RemoteTheme < ActiveRecord::Base @@ -7,8 +8,32 @@ class RemoteTheme < ActiveRecord::Base has_one :theme + def self.update_tgz_theme(filename, 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 ||= Theme.new(user_id: user&.id || -1, name: theme_info["name"]) + + remote_theme = new + remote_theme.theme = theme + remote_theme.remote_url = "" + remote_theme.update_from_remote(importer, skip_update: true) + + theme.save! + theme + ensure + begin + importer.cleanup! + rescue => e + Rails.logger.warn("Failed cleanup remote path #{e}") + end + end + def self.import_theme(url, user = Discourse.system_user, private_key: nil) - importer = GitImporter.new(url, private_key: private_key) + importer = ThemeStore::GitImporter.new(url, private_key: private_key) importer.import! theme_info = JSON.parse(importer["about.json"]) @@ -32,19 +57,19 @@ class RemoteTheme < ActiveRecord::Base end def update_remote_version - importer = GitImporter.new(remote_url, private_key: private_key) + importer = ThemeStore::GitImporter.new(remote_url, private_key: private_key) importer.import! self.updated_at = Time.zone.now self.remote_version, self.commits_behind = importer.commits_since(remote_version) end - def update_from_remote(importer = nil) + def update_from_remote(importer = nil, skip_update: false) return unless remote_url cleanup = false unless importer cleanup = true - importer = GitImporter.new(remote_url, private_key: private_key) + importer = ThemeStore::GitImporter.new(remote_url, private_key: private_key) importer.import! end @@ -99,10 +124,13 @@ class RemoteTheme < ActiveRecord::Base self.license_url ||= theme_info["license_url"] self.about_url ||= theme_info["about_url"] - self.remote_updated_at = Time.zone.now - self.remote_version = importer.version - self.local_version = importer.version - self.commits_behind = 0 + + if !skip_update + self.remote_updated_at = Time.zone.now + self.remote_version = importer.version + self.local_version = importer.version + self.commits_behind = 0 + end update_theme_color_schemes(theme, theme_info["color_schemes"]) diff --git a/lib/git_importer.rb b/lib/theme_store/git_importer.rb similarity index 97% rename from lib/git_importer.rb rename to lib/theme_store/git_importer.rb index 55bf47e826b..d65d84e9a6c 100644 --- a/lib/git_importer.rb +++ b/lib/theme_store/git_importer.rb @@ -1,4 +1,6 @@ -class GitImporter +module ThemeStore; end + +class ThemeStore::GitImporter attr_reader :url diff --git a/lib/theme_store/tgz_importer.rb b/lib/theme_store/tgz_importer.rb new file mode 100644 index 00000000000..1c651744b9c --- /dev/null +++ b/lib/theme_store/tgz_importer.rb @@ -0,0 +1,47 @@ +module ThemeStore; end + +class ThemeStore::TgzImporter + + attr_reader :url + + def initialize(filename) + @temp_folder = "#{Pathname.new(Dir.tmpdir).realpath}/discourse_theme_#{SecureRandom.hex}" + @filename = filename + end + + def import! + FileUtils.mkdir(@temp_folder) + Dir.chdir(@temp_folder) do + Discourse::Utils.execute_command("tar", "-xzvf", @filename, "--strip", "1") + end + end + + def cleanup! + FileUtils.rm_rf(@temp_folder) + end + + def version + "" + end + + def real_path(relative) + fullpath = "#{@temp_folder}/#{relative}" + return nil unless File.exist?(fullpath) + + # careful to handle symlinks here, don't want to expose random data + fullpath = Pathname.new(fullpath).realpath.to_s + + if fullpath && fullpath.start_with?(@temp_folder) + fullpath + else + nil + end + end + + def [](value) + fullpath = real_path(value) + return nil unless fullpath + File.read(fullpath) + end + +end diff --git a/script/theme-watcher b/script/theme-watcher new file mode 100755 index 00000000000..4d5f1da5a22 --- /dev/null +++ b/script/theme-watcher @@ -0,0 +1,85 @@ +#!/usr/bin/env ruby + +require 'fileutils' +require 'pathname' +require 'tempfile' +require 'securerandom' +require 'minitar' +require 'zlib' +require 'find' +require 'net/http' +require 'net/http/post/multipart' +require 'uri' +require 'listen' + +# Work in progress theme watcher for Discourse +# +# Monitor a theme directory locally and automatically keep it in sync with Discourse + +def usage + puts "Usage: theme-watcher DIR SITE" + exit 1 +end + +$api_key = ENV['DISCOURSE_API_KEY'] +$dir = ARGV[1] +$site = ARGV[2] + +if !$api_key + puts "No API key found in DISCOURSE_API_KEY env var enter your API key: " + $api_key = gets +end + +if !File.exist?("#{$dir}/about.json") + puts "No about.json file found in #{dir}!" + puts + usage +end + +def compress_dir(gzip, dir) + sgz = Zlib::GzipWriter.new(File.open(gzip, 'wb')) + tar = Archive::Tar::Minitar::Output.new(sgz) + + Dir.chdir(dir + "/../") do + Find.find(File.basename(dir)) do |x| + Find.prune if File.basename(x)[0] == ?. + next if File.directory?(x) + + Minitar.pack_file(x, tar) + end + end +ensure + tar.close + sgz.close +end + +def upload_full_theme(dir, site) + filename = "#{Pathname.new(Dir.tmpdir).realpath}/bundle_#{SecureRandom.hex}.tar.gz" + compress_dir(filename, dir) + + # new full upload endpoint + uri = URI.parse(site + "/admin/themes/import.json?api_key=#{$api_key}") + http = Net::HTTP.new(uri.host, uri.port) + File.open(filename) do |tgz| + + request = Net::HTTP::Post::Multipart.new( + uri.request_uri, + "bundle" => UploadIO.new(tgz, "application/tar+gzip", "bundle.tar.gz"), + ) + response = http.request(request) + p response.code + end + +ensure + FileUtils.rm_f filename +end + +upload_full_theme($dir, $site) + +listener = Listen.to($dir) do |modified, added, removed| + puts "Change detected" + upload_full_theme($dir, $site) +end + +listener.start +sleep diff --git a/spec/components/theme_store/tgz_importer_spec.rb b/spec/components/theme_store/tgz_importer_spec.rb new file mode 100644 index 00000000000..693074a1b2c --- /dev/null +++ b/spec/components/theme_store/tgz_importer_spec.rb @@ -0,0 +1,37 @@ + +# encoding: utf-8 + +require 'rails_helper' +require 'theme_store/tgz_importer' + +describe ThemeStore::TgzImporter do + before do + @temp_folder = "#{Pathname.new(Dir.tmpdir).realpath}/discourse_theme_#{SecureRandom.hex}" + end + + after do + FileUtils.rm_rf @temp_folder + end + + it "can import a simple theme" do + + FileUtils.mkdir(@temp_folder) + + Dir.chdir(@temp_folder) do + FileUtils.mkdir('test/') + File.write("test/hello.txt", "hello world") + FileUtils.mkdir('test/a') + File.write("test/a/inner", "hello world inner") + + `tar -cvzf test.tar.gz test/*` + end + + importer = ThemeStore::TgzImporter.new("#{@temp_folder}/test.tar.gz") + importer.import! + + expect(importer["hello.txt"]).to eq("hello world") + expect(importer["a/inner"]).to eq("hello world inner") + + importer.cleanup! + end +end