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:
parent
200c6673f1
commit
39e679d3cb
2
Gemfile
2
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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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));
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
#
|
||||
|
|
|
@ -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:"
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
class AddPrivateKeyToRemoteTheme < ActiveRecord::Migration[5.1]
|
||||
def change
|
||||
add_column :remote_themes, :private_key, :text
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in New Issue