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 @@
+
{{/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