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.
This commit is contained in:
Sam 2018-03-09 16:14:21 +11:00
parent 200c6673f1
commit 39e679d3cb
13 changed files with 145 additions and 10 deletions

View File

@ -178,6 +178,8 @@ gem 'sassc', require: false
gem 'rotp'
gem 'rqrcode'
gem 'sshkey', require: false
if ENV["IMPORT"] == "1"
gem 'mysql2'
gem 'redcarpet'

View File

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

View File

@ -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));
}
}

View File

@ -16,7 +16,24 @@
<div class="inputs">
{{input value=uploadUrl placeholder="https://github.com/discourse/discourse/sample_theme"}}
<span class="description">{{i18n 'admin.customize.theme.import_web_tip'}}</span>
{{#if checkPrivate}}
<div class='check-private'>
<label>
{{input type="checkbox" checked=privateChecked}}
{{i18n 'admin.customize.theme.is_private'}}
</label>
{{#if privateChecked}}
{{#if publicKey}}
<div class='public-key'>
{{i18n 'admin.customize.theme.public_key'}}
{{textarea disabled=true value=publicKey}}
</div>
{{/if}}
{{/if}}
</div>
{{/if}}
</div>
{{/if}}
</div>
{{/d-modal-body}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
class AddPrivateKeyToRemoteTheme < ActiveRecord::Migration[5.1]
def change
add_column :remote_themes, :private_key, :text
end
end

View File

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

View File

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