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 'rotp'
gem 'rqrcode' gem 'rqrcode'
gem 'sshkey', require: false
if ENV["IMPORT"] == "1" if ENV["IMPORT"] == "1"
gem 'mysql2' gem 'mysql2'
gem 'redcarpet' gem 'redcarpet'

View File

@ -372,6 +372,7 @@ GEM
actionpack (>= 4.0) actionpack (>= 4.0)
activesupport (>= 4.0) activesupport (>= 4.0)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
sshkey (1.9.0)
stackprof (0.2.10) stackprof (0.2.10)
thor (0.19.4) thor (0.19.4)
thread_safe (0.3.6) thread_safe (0.3.6)
@ -497,6 +498,7 @@ DEPENDENCIES
shoulda shoulda
sidekiq sidekiq
sprockets-rails sprockets-rails
sshkey
stackprof stackprof
thor thor
tilt tilt

View File

@ -1,6 +1,7 @@
import ModalFunctionality from 'discourse/mixins/modal-functionality'; import ModalFunctionality from 'discourse/mixins/modal-functionality';
import { ajax } from 'discourse/lib/ajax'; import { ajax } from 'discourse/lib/ajax';
import { popupAjaxError } from 'discourse/lib/ajax-error'; import { popupAjaxError } from 'discourse/lib/ajax-error';
import { observes } from 'ember-addons/ember-computed-decorators';
export default Ember.Controller.extend(ModalFunctionality, { export default Ember.Controller.extend(ModalFunctionality, {
local: Ember.computed.equal('selection', 'local'), local: Ember.computed.equal('selection', 'local'),
@ -9,6 +10,25 @@ export default Ember.Controller.extend(ModalFunctionality, {
adminCustomizeThemes: Ember.inject.controller(), adminCustomizeThemes: Ember.inject.controller(),
loading: false, 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: { actions: {
importTheme() { importTheme() {
@ -22,7 +42,13 @@ export default Ember.Controller.extend(ModalFunctionality, {
options.data = new FormData(); options.data = new FormData();
options.data.append('theme', $('#file-input')[0].files[0]); options.data.append('theme', $('#file-input')[0].files[0]);
} else { } 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); this.set('loading', true);
@ -30,7 +56,13 @@ export default Ember.Controller.extend(ModalFunctionality, {
const theme = this.store.createRecord('theme',result.theme); const theme = this.store.createRecord('theme',result.theme);
this.get('adminCustomizeThemes').send('addTheme', theme); this.get('adminCustomizeThemes').send('addTheme', theme);
this.send('closeModal'); 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"> <div class="inputs">
{{input value=uploadUrl placeholder="https://github.com/discourse/discourse/sample_theme"}} {{input value=uploadUrl placeholder="https://github.com/discourse/discourse/sample_theme"}}
<span class="description">{{i18n 'admin.customize.theme.import_web_tip'}}</span> <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> </div>
{{/if}} {{/if}}
</div> </div>
{{/d-modal-body}} {{/d-modal-body}}

View File

@ -239,3 +239,23 @@
#custom_emoji { #custom_emoji {
width: 27%; 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
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 def import
@theme = nil @theme = nil
if params[:theme] if params[:theme]
@ -66,8 +76,12 @@ class Admin::ThemesController < Admin::AdminController
render json: @theme.errors, status: :unprocessable_entity render json: @theme.errors, status: :unprocessable_entity
end end
elsif params[:remote] elsif params[:remote]
@theme = RemoteTheme.import_theme(params[:remote]) begin
render json: @theme, status: :created @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 else
render json: @theme.errors, status: :unprocessable_entity render json: @theme.errors, status: :unprocessable_entity
end end

View File

@ -7,8 +7,8 @@ class RemoteTheme < ActiveRecord::Base
has_one :theme has_one :theme
def self.import_theme(url, user = Discourse.system_user) def self.import_theme(url, user = Discourse.system_user, private_key: nil)
importer = GitImporter.new(url) importer = GitImporter.new(url, private_key: private_key)
importer.import! importer.import!
theme_info = JSON.parse(importer["about.json"]) theme_info = JSON.parse(importer["about.json"])
@ -17,6 +17,7 @@ class RemoteTheme < ActiveRecord::Base
remote_theme = new remote_theme = new
theme.remote_theme = remote_theme theme.remote_theme = remote_theme
remote_theme.private_key = private_key
remote_theme.remote_url = importer.url remote_theme.remote_url = importer.url
remote_theme.update_from_remote(importer) remote_theme.update_from_remote(importer)
@ -31,7 +32,7 @@ class RemoteTheme < ActiveRecord::Base
end end
def update_remote_version def update_remote_version
importer = GitImporter.new(remote_url) importer = GitImporter.new(remote_url, private_key: private_key)
importer.import! importer.import!
self.updated_at = Time.zone.now self.updated_at = Time.zone.now
self.remote_version, self.commits_behind = importer.commits_since(remote_version) self.remote_version, self.commits_behind = importer.commits_since(remote_version)
@ -43,7 +44,7 @@ class RemoteTheme < ActiveRecord::Base
unless importer unless importer
cleanup = true cleanup = true
importer = GitImporter.new(remote_url) importer = GitImporter.new(remote_url, private_key: private_key)
importer.import! importer.import!
end end
@ -162,4 +163,5 @@ end
# remote_updated_at :datetime # remote_updated_at :datetime
# created_at :datetime not null # created_at :datetime not null
# updated_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!)" delete_upload_confirm: "Delete this upload? (Theme CSS may stop working!)"
import_web_tip: "Repository containing theme" import_web_tip: "Repository containing theme"
import_file_tip: ".dcstyle.json file 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" about_theme: "About Theme"
license: "License" license: "License"
component_of: "Theme is a component of:" component_of: "Theme is a component of:"

View File

@ -151,6 +151,8 @@ en:
other: ! '%{count} errors prohibited this %{model} from being saved' other: ! '%{count} errors prohibited this %{model} from being saved'
embed: embed:
load_from_remote: "There was an error loading that post." 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: site_settings:
min_username_length_exists: "You cannot set the minimum username length above the shortest username." 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." 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/import" => "themes#import"
post "themes/upload_asset" => "themes#upload_asset" post "themes/upload_asset" => "themes#upload_asset"
post "themes/generate_key_pair" => "themes#generate_key_pair"
get "themes/:id/preview" => "themes#preview" get "themes/:id/preview" => "themes#preview"
scope "/customize", constraints: AdminConstraint.new do 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 attr_reader :url
def initialize(url) def initialize(url, private_key: nil)
@url = url @url = url
if @url.start_with?("https://github.com") && !@url.end_with?(".git") if @url.start_with?("https://github.com") && !@url.end_with?(".git")
@url += ".git" @url += ".git"
end end
@temp_folder = "#{Pathname.new(Dir.tmpdir).realpath}/discourse_theme_#{SecureRandom.hex}" @temp_folder = "#{Pathname.new(Dir.tmpdir).realpath}/discourse_theme_#{SecureRandom.hex}"
@private_key = private_key
end end
def import! def import!
Discourse::Utils.execute_command("git", "clone", @url, @temp_folder) if @private_key
import_private!
else
import_public!
end
end end
def commits_since(hash) def commits_since(hash)
@ -55,4 +60,26 @@ class GitImporter
File.read(fullpath) File.read(fullpath)
end 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 end

View File

@ -12,6 +12,15 @@ describe Admin::ThemesController do
@user = log_in(:admin) @user = log_in(:admin)
end 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 context '.upload_asset' do
render_views render_views