From 39e679d3cb208755231c99d8b5a56f077e9a256f Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 9 Mar 2018 16:14:21 +1100 Subject: [PATCH] FEATURE: allow themes to live in private git repos This feature allows themes sourced from git to live on private servers, it automatically generates key pairs. --- Gemfile | 2 ++ Gemfile.lock | 2 ++ .../modals/admin-import-theme.js.es6 | 36 +++++++++++++++++-- .../templates/modal/admin-import-theme.hbs | 17 +++++++++ .../stylesheets/common/admin/customize.scss | 20 +++++++++++ app/controllers/admin/themes_controller.rb | 18 ++++++++-- app/models/remote_theme.rb | 10 +++--- config/locales/client.en.yml | 2 ++ config/locales/server.en.yml | 2 ++ config/routes.rb | 1 + ...9014014_add_private_key_to_remote_theme.rb | 5 +++ lib/git_importer.rb | 31 ++++++++++++++-- .../admin/themes_controller_spec.rb | 9 +++++ 13 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 db/migrate/20180309014014_add_private_key_to_remote_theme.rb diff --git a/Gemfile b/Gemfile index d7ed141f272..bc0f206b24d 100644 --- a/Gemfile +++ b/Gemfile @@ -178,6 +178,8 @@ gem 'sassc', require: false gem 'rotp' gem 'rqrcode' +gem 'sshkey', require: false + if ENV["IMPORT"] == "1" gem 'mysql2' gem 'redcarpet' diff --git a/Gemfile.lock b/Gemfile.lock index 1b81cc2495e..f58f32f39ed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -372,6 +372,7 @@ GEM actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) + sshkey (1.9.0) stackprof (0.2.10) thor (0.19.4) thread_safe (0.3.6) @@ -497,6 +498,7 @@ DEPENDENCIES shoulda sidekiq sprockets-rails + sshkey stackprof thor tilt diff --git a/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6 index d971a555bca..950df15a841 100644 --- a/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6 +++ b/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6 @@ -1,6 +1,7 @@ import ModalFunctionality from 'discourse/mixins/modal-functionality'; import { ajax } from 'discourse/lib/ajax'; import { popupAjaxError } from 'discourse/lib/ajax-error'; +import { observes } from 'ember-addons/ember-computed-decorators'; export default Ember.Controller.extend(ModalFunctionality, { local: Ember.computed.equal('selection', 'local'), @@ -9,6 +10,25 @@ export default Ember.Controller.extend(ModalFunctionality, { adminCustomizeThemes: Ember.inject.controller(), loading: false, + checkPrivate: Ember.computed.match('uploadUrl', /^git/), + + @observes('privateChecked') + privateWasChecked() { + const checked = this.get('privateChecked'); + if (checked && !this._keyLoading) { + this._keyLoading = true; + ajax('/admin/themes/generate_key_pair', {method: 'POST'}) + .then(pair => { + this.set('privateKey', pair.private_key); + this.set('publicKey', pair.public_key); + }) + .catch(popupAjaxError) + .finally(()=>{ + this._keyLoading = false; + }); + } + }, + actions: { importTheme() { @@ -22,7 +42,13 @@ export default Ember.Controller.extend(ModalFunctionality, { options.data = new FormData(); options.data.append('theme', $('#file-input')[0].files[0]); } else { - options.data = {remote: this.get('uploadUrl')}; + options.data = { + remote: this.get('uploadUrl') + }; + + if (this.get('privateChecked')){ + options.data.private_key = this.get('privateKey'); + } } this.set('loading', true); @@ -30,7 +56,13 @@ export default Ember.Controller.extend(ModalFunctionality, { const theme = this.store.createRecord('theme',result.theme); this.get('adminCustomizeThemes').send('addTheme', theme); this.send('closeModal'); - }).catch(popupAjaxError).finally(() => this.set('loading', false)); + }) + .then(()=>{ + this.set('privateKey', null); + this.set('publicKey', null); + }) + .catch(popupAjaxError) + .finally(() => this.set('loading', false)); } } 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 19d7065db61..6263ca6e0e8 100644 --- a/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs +++ b/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs @@ -16,7 +16,24 @@
{{input value=uploadUrl placeholder="https://github.com/discourse/discourse/sample_theme"}} {{i18n 'admin.customize.theme.import_web_tip'}} + {{#if checkPrivate}} +
+ + {{#if privateChecked}} + {{#if publicKey}} +
+ {{i18n 'admin.customize.theme.public_key'}} + {{textarea disabled=true value=publicKey}} +
+ {{/if}} + {{/if}} +
+ {{/if}}
+ {{/if}} {{/d-modal-body}} diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss index 8ba862a92aa..453af75209e 100644 --- a/app/assets/stylesheets/common/admin/customize.scss +++ b/app/assets/stylesheets/common/admin/customize.scss @@ -239,3 +239,23 @@ #custom_emoji { width: 27%; } + +.modal-body .inputs .check-private { + margin-top: 10px; + label { + padding-left: 0; + } + label input { + width: auto; + margin: 3px 0; + } + + .public-key { + margin-top: 10px; + textarea { + cursor: auto; + height: 150px; + } + } +} + diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb index 3013c1c2844..5d76f9f97b8 100644 --- a/app/controllers/admin/themes_controller.rb +++ b/app/controllers/admin/themes_controller.rb @@ -27,6 +27,16 @@ class Admin::ThemesController < Admin::AdminController end end + def generate_key_pair + require 'sshkey' + k = SSHKey.generate + + render json: { + private_key: k.private_key, + public_key: k.ssh_public_key + } + end + def import @theme = nil if params[:theme] @@ -66,8 +76,12 @@ class Admin::ThemesController < Admin::AdminController render json: @theme.errors, status: :unprocessable_entity end elsif params[:remote] - @theme = RemoteTheme.import_theme(params[:remote]) - render json: @theme, status: :created + begin + @theme = RemoteTheme.import_theme(params[:remote], current_user, private_key: params[:private_key]) + render json: @theme, status: :created + rescue RuntimeError + render_json_error I18n.t('errors.theme.other') + 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 9d119327448..d2bad885253 100644 --- a/app/models/remote_theme.rb +++ b/app/models/remote_theme.rb @@ -7,8 +7,8 @@ class RemoteTheme < ActiveRecord::Base has_one :theme - def self.import_theme(url, user = Discourse.system_user) - importer = GitImporter.new(url) + def self.import_theme(url, user = Discourse.system_user, private_key: nil) + importer = GitImporter.new(url, private_key: private_key) importer.import! theme_info = JSON.parse(importer["about.json"]) @@ -17,6 +17,7 @@ class RemoteTheme < ActiveRecord::Base remote_theme = new theme.remote_theme = remote_theme + remote_theme.private_key = private_key remote_theme.remote_url = importer.url remote_theme.update_from_remote(importer) @@ -31,7 +32,7 @@ class RemoteTheme < ActiveRecord::Base end def update_remote_version - importer = GitImporter.new(remote_url) + importer = 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) @@ -43,7 +44,7 @@ class RemoteTheme < ActiveRecord::Base unless importer cleanup = true - importer = GitImporter.new(remote_url) + importer = GitImporter.new(remote_url, private_key: private_key) importer.import! end @@ -162,4 +163,5 @@ end # remote_updated_at :datetime # created_at :datetime not null # updated_at :datetime not null +# private_key :text # diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 0f151c9289c..292d9989862 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3068,6 +3068,8 @@ en: 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" + is_private: "Theme is in a private git repository" + public_key: "Grant the following public key access to the repo:" about_theme: "About Theme" license: "License" component_of: "Theme is a component of:" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index ab0e4d714bd..6056f19a12b 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -151,6 +151,8 @@ en: other: ! '%{count} errors prohibited this %{model} from being saved' embed: load_from_remote: "There was an error loading that post." + theme: + other: "Error cloning git repository, access is denied or repository is not found" site_settings: min_username_length_exists: "You cannot set the minimum username length above the shortest username." min_username_length_range: "You cannot set the minimum above the maximum." diff --git a/config/routes.rb b/config/routes.rb index de27e9079aa..ee175fc8592 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -201,6 +201,7 @@ Discourse::Application.routes.draw do post "themes/import" => "themes#import" post "themes/upload_asset" => "themes#upload_asset" + post "themes/generate_key_pair" => "themes#generate_key_pair" get "themes/:id/preview" => "themes#preview" scope "/customize", constraints: AdminConstraint.new do diff --git a/db/migrate/20180309014014_add_private_key_to_remote_theme.rb b/db/migrate/20180309014014_add_private_key_to_remote_theme.rb new file mode 100644 index 00000000000..81dc798a09b --- /dev/null +++ b/db/migrate/20180309014014_add_private_key_to_remote_theme.rb @@ -0,0 +1,5 @@ +class AddPrivateKeyToRemoteTheme < ActiveRecord::Migration[5.1] + def change + add_column :remote_themes, :private_key, :text + end +end diff --git a/lib/git_importer.rb b/lib/git_importer.rb index c54ec2cd02c..55bf47e826b 100644 --- a/lib/git_importer.rb +++ b/lib/git_importer.rb @@ -2,16 +2,21 @@ class GitImporter attr_reader :url - def initialize(url) + def initialize(url, private_key: nil) @url = url if @url.start_with?("https://github.com") && !@url.end_with?(".git") @url += ".git" end @temp_folder = "#{Pathname.new(Dir.tmpdir).realpath}/discourse_theme_#{SecureRandom.hex}" + @private_key = private_key end def import! - Discourse::Utils.execute_command("git", "clone", @url, @temp_folder) + if @private_key + import_private! + else + import_public! + end end def commits_since(hash) @@ -55,4 +60,26 @@ class GitImporter File.read(fullpath) end + protected + + def import_public! + Discourse::Utils.execute_command("git", "clone", @url, @temp_folder) + end + + def import_private! + ssh_folder = "#{Pathname.new(Dir.tmpdir).realpath}/discourse_theme_ssh_#{SecureRandom.hex}" + FileUtils.mkdir_p ssh_folder + + Dir.chdir(ssh_folder) do + File.write('id_rsa', @private_key.strip) + FileUtils.chmod(0600, 'id_rsa') + end + + Discourse::Utils.execute_command({ + 'GIT_SSH_COMMAND' => "ssh -i #{ssh_folder}/id_rsa" + }, "git", "clone", @url, @temp_folder) + ensure + FileUtils.rm_rf ssh_folder + end + end diff --git a/spec/controllers/admin/themes_controller_spec.rb b/spec/controllers/admin/themes_controller_spec.rb index 1418f165a8f..c2c00b78df3 100644 --- a/spec/controllers/admin/themes_controller_spec.rb +++ b/spec/controllers/admin/themes_controller_spec.rb @@ -12,6 +12,15 @@ describe Admin::ThemesController do @user = log_in(:admin) end + context '.generate_key_pair' do + it 'can generate key pairs' do + post :generate_key_pair, format: :json + json = JSON.parse(response.body) + expect(json["private_key"]).to include("RSA PRIVATE KEY") + expect(json["public_key"]).to include("ssh-rsa ") + end + end + context '.upload_asset' do render_views