require 'digest/sha1' require 'fileutils' require_dependency 'plugin/metadata' require_dependency 'plugin/auth_provider' class Plugin::Instance attr_accessor :path, :metadata attr_reader :admin_route # Memoized array readers [:assets, :auth_providers, :color_schemes, :initializers, :javascripts, :styles].each do |att| class_eval %Q{ def #{att} @#{att} ||= [] end } end # Memoized hash readers [:seed_data, :emojis].each do |att| class_eval %Q{ def #{att} @#{att} ||= HashWithIndifferentAccess.new({}) end } end def self.find_all(parent_path) [].tap { |plugins| # also follows symlinks - http://stackoverflow.com/q/357754 Dir["#{parent_path}/**/*/**/plugin.rb"].sort.each do |path| # tagging is included in core, so don't load it next if path =~ /discourse-tagging/ source = File.read(path) metadata = Plugin::Metadata.parse(source) plugins << self.new(metadata, path) end } end def initialize(metadata=nil, path=nil) @metadata = metadata @path = path @idx = 0 end def add_admin_route(label, location) @admin_route = {label: label, location: location} end def enabled? @enabled_site_setting ? SiteSetting.send(@enabled_site_setting) : true end delegate :name, to: :metadata def add_to_serializer(serializer, attr, define_include_method=true, &block) klass = "#{serializer.to_s.classify}Serializer".constantize rescue "#{serializer.to_s}Serializer".constantize klass.attributes(attr) unless attr.to_s.start_with?("include_") klass.send(:define_method, attr, &block) return unless define_include_method # Don't include serialized methods if the plugin is disabled plugin = self klass.send(:define_method, "include_#{attr}?") { plugin.enabled? } end def whitelist_staff_user_custom_field(field) User.register_plugin_staff_custom_field(field, self) end # Extend a class but check that the plugin is enabled # for class methods use `add_class_method` def add_to_class(klass, attr, &block) klass = klass.to_s.classify.constantize rescue klass.to_s.constantize hidden_method_name = :"#{attr}_without_enable_check" klass.send(:define_method, hidden_method_name, &block) plugin = self klass.send(:define_method, attr) do |*args| send(hidden_method_name, *args) if plugin.enabled? end end # Adds a class method to a class, respecting if plugin is enabled def add_class_method(klass, attr, &block) klass = klass.to_s.classify.constantize rescue klass.to_s.constantize hidden_method_name = :"#{attr}_without_enable_check" klass.send(:define_singleton_method, hidden_method_name, &block) plugin = self klass.send(:define_singleton_method, attr) do |*args| send(hidden_method_name, *args) if plugin.enabled? end end def add_model_callback(klass, callback, &block) klass = klass.to_s.classify.constantize rescue klass.to_s.constantize plugin = self # generate a unique method name method_name = "#{plugin.name}_#{klass.name}_#{callback}#{@idx}".underscore @idx += 1 hidden_method_name = :"#{method_name}_without_enable_check" klass.send(:define_method, hidden_method_name, &block) klass.send(callback) do |*args| send(hidden_method_name, *args) if plugin.enabled? end end # Add validation method but check that the plugin is enabled def validate(klass, name, &block) klass = klass.to_s.classify.constantize klass.send(:define_method, name, &block) plugin = self klass.validate(name, if: -> { plugin.enabled? }) end # will make sure all the assets this plugin needs are registered def generate_automatic_assets! paths = [] assets = [] automatic_assets.each do |path, contents| write_asset(path, contents) paths << path assets << [path] end automatic_server_assets.each do |path, contents| write_asset(path, contents) paths << path assets << [path, :server_side] end delete_extra_automatic_assets(paths) assets end def delete_extra_automatic_assets(good_paths) return unless Dir.exists? auto_generated_path 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 after_initialize(&block) initializers << block end # A proxy to `DiscourseEvent.on` which does nothing if the plugin is disabled def on(event_name, &block) DiscourseEvent.on(event_name) do |*args| block.call(*args) if enabled? end end def notify_after_initialize color_schemes.each do |c| ColorScheme.create_from_base(name: c[:name], colors: c[:colors]) unless ColorScheme.where(name: c[:name]).exists? end initializers.each do |callback| begin callback.call(self) rescue ActiveRecord::StatementInvalid => e # When running db:migrate for the first time on a new database, plugin initializers might # try to use models. Tolerate it. raise e unless e.message.try(:include?, "PG::UndefinedTable") end end end def listen_for(event_name) return unless self.respond_to?(event_name) DiscourseEvent.on(event_name, &self.method(event_name)) end def register_css(style) styles << style end def register_javascript(js) javascripts << js end def register_custom_html(hash) DiscoursePluginRegistry.custom_html ||= {} DiscoursePluginRegistry.custom_html.merge!(hash) end def register_asset(file, opts=nil) full_path = File.dirname(path) << "/assets/" << file assets << [full_path, opts] end def register_color_scheme(name, colors) color_schemes << {name: name, colors: colors} end def register_seed_data(key, value) seed_data[key] = value end def register_emoji(name, url) emojis[name] = url end def automatic_assets css = styles.join("\n") js = javascripts.join("\n") auth_providers.each do |auth| js << "Discourse.LoginMethod.register(Discourse.LoginMethod.create(#{auth.to_json}));\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 # Generate an IIFE for the JS js = "(function(){#{js}})();" if js.present? result = [] result << [css, 'css'] if css.present? result << [js, 'js'] if js.present? result.map do |asset, extension| hash = Digest::SHA1.hexdigest asset ["#{auto_generated_path}/plugin_#{hash}.#{extension}", asset] end end def automatic_server_assets js = "" unless emojis.blank? js << "Discourse.Emoji.addCustomEmojis(function() {" << "\n" if @enabled_site_setting.present? js << "if (Discourse.SiteSettings.#{@enabled_site_setting}) {" << "\n" end emojis.each do |name, url| js << "Discourse.Dialect.registerEmoji('#{name}', '#{url}');" << "\n" end if @enabled_site_setting.present? js << "}" << "\n" end js << "});" << "\n" end result = [] if js.present? # Generate an IIFE for the JS asset = "(function(){#{js}})();" hash = Digest::SHA1.hexdigest(asset) result << ["#{auto_generated_path}/plugin_#{hash}.js", asset] end result 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! if @path # Automatically include all ES6 JS and hbs files root_path = "#{File.dirname(@path)}/assets/javascripts" DiscoursePluginRegistry.register_glob(root_path, 'js.es6') DiscoursePluginRegistry.register_glob(root_path, 'hbs') admin_path = "#{File.dirname(@path)}/admin/assets/javascripts" DiscoursePluginRegistry.register_glob(admin_path, 'js.es6', admin: true) DiscoursePluginRegistry.register_glob(admin_path, 'hbs', admin: true) end self.instance_eval File.read(path), path if auto_assets = generate_automatic_assets! assets.concat(auto_assets) end register_assets! unless assets.blank? seed_data.each do |key, value| DiscoursePluginRegistry.register_seed_data(key, value) end # TODO: possibly amend this to a rails engine # Automatically include assets Rails.configuration.assets.paths << auto_generated_path Rails.configuration.assets.paths << File.dirname(path) + "/assets" Rails.configuration.assets.paths << File.dirname(path) + "/admin/assets" Rails.configuration.assets.paths << File.dirname(path) + "/test/javascripts" # Automatically include rake tasks Rake.add_rakelib(File.dirname(path) + "/lib/tasks") # Automatically include migrations Rails.configuration.paths["db/migrate"] << File.dirname(path) + "/db/migrate" public_data = File.dirname(path) + "/public" if Dir.exists?(public_data) target = Rails.root.to_s + "/public/plugins/" `mkdir -p #{target}` target << name.gsub(/\s/,"_") # TODO a cleaner way of registering and unregistering `rm -f #{target}` `ln -s #{public_data} #{target}` end end def auth_provider(opts) provider = Plugin::AuthProvider.new Plugin::AuthProvider.auth_attributes.each do |sym| provider.send "#{sym}=", opts.delete(sym) end auth_providers << provider end # shotgun approach to gem loading, in future we need to hack bundler # to at least determine dependencies do not clash before loading # # Additionally we want to support multiple ruby versions correctly and so on # # This is a very rough initial implementation def gem(name, version, opts = {}) gems_path = File.dirname(path) + "/gems/#{RUBY_VERSION}" spec_path = gems_path + "/specifications" spec_file = spec_path + "/#{name}-#{version}.gemspec" unless File.exists? spec_file command = "gem install #{name} -v #{version} -i #{gems_path} --no-document --ignore-dependencies" if opts[:source] command << " --source #{opts[:source]}" end puts command puts `#{command}` end if File.exists? spec_file spec = Gem::Specification.load spec_file spec.activate unless opts[:require] == false require opts[:require_name] ? opts[:require_name] : name end else puts "You are specifying the gem #{name} in #{path}, however it does not exist!" exit(-1) end end def enabled_site_setting(setting=nil) if setting @enabled_site_setting = setting else @enabled_site_setting end end protected def register_assets! assets.each do |asset, opts| DiscoursePluginRegistry.register_asset(asset, opts) end end private def write_asset(path, contents) unless File.exists?(path) ensure_directory(path) File.open(path,"w") { |f| f.write(contents) } end end end