working plugin interface for custom openid auth, custom css and custom js

This commit is contained in:
Sam 2013-08-01 15:59:57 +10:00
parent 61b330abb4
commit 160107a712
12 changed files with 422 additions and 95 deletions

View File

@ -16,19 +16,12 @@ Discourse.LoginController = Discourse.Controller.extend(Discourse.ModalFunctiona
return Discourse.Site.instance();
}.property(),
/**
Determines whether at least one login button is enabled
**/
hasAtLeastOneLoginButton: function() {
return Discourse.SiteSettings.enable_google_logins ||
Discourse.SiteSettings.enable_facebook_logins ||
Discourse.SiteSettings.enable_cas_logins ||
Discourse.SiteSettings.enable_twitter_logins ||
Discourse.SiteSettings.enable_yahoo_logins ||
Discourse.SiteSettings.enable_github_logins ||
Discourse.SiteSettings.enable_persona_logins;
}.property(),
return Em.get("Discourse.LoginMethod.all").length > 0;
}.property("Discourse.LoginMethod.all.@each"),
loginButtonText: function() {
return this.get('loggingIn') ? I18n.t('login.logging_in') : I18n.t('login.title');
@ -78,54 +71,30 @@ Discourse.LoginController = Discourse.Controller.extend(Discourse.ModalFunctiona
authMessage: (function() {
if (this.blank('authenticate')) return "";
return I18n.t("login." + (this.get('authenticate')) + ".message");
var method = Discourse.get('LoginMethod.all').findProperty("name", this.get("authenticate"));
if(method){
return method.get('message');
}
}).property('authenticate'),
twitterLogin: function() {
this.set('authenticate', 'twitter');
var left = this.get('lastX') - 400;
var top = this.get('lastY') - 200;
return window.open(Discourse.getURL("/auth/twitter"), "_blank", "menubar=no,status=no,height=400,width=800,left=" + left + ",top=" + top);
},
externalLogin: function(loginMethod){
var name = loginMethod.get("name");
var customLogin = loginMethod.get("customLogin");
facebookLogin: function() {
this.set('authenticate', 'facebook');
var left = this.get('lastX') - 400;
var top = this.get('lastY') - 200;
return window.open(Discourse.getURL("/auth/facebook"), "_blank", "menubar=no,status=no,height=400,width=800,left=" + left + ",top=" + top);
},
casLogin: function() {
var left, top;
this.set('authenticate', 'cas');
left = this.get('lastX') - 400;
top = this.get('lastY') - 200;
return window.open("/auth/cas", "_blank", "menubar=no,status=no,height=400,width=800,left=" + left + ",top=" + top);
},
openidLogin: function(provider) {
var left = this.get('lastX') - 400;
var top = this.get('lastY') - 200;
if (provider === "yahoo") {
this.set("authenticate", 'yahoo');
return window.open(Discourse.getURL("/auth/yahoo"), "_blank", "menubar=no,status=no,height=400,width=800,left=" + left + ",top=" + top);
if(customLogin){
customLogin();
} else {
window.open(Discourse.getURL("/auth/google"), "_blank", "menubar=no,status=no,height=500,width=850,left=" + left + ",top=" + top);
return this.set("authenticate", 'google');
this.set('authenticate', name);
var left = this.get('lastX') - 400;
var top = this.get('lastY') - 200;
var height = loginMethod.get("frameHeight") || 400;
var width = loginMethod.get("frameWidth") || 800;
window.open(Discourse.getURL("/auth/" + name), "_blank",
"menubar=no,status=no,height=" + height + ",width=" + width + ",left=" + left + ",top=" + top);
}
},
githubLogin: function() {
this.set('authenticate', 'github');
var left = this.get('lastX') - 400;
var top = this.get('lastY') - 200;
return window.open(Discourse.getURL("/auth/github"), "_blank", "menubar=no,status=no,height=400,width=800,left=" + left + ",top=" + top);
},
personaLogin: function() {
navigator.id.request();
},
authenticationComplete: function(options) {
if (options.awaiting_approval) {
this.flash(I18n.t('login.awaiting_approval'), 'success');

View File

@ -0,0 +1,69 @@
Discourse.LoginMethod = Ember.Object.extend({
title: function(){
return this.get("titleOverride") || I18n.t("login." + this.get("name") + ".title");
}.property(),
message: function(){
return this.get("messageOverride") || I18n.t("login." + this.get("name") + ".message");
}.property()
});
// Note, you can add login methods by adding to the list
// just Em.get("Discourse.LoginMethod.all") and then
// pushObject for any new methods
Discourse.LoginMethod.reopenClass({
register: function(method){
if(this.methods){
this.methods.pushObject(method);
} else {
this.preRegister = this.preRegister || [];
this.preRegister.push(method);
}
},
all: function(){
if (this.methods) { return this.methods; }
var methods = this.methods = Em.A();
/*
* enable_google_logins etc.
* */
[ "google",
"facebook",
"cas",
"twitter",
"yahoo",
"github",
"persona"
].forEach(function(name){
if(Discourse.SiteSettings["enable_" + name + "_logins"]){
var params = {name: name};
if(name === "persona") {
params.customLogin = function(){
navigator.id.request();
};
}
if(name === "google") {
params.frameWidth = 850;
params.frameHeight = 500;
}
methods.pushObject(Discourse.LoginMethod.create(params));
}
});
if (this.preRegister){
this.preRegister.forEach(function(method){
methods.pushObject(method);
});
delete this.preRegister;
}
return methods;
}.property()
});

View File

@ -1,27 +1,9 @@
<div class="modal-body">
{{#if hasAtLeastOneLoginButton}}
<div id="login-buttons">
{{#if Discourse.SiteSettings.enable_google_logins}}
<button class="btn btn-social google" title="{{i18n login.google.title}}" {{action openidLogin "google"}}>{{i18n login.google.title}}</button>
{{/if}}
{{#if Discourse.SiteSettings.enable_facebook_logins}}
<button class="btn btn-social facebook" title="{{i18n login.facebook.title}}" {{action "facebookLogin"}}>{{i18n login.facebook.title}}</button>
{{/if}}
{{#if Discourse.SiteSettings.enable_cas_logins}}
<button class="btn btn-social cas" title="{{i18n login.cas.title}}" {{action "casLogin"}}>{{i18n login.cas.title}}</button>
{{/if}}
{{#if Discourse.SiteSettings.enable_twitter_logins}}
<button class="btn btn-social twitter" title="{{i18n login.twitter.title}}" {{action "twitterLogin"}}>{{i18n login.twitter.title}}</button>
{{/if}}
{{#if Discourse.SiteSettings.enable_yahoo_logins}}
<button class="btn btn-social yahoo" title="{{i18n login.yahoo.title}}" {{action openidLogin "yahoo"}}>{{i18n login.yahoo.title}}</button>
{{/if}}
{{#if Discourse.SiteSettings.enable_github_logins}}
<button class="btn btn-social github" title="{{i18n login.github.title}}" {{action "githubLogin"}}>{{i18n login.github.title}}</button>
{{/if}}
{{#if Discourse.SiteSettings.enable_persona_logins}}
<button class="btn btn-social persona" title="{{i18n login.persona.title}}" {{action "personaLogin"}}>{{i18n login.persona.title}}</button>
{{/if}}
{{#each Discourse.LoginMethod.all}}
<button class="btn btn-social {{unbound name}}" {{action externalLogin this}}>{{title}}</button>
{{/each}}
</div>
{{/if}}
{{#if Discourse.SiteSettings.enable_local_logins}}

View File

@ -20,15 +20,28 @@ class Users::OmniauthCallbacksController < ApplicationController
skip_before_filter :verify_authenticity_token, only: :complete
def complete
# Make sure we support that provider
provider = params[:provider]
raise Discourse::InvalidAccess.new unless self.class.types.keys.map(&:to_s).include?(provider)
# Check if the provider is enabled
raise Discourse::InvalidAccess.new("provider is not enabled") unless SiteSetting.send("enable_#{provider}_logins?")
# If we are a plugin, then try to login with it
found = false
Discourse.auth_providers.each do |p|
if p.name == provider && p.type == :open_id
create_or_sign_on_user_using_openid request.env["omniauth.auth"]
found = true
break
end
end
# Call the appropriate logic
send("create_or_sign_on_user_using_#{provider}", request.env["omniauth.auth"])
unless found
# Make sure we support that provider
raise Discourse::InvalidAccess.new unless self.class.types.keys.map(&:to_s).include?(provider)
# Check if the provider is enabled
raise Discourse::InvalidAccess.new("provider is not enabled") unless SiteSetting.send("enable_#{provider}_logins?")
# Call the appropriate logic
send("create_or_sign_on_user_using_#{provider}", request.env["omniauth.auth"])
end
@data[:awaiting_approval] = true if invite_only?

View File

@ -119,6 +119,11 @@ module Discourse
# attr_accessible.
config.active_record.whitelist_attributes = false
unless Rails.env.test?
require 'plugin'
Discourse.activate_plugins!
end
# So open id logs somewhere sane
config.after_initialize do
OpenID::Util.logger = Rails.logger
@ -131,9 +136,6 @@ module Discourse
Clockwork.run
end
end
end
end
end

View File

@ -18,8 +18,18 @@ Rails.application.config.middleware.use OmniAuth::Builder do
:identifier => 'https://me.yahoo.com',
:require => 'omniauth-openid'
# lambda is required for proper multisite support,
# without it subdomains will not function correctly
Discourse.auth_providers.each do |p|
if p.type == :open_id
provider :open_id, {
:name => p.name,
:store => OpenID::Store::Redis.new($redis),
:require => 'omniauth-openid'
}.merge(p.options)
end
end
# lambda is required for proper multisite support,
# without it subdomains will not function correctly
provider :facebook,
:setup => lambda { |env|
strategy = env['omniauth.strategy']

4
lib/auth_provider.rb Normal file
View File

@ -0,0 +1,4 @@
class AuthProvider
attr_accessor :type, :glyph, :background_color, :name, :title,
:message, :frame_width, :frame_height, :options
end

View File

@ -23,6 +23,31 @@ module Discourse
# Cross site request forgery
class CSRF < Exception; end
def self.activate_plugins!
@plugins = Plugin.find_all("#{Rails.root}/plugins")
@plugins.each do |plugin|
plugin.activate!
end
end
def self.plugins
@plugins
end
def self.auth_providers
providers = nil
if plugins
plugins.each do |p|
next unless p.auth_providers
p.auth_providers.each do |prov|
providers ||= []
providers << prov
end
end
end
providers
end
def self.cache
@cache ||= Cache.new
end

View File

@ -7,21 +7,22 @@ class DiscoursePluginRegistry
attr_accessor :javascripts
attr_accessor :server_side_javascripts
attr_accessor :stylesheets
# Default accessor values
#
def stylesheets
@stylesheets ||= Set.new
end
def javascripts
@javascripts ||= Set.new
end
def server_side_javascripts
@server_side_javascripts ||= Set.new
end
end
# Default accessor values
#
def self.stylesheets
@stylesheets ||= Set.new
end
def self.javascripts
@javascripts ||= Set.new
end
def self.server_side_javascripts
@server_side_javascripts ||= Set.new
end
def register_js(filename, options={})
# If we have a server side option, add that too.

177
lib/plugin.rb Normal file
View File

@ -0,0 +1,177 @@
require_dependency 'auth_provider'
require 'digest/sha1'
require 'fileutils'
class Plugin
METADATA = [:name, :about, :version, :authors]
attr_accessor :path
attr_accessor *METADATA
attr_reader :auth_providers
attr_reader :assets
def self.find_all(parent_path)
plugins = []
Dir["#{parent_path}/**/plugin.rb"].each do |path|
plugin = parse(File.read(path))
plugin.path = path
plugins << plugin
end
plugins
end
def self.parse(text)
plugin = self.new
text.each_line do |line|
break unless plugin.parse_line(line)
end
plugin
end
def initialize
@assets = []
end
def parse_line(line)
line = line.strip
unless line.empty?
return false unless line[0] == "#"
attribute, *description = line[1..-1].split(":")
description = description.join(":")
attribute = attribute.strip.to_sym
if METADATA.include?(attribute)
self.send("#{attribute}=", description.strip)
end
end
true
end
# will make sure all the assets this plugin needs are registered
def generate_automatic_assets!
paths = []
automatic_assets.each do |path, contents|
unless File.exists? path
ensure_directory path
File.open(path,"w") do |f|
f.write(contents)
end
end
paths << path
end
delete_extra_automatic_assets(paths)
paths
end
def delete_extra_automatic_assets(good_paths)
filenames = good_paths.map{|f| File.basename(f)}
# nuke old files
Dir.foreach(auto_generated_path) do |p|
next if [".", ".."].include?(p)
next if filenames.include?(p)
File.delete(auto_generated_path + "/#{p}")
end
end
def ensure_directory(path)
dirname = File.dirname(path)
unless File.directory?(dirname)
FileUtils.mkdir_p(dirname)
end
end
def auto_generated_path
File.dirname(path) << "/auto_generated"
end
def register_css(style)
@styles ||= []
@styles << style
end
def register_javascript(js)
@javascripts ||= []
@javascripts << js
end
def automatic_assets
css = ""
js = "(function(){"
css = @styles.join("\n") if @styles
js = @javascripts.join("\n") if @javascripts
unless auth_providers.blank?
auth_providers.each do |auth|
overrides = ""
overrides = ", titleOverride: '#{auth.title}'" if auth.title
overrides << ", messageOverride: '#{auth.message}'" if auth.message
overrides << ", frameWidth: '#{auth.frame_width}'" if auth.frame_width
overrides << ", frameHeight: '#{auth.frame_height}'" if auth.frame_height
js << "Discourse.LoginMethod.register(Discourse.LoginMethod.create({name: '#{auth.name}'#{overrides}}));\n"
if auth.glyph
css << ".btn-social.#{auth.name}:before{ content: '#{auth.glyph}'; }\n"
end
if auth.background_color
css << ".btn-social.#{auth.name}{ background: #{auth.background_color}; }\n"
end
end
end
js << "})();"
# TODO don't serve blank assets
[[css,"css"],[js,"js"]].map do |asset, extension|
hash = Digest::SHA1.hexdigest asset
["#{auto_generated_path}/plugin_#{hash}.#{extension}", asset]
end
end
# note, we need to be able to parse seperately to activation.
# this allows us to present information about a plugin in the UI
# prior to activations
def activate!
self.instance_eval File.read(path)
if auto_assets = generate_automatic_assets!
assets.concat auto_assets
end
unless assets.blank?
paths = []
assets.each do |asset|
if asset =~ /\.js$/
DiscoursePluginRegistry.javascripts << asset
elsif asset =~ /\.css$|\.scss$/
DiscoursePluginRegistry.stylesheets << asset
end
paths << File.dirname(asset)
end
# TODO possibly amend this to a rails engine
Rails.configuration.assets.paths << auto_generated_path
Rails.configuration.assets.paths << File.dirname(path) + "/assets"
end
end
def auth_provider(type, opts)
@auth_providers ||= []
provider = AuthProvider.new
provider.type = type
[:name, :glyph, :background_color, :title, :message, :frame_width, :frame_height].each do |sym|
provider.send "#{sym}=", opts.delete(sym)
end
provider.options = opts
@auth_providers << provider
end
end

View File

@ -0,0 +1,65 @@
require 'spec_helper'
require_dependency 'plugin'
describe Plugin do
context "parse" do
it "correctly parses plugin info" do
plugin = Plugin.parse <<TEXT
# name: plugin-name
# about: about: my plugin
# version: 0.1
# authors: Frank Zappa
some_ruby
TEXT
plugin.name.should == "plugin-name"
plugin.about.should == "about: my plugin"
plugin.version.should == "0.1"
plugin.authors.should == "Frank Zappa"
end
end
context "find_all" do
it "can find plugins correctly" do
plugins = Plugin.find_all("#{Rails.root}/spec/fixtures/plugins")
plugins.count.should == 1
plugin = plugins[0]
plugin.name.should == "plugin-name"
plugin.path.should == "#{Rails.root}/spec/fixtures/plugins/my_plugin/plugin.rb"
end
it "does not blow up on missing directory" do
plugins = Plugin.find_all("#{Rails.root}/frank_zappa")
plugins.count.should == 0
end
end
context "activate!" do
it "can activate plugins correctly" do
plugin = Plugin.new
plugin.path = "#{Rails.root}/spec/fixtures/plugins/my_plugin/plugin.rb"
plugin.ensure_directory(plugin.auto_generated_path)
junk_file = "#{plugin.auto_generated_path}/junk"
File.open("#{plugin.auto_generated_path}/junk", "w") {|f| f.write("junk")}
plugin.activate!
plugin.auth_providers.count.should == 1
auth_provider = plugin.auth_providers[0]
auth_provider.options.should == {:identifier => 'https://zappa.com'}
auth_provider.type.should == :open_id
# calls ensure_assets! make sure they are there
plugin.assets.count.should == 2
plugin.assets.each do |a|
File.exists?(a).should be_true
end
# ensure it cleans up all crap in autogenerated directory
File.exists?(junk_file).should be_false
end
end
end

View File

@ -0,0 +1,10 @@
# name: plugin-name
# about: about: my plugin
# version: 0.1
# authors: Frank Zappa
auth_provider :open_id,
:name => 'zappa',
:identifier => 'https://zappa.com',
:background_color => '#dd4814',
:glyph => 'B'