require_dependency 'discourse_sass_importer' class SiteCustomization < ActiveRecord::Base ENABLED_KEY = '7e202ef2-56d7-47d5-98d8-a9c8d15e57dd' # placing this in uploads to ease deployment rules CACHE_PATH = 'uploads/stylesheet-cache' @lock = Mutex.new before_create do self.position ||= (SiteCustomization.maximum(:position) || 0) + 1 self.enabled ||= false self.key ||= SecureRandom.uuid true end def compile_stylesheet(scss) env = Rails.application.assets # In production Rails.application.assets is a Sprockets::Index # instead of Sprockets::Environment, there is no cleaner way # to get the environment from the index. if env.is_a?(Sprockets::Index) env = env.instance_variable_get('@environment') end context = env.context_class.new(env, "custom.scss", "app/assets/stylesheets/custom.scss") ::Sass::Engine.new(scss, { syntax: :scss, cache: false, read_cache: false, style: :compressed, filesystem_importer: DiscourseSassImporter, sprockets: { context: context, environment: context.environment } }).render rescue => e puts e.backtrace.join("\n") unless Sass::SyntaxError === e raise e end before_save do ['stylesheet', 'mobile_stylesheet'].each do |stylesheet_attr| if self.send("#{stylesheet_attr}_changed?") begin self.send("#{stylesheet_attr}_baked=", compile_stylesheet(self.send(stylesheet_attr))) rescue Sass::SyntaxError => e error = e.sass_backtrace_str("custom stylesheet") error.gsub!("\n", '\A ') error.gsub!("'", '\27 ') self.send("#{stylesheet_attr}_baked=", "footer { white-space: pre; } footer:after { content: '#{error}' }") end end end end after_save do if stylesheet_changed? File.delete(stylesheet_fullpath) if File.exists?(stylesheet_fullpath) end if mobile_stylesheet_changed? File.delete(stylesheet_fullpath(:mobile)) if File.exists?(stylesheet_fullpath(:mobile)) end remove_from_cache! if stylesheet_changed? or mobile_stylesheet_changed? ensure_stylesheets_on_disk! # TODO: this is broken now because there's mobile stuff too MessageBus.publish "/file-change/#{key}", stylesheet_hash end MessageBus.publish "/header-change/#{key}", header if header_changed? end after_destroy do if File.exists?(stylesheet_fullpath) File.delete stylesheet_fullpath end if File.exists?(stylesheet_fullpath(:mobile)) File.delete stylesheet_fullpath(:mobile) end self.remove_from_cache! end def self.enabled_key ENABLED_KEY.dup << RailsMultisite::ConnectionManagement.current_db end def self.enabled_style_key @cache ||= {} preview_style = @cache[enabled_key] return if preview_style == :none return preview_style if preview_style @lock.synchronize do style = where(enabled: true).first if style @cache[enabled_key] = style.key else @cache[enabled_key] = :none nil end end end def self.custom_stylesheet(preview_style, target=:desktop) preview_style ||= enabled_style_key style = lookup_style(preview_style) style.stylesheet_link_tag(target).html_safe if style end def self.custom_header(preview_style, target=:desktop) preview_style ||= enabled_style_key style = lookup_style(preview_style) if style && ((target != :mobile && style.header) || (target == :mobile && style.mobile_header)) target == :mobile ? style.mobile_header.html_safe : style.header.html_safe else "" end end def self.override_default_style(preview_style) preview_style ||= enabled_style_key style = lookup_style(preview_style) style.override_default_style if style end def self.lookup_style(key) return if key.blank? # cache is cross site resiliant cause key is secure random @cache ||= {} ensure_cache_listener style = @cache[key] return style if style @lock.synchronize do style = where(key: key).first style.ensure_stylesheets_on_disk! if style @cache[key] = style end end def self.ensure_cache_listener unless @subscribed klass = self MessageBus.subscribe("/site_customization") do |msg| message = msg.data klass.remove_from_cache!(message["key"], false) end @subscribed = true end end def self.remove_from_cache!(key, broadcast = true) MessageBus.publish('/site_customization', key: key) if broadcast if @cache @lock.synchronize do @cache[key] = nil end end end def remove_from_cache! self.class.remove_from_cache!(self.class.enabled_key) self.class.remove_from_cache!(key) end def stylesheet_hash(target=:desktop) Digest::MD5.hexdigest( target == :mobile ? mobile_stylesheet : stylesheet ) end def cache_fullpath "#{Rails.root}/public/#{CACHE_PATH}" end def ensure_stylesheets_on_disk! [[:desktop, 'stylesheet_baked'], [:mobile, 'mobile_stylesheet_baked']].each do |target, baked_attr| path = stylesheet_fullpath(target) dir = cache_fullpath FileUtils.mkdir_p(dir) unless File.exists?(path) File.open(path, "w") do |f| f.puts self.send(baked_attr) end end end end def stylesheet_filename(target=:desktop) target == :desktop ? "/#{self.key}.css" : "/#{target}_#{self.key}.css" end def stylesheet_fullpath(target=:desktop) "#{cache_fullpath}#{stylesheet_filename(target)}" end def stylesheet_link_tag(target=:desktop) return mobile_stylesheet_link_tag if target == :mobile return "" unless stylesheet.present? return @stylesheet_link_tag if @stylesheet_link_tag ensure_stylesheets_on_disk! @stylesheet_link_tag = "" end def mobile_stylesheet_link_tag return "" unless mobile_stylesheet.present? return @mobile_stylesheet_link_tag if @mobile_stylesheet_link_tag ensure_stylesheets_on_disk! @mobile_stylesheet_link_tag = "" end end # == Schema Information # # Table name: site_customizations # # id :integer not null, primary key # name :string(255) not null # stylesheet :text # header :text # position :integer not null # user_id :integer not null # enabled :boolean not null # key :string(255) not null # created_at :datetime not null # updated_at :datetime not null # override_default_style :boolean default(FALSE), not null # stylesheet_baked :text default(""), not null # mobile_stylesheet :text # mobile_header :text # mobile_stylesheet_baked :text # # Indexes # # index_site_customizations_on_key (key) #