From 8e3691d5370bb95d99fe750f46287763721fcc9c Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Tue, 15 Jun 2021 14:57:17 +0800 Subject: [PATCH] PERF: Eager load Theme associations in Stylesheet Manager. Before this change, calling `StyleSheet::Manager.stylesheet_details` for the first time resulted in multiple queries to the database. This is because the code was modelled in a way where each `Theme` was loaded from the database one at a time. This PR restructures the code such that it allows us to load all the theme records in a single query. It also allows us to eager load the required associations upfront. In order to achieve this, I removed the support of loading multiple themes per request. It was initially added to support user selectable theme components but the feature was never completed and abandoned because it wasn't a feature that we thought was worth building. --- app/controllers/application_controller.rb | 43 +- app/controllers/bootstrap_controller.rb | 19 +- app/controllers/qunit_controller.rb | 2 +- app/controllers/stylesheets_controller.rb | 24 +- app/controllers/svg_sprite_controller.rb | 8 +- app/helpers/application_helper.rb | 48 +- app/helpers/qunit_helper.rb | 2 +- app/models/color_scheme.rb | 1 + app/models/theme.rb | 69 +-- .../_discourse_publish_stylesheet.html.erb | 2 +- .../common/_discourse_stylesheet.html.erb | 3 +- app/views/layouts/application.html.erb | 2 +- app/views/layouts/crawler.html.erb | 2 +- app/views/layouts/no_ember.html.erb | 7 +- config/routes.rb | 2 +- lib/content_security_policy.rb | 8 +- lib/content_security_policy/extension.rb | 15 +- lib/content_security_policy/middleware.rb | 6 +- lib/middleware/anonymous_cache.rb | 6 +- lib/stylesheet/importer.rb | 2 +- lib/stylesheet/manager.rb | 493 ++++++------------ lib/stylesheet/manager/builder.rb | 274 ++++++++++ lib/stylesheet/manager/scss_checker.rb | 35 ++ lib/svg_sprite/svg_sprite.rb | 52 +- lib/theme_modifier_helper.rb | 2 +- spec/components/scss_checker_spec.rb | 36 ++ spec/components/stylesheet/manager_spec.rb | 307 ++++++++--- spec/components/svg_sprite/svg_sprite_spec.rb | 46 +- spec/helpers/application_helper_spec.rb | 4 +- spec/lib/theme_flag_modifier_spec.rb | 2 +- spec/models/color_scheme_spec.rb | 9 +- spec/models/theme_spec.rb | 83 ++- spec/requests/application_controller_spec.rb | 26 +- spec/requests/safe_mode_controller_spec.rb | 2 + spec/requests/stylesheets_controller_spec.rb | 9 +- 35 files changed, 983 insertions(+), 668 deletions(-) create mode 100644 lib/stylesheet/manager/builder.rb create mode 100644 lib/stylesheet/manager/scss_checker.rb create mode 100644 spec/components/scss_checker_spec.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7ec30918698..e7fcbdcd25b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -10,7 +10,7 @@ class ApplicationController < ActionController::Base include Hijack include ReadOnlyHeader - attr_reader :theme_ids + attr_reader :theme_id serialization_scope :guardian @@ -448,35 +448,34 @@ class ApplicationController < ActionController::Base resolve_safe_mode return if request.env[NO_CUSTOM] - theme_ids = [] + theme_id = nil - if preview_theme_id = request[:preview_theme_id]&.to_i - ids = [preview_theme_id] - theme_ids = ids if guardian.allow_themes?(ids, include_preview: true) + if (preview_theme_id = request[:preview_theme_id]&.to_i) && + guardian.allow_themes?([preview_theme_id], include_preview: true) + + theme_id = preview_theme_id end user_option = current_user&.user_option - if theme_ids.blank? + if theme_id.blank? ids, seq = cookies[:theme_ids]&.split("|") - ids = ids&.split(",")&.map(&:to_i) - if ids.present? && seq && seq.to_i == user_option&.theme_key_seq.to_i - theme_ids = ids if guardian.allow_themes?(ids) + id = ids&.split(",")&.map(&:to_i)&.first + if id.present? && seq && seq.to_i == user_option&.theme_key_seq.to_i + theme_id = id if guardian.allow_themes?([id]) end end - if theme_ids.blank? + if theme_id.blank? ids = user_option&.theme_ids || [] - theme_ids = ids if guardian.allow_themes?(ids) + theme_id = ids.first if guardian.allow_themes?(ids) end - if theme_ids.blank? && SiteSetting.default_theme_id != -1 - if guardian.allow_themes?([SiteSetting.default_theme_id]) - theme_ids << SiteSetting.default_theme_id - end + if theme_id.blank? && SiteSetting.default_theme_id != -1 && guardian.allow_themes?([SiteSetting.default_theme_id]) + theme_id = SiteSetting.default_theme_id end - @theme_ids = request.env[:resolved_theme_ids] = theme_ids + @theme_id = request.env[:resolved_theme_id] = theme_id end def guardian @@ -635,10 +634,10 @@ class ApplicationController < ActionController::Base target = view_context.mobile_view? ? :mobile : :desktop data = - if @theme_ids.present? + if @theme_id.present? { - top: Theme.lookup_field(@theme_ids, target, "after_header"), - footer: Theme.lookup_field(@theme_ids, target, "footer") + top: Theme.lookup_field(@theme_id, target, "after_header"), + footer: Theme.lookup_field(@theme_id, target, "footer") } else {} @@ -943,9 +942,9 @@ class ApplicationController < ActionController::Base end def activated_themes_json - ids = @theme_ids&.compact - return "{}" if ids.blank? - ids = Theme.transform_ids(ids) + id = @theme_id + return "{}" if id.blank? + ids = Theme.transform_ids(id) Theme.where(id: ids).pluck(:id, :name).to_h.to_json end end diff --git a/app/controllers/bootstrap_controller.rb b/app/controllers/bootstrap_controller.rb index 0921e0aab81..5bb4dc6ddb3 100644 --- a/app/controllers/bootstrap_controller.rb +++ b/app/controllers/bootstrap_controller.rb @@ -34,7 +34,7 @@ class BootstrapController < ApplicationController ).each do |file| add_style(file, plugin: true) end - add_style(mobile_view? ? :mobile_theme : :desktop_theme) if theme_ids.present? + add_style(mobile_view? ? :mobile_theme : :desktop_theme) if theme_id.present? extra_locales = [] if ExtraLocalesController.client_overrides_exist? @@ -51,7 +51,7 @@ class BootstrapController < ApplicationController ).map { |f| script_asset_path(f) } bootstrap = { - theme_ids: theme_ids, + theme_ids: [theme_id], title: SiteSetting.title, current_homepage: current_homepage, locale_script: locale, @@ -75,15 +75,14 @@ class BootstrapController < ApplicationController private def add_scheme(scheme_id, media) return if scheme_id.to_i == -1 - theme_id = theme_ids&.first - if style = Stylesheet::Manager.color_scheme_stylesheet_details(scheme_id, media, theme_id) + if style = Stylesheet::Manager.new(theme_id: theme_id).color_scheme_stylesheet_details(scheme_id, media) @stylesheets << { href: style[:new_href], media: media } end end def add_style(target, opts = nil) - if styles = Stylesheet::Manager.stylesheet_details(target, 'all', theme_ids) + if styles = Stylesheet::Manager.new(theme_id: theme_id).stylesheet_details(target, 'all') styles.each do |style| @stylesheets << { href: style[:new_href], @@ -117,11 +116,11 @@ private theme_view = mobile_view? ? :mobile : :desktop - add_if_present(theme_html, :body_tag, Theme.lookup_field(theme_ids, theme_view, 'body_tag')) - add_if_present(theme_html, :head_tag, Theme.lookup_field(theme_ids, theme_view, 'head_tag')) - add_if_present(theme_html, :header, Theme.lookup_field(theme_ids, theme_view, 'header')) - add_if_present(theme_html, :translations, Theme.lookup_field(theme_ids, :translations, I18n.locale)) - add_if_present(theme_html, :js, Theme.lookup_field(theme_ids, :extra_js, nil)) + add_if_present(theme_html, :body_tag, Theme.lookup_field(theme_id, theme_view, 'body_tag')) + add_if_present(theme_html, :head_tag, Theme.lookup_field(theme_id, theme_view, 'head_tag')) + add_if_present(theme_html, :header, Theme.lookup_field(theme_id, theme_view, 'header')) + add_if_present(theme_html, :translations, Theme.lookup_field(theme_id, :translations, I18n.locale)) + add_if_present(theme_html, :js, Theme.lookup_field(theme_id, :extra_js, nil)) theme_html end diff --git a/app/controllers/qunit_controller.rb b/app/controllers/qunit_controller.rb index 8bb951b0fa0..98f7d70dac9 100644 --- a/app/controllers/qunit_controller.rb +++ b/app/controllers/qunit_controller.rb @@ -43,7 +43,7 @@ class QunitController < ApplicationController return end - request.env[:resolved_theme_ids] = [theme.id] + request.env[:resolved_theme_id] = theme.id request.env[:skip_theme_ids_transformation] = true end diff --git a/app/controllers/stylesheets_controller.rb b/app/controllers/stylesheets_controller.rb index d9446882a2e..427f2b9203a 100644 --- a/app/controllers/stylesheets_controller.rb +++ b/app/controllers/stylesheets_controller.rb @@ -19,7 +19,8 @@ class StylesheetsController < ApplicationController params.require("id") params.permit("theme_id") - stylesheet = Stylesheet::Manager.color_scheme_stylesheet_details(params[:id], 'all', params[:theme_id]) + manager = Stylesheet::Manager.new(theme_id: params[:theme_id]) + stylesheet = manager.color_scheme_stylesheet_details(params[:id], 'all') render json: stylesheet end protected @@ -40,16 +41,19 @@ class StylesheetsController < ApplicationController # we hold off re-compilation till someone asks for asset if target.include?("color_definitions") split_target, color_scheme_id = target.split(/_(-?[0-9]+)/) - Stylesheet::Manager.color_scheme_stylesheet_link_tag(color_scheme_id) + + Stylesheet::Manager.new.color_scheme_stylesheet_link_tag(color_scheme_id) else - if target.include?("theme") - split_target, theme_id = target.split(/_(-?[0-9]+)/) - theme = Theme.find_by(id: theme_id) if theme_id.present? - else - split_target, color_scheme_id = target.split(/_(-?[0-9]+)/) - theme = Theme.find_by(color_scheme_id: color_scheme_id) - end - Stylesheet::Manager.stylesheet_link_tag(split_target, nil, theme&.id) + theme_id = + if target.include?("theme") + split_target, theme_id = target.split(/_(-?[0-9]+)/) + Theme.where(id: theme_id).pluck_first(:id) if theme_id.present? + else + split_target, color_scheme_id = target.split(/_(-?[0-9]+)/) + Theme.where(color_scheme_id: color_scheme_id).pluck_first(:id) + end + + Stylesheet::Manager.new(theme_id: theme_id).stylesheet_link_tag(split_target, nil) end end diff --git a/app/controllers/svg_sprite_controller.rb b/app/controllers/svg_sprite_controller.rb index 5851e4b5645..259fb11c326 100644 --- a/app/controllers/svg_sprite_controller.rb +++ b/app/controllers/svg_sprite_controller.rb @@ -12,13 +12,13 @@ class SvgSpriteController < ApplicationController no_cookies RailsMultisite::ConnectionManagement.with_hostname(params[:hostname]) do - theme_ids = params[:theme_ids].split(",").map(&:to_i) + theme_id = params[:theme_id].to_i - if SvgSprite.version(theme_ids) != params[:version] - return redirect_to path(SvgSprite.path(theme_ids)) + if SvgSprite.version(theme_id) != params[:version] + return redirect_to path(SvgSprite.path(theme_id)) end - svg_sprite = "window.__svg_sprite = #{SvgSprite.bundle(theme_ids).inspect};" + svg_sprite = "window.__svg_sprite = #{SvgSprite.bundle(theme_id).inspect};" response.headers["Last-Modified"] = 10.years.ago.httpdate response.headers["Content-Length"] = svg_sprite.bytesize.to_s diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 702f83ca5af..480b242969d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -408,14 +408,19 @@ module ApplicationHelper end end - def theme_ids + def theme_id if customization_disabled? - [nil] + nil else - request.env[:resolved_theme_ids] + request.env[:resolved_theme_id] end end + def stylesheet_manager + return @stylesheet_manager if defined?(@stylesheet_manager) + @stylesheet_manager = Stylesheet::Manager.new(theme_id: theme_id) + end + def scheme_id return @scheme_id if defined?(@scheme_id) @@ -424,12 +429,9 @@ module ApplicationHelper return custom_user_scheme_id end - return if theme_ids.blank? + return if theme_id.blank? - @scheme_id = Theme - .where(id: theme_ids.first) - .pluck(:color_scheme_id) - .first + @scheme_id = Theme.where(id: theme_id).pluck_first(:color_scheme_id) end def dark_scheme_id @@ -457,7 +459,7 @@ module ApplicationHelper def theme_lookup(name) Theme.lookup_field( - theme_ids, + theme_id, mobile_view? ? :mobile : :desktop, name, skip_transformation: request.env[:skip_theme_ids_transformation].present? @@ -466,7 +468,7 @@ module ApplicationHelper def theme_translations_lookup Theme.lookup_field( - theme_ids, + theme_id, :translations, I18n.locale, skip_transformation: request.env[:skip_theme_ids_transformation].present? @@ -475,7 +477,7 @@ module ApplicationHelper def theme_js_lookup Theme.lookup_field( - theme_ids, + theme_id, :extra_js, nil, skip_transformation: request.env[:skip_theme_ids_transformation].present? @@ -483,22 +485,26 @@ module ApplicationHelper end def discourse_stylesheet_link_tag(name, opts = {}) - if opts.key?(:theme_ids) - ids = opts[:theme_ids] unless customization_disabled? - else - ids = theme_ids - end + manager = + if opts.key?(:theme_id) + Stylesheet::Manager.new( + theme_id: customization_disabled? ? nil : opts[:theme_id] + ) + else + stylesheet_manager + end - Stylesheet::Manager.stylesheet_link_tag(name, 'all', ids) + manager.stylesheet_link_tag(name, 'all') end def discourse_color_scheme_stylesheets result = +"" - result << Stylesheet::Manager.color_scheme_stylesheet_link_tag(scheme_id, 'all', theme_ids) + result << stylesheet_manager.color_scheme_stylesheet_link_tag(scheme_id, 'all') if dark_scheme_id != -1 - result << Stylesheet::Manager.color_scheme_stylesheet_link_tag(dark_scheme_id, '(prefers-color-scheme: dark)', theme_ids) + result << stylesheet_manager.color_scheme_stylesheet_link_tag(dark_scheme_id, '(prefers-color-scheme: dark)') end + result.html_safe end @@ -525,7 +531,7 @@ module ApplicationHelper asset_version: Discourse.assets_digest, disable_custom_css: loading_admin?, highlight_js_path: HighlightJs.path, - svg_sprite_path: SvgSprite.path(theme_ids), + svg_sprite_path: SvgSprite.path(theme_id), enable_js_error_reporting: GlobalSetting.enable_js_error_reporting, color_scheme_is_dark: dark_color_scheme?, user_color_scheme_id: scheme_id, @@ -533,7 +539,7 @@ module ApplicationHelper } if Rails.env.development? - setup_data[:svg_icon_list] = SvgSprite.all_icons(theme_ids) + setup_data[:svg_icon_list] = SvgSprite.all_icons(theme_id) if ENV['DEBUG_PRELOADED_APP_DATA'] setup_data[:debug_preloaded_app_data] = true diff --git a/app/helpers/qunit_helper.rb b/app/helpers/qunit_helper.rb index 98f509c6644..e0376a1ad26 100644 --- a/app/helpers/qunit_helper.rb +++ b/app/helpers/qunit_helper.rb @@ -2,7 +2,7 @@ module QunitHelper def theme_tests - theme = Theme.find_by(id: request.env[:resolved_theme_ids]&.first) + theme = Theme.find_by(id: request.env[:resolved_theme_id]) return "" if theme.blank? _, digest = theme.baked_js_tests_with_digest diff --git a/app/models/color_scheme.rb b/app/models/color_scheme.rb index dd2cf021cc8..22682be18dd 100644 --- a/app/models/color_scheme.rb +++ b/app/models/color_scheme.rb @@ -320,6 +320,7 @@ class ColorScheme < ActiveRecord::Base end if theme_ids.present? Stylesheet::Manager.cache.clear + Theme.notify_theme_change( theme_ids, with_scheme: true, diff --git a/app/models/theme.rb b/app/models/theme.rb index 8d3cd01c9ba..ad5f63be4c5 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -29,6 +29,9 @@ class Theme < ActiveRecord::Base has_many :locale_fields, -> { filter_locale_fields(I18n.fallbacks[I18n.locale]) }, class_name: 'ThemeField' has_many :upload_fields, -> { where(type_id: ThemeField.types[:theme_upload_var]).preload(:upload) }, class_name: 'ThemeField' has_many :extra_scss_fields, -> { where(target_id: Theme.targets[:extra_scss]) }, class_name: 'ThemeField' + has_many :yaml_theme_fields, -> { where("name = 'yaml' AND type_id = ?", ThemeField.types[:yaml]) }, class_name: 'ThemeField' + has_many :var_theme_fields, -> { where("type_id IN (?)", ThemeField.theme_var_type_ids) }, class_name: 'ThemeField' + has_many :builder_theme_fields, -> { where("name IN (?)", ThemeField.scss_fields) }, class_name: 'ThemeField' validate :component_validations @@ -164,6 +167,16 @@ class Theme < ActiveRecord::Base end end + def self.parent_theme_ids + get_set_cache "parent_theme_ids" do + Theme.where(component: false).pluck(:id) + end + end + + def self.is_parent_theme?(id) + self.parent_theme_ids.include?(id) + end + def self.user_theme_ids get_set_cache "user_theme_ids" do Theme.user_selectable.pluck(:id) @@ -188,25 +201,22 @@ class Theme < ActiveRecord::Base expire_site_cache! end - def self.transform_ids(ids, extend: true) - return [] if ids.nil? - get_set_cache "#{extend ? "extended_" : ""}transformed_ids_#{ids.join("_")}" do - next [] if ids.blank? + def self.transform_ids(id) + return [] if id.blank? - ids = ids.dup - ids.uniq! - parent = ids.shift - - components = ids - components.push(*components_for(parent)) if extend - components.sort!.uniq! - - all_ids = [parent, *components] + get_set_cache "transformed_ids_#{id}" do + all_ids = + if self.is_parent_theme?(id) + components = components_for(id).tap { |c| c.sort!.uniq! } + [id, *components] + else + [id] + end disabled_ids = Theme.where(id: all_ids) .includes(:remote_theme) .select { |t| !t.supported? || !t.enabled? } - .pluck(:id) + .map(&:id) all_ids - disabled_ids end @@ -272,11 +282,10 @@ class Theme < ActiveRecord::Base end end - def self.lookup_field(theme_ids, target, field, skip_transformation: false) - return if theme_ids.blank? - theme_ids = [theme_ids] unless Array === theme_ids + def self.lookup_field(theme_id, target, field, skip_transformation: false) + return "" if theme_id.blank? - theme_ids = transform_ids(theme_ids) if !skip_transformation + theme_ids = !skip_transformation ? transform_ids(theme_id) : [theme_id] cache_key = "#{theme_ids.join(",")}:#{target}:#{field}:#{Theme.compiler_version}" lookup = @cache[cache_key] return lookup.html_safe if lookup @@ -289,8 +298,8 @@ class Theme < ActiveRecord::Base def self.lookup_modifier(theme_ids, modifier_name) theme_ids = [theme_ids] unless Array === theme_ids - theme_ids = transform_ids(theme_ids) + get_set_cache("#{theme_ids.join(",")}:modifier:#{modifier_name}:#{Theme.compiler_version}") do ThemeModifierSet.resolve_modifier_for_themes(theme_ids, modifier_name) end @@ -335,14 +344,18 @@ class Theme < ActiveRecord::Base def notify_theme_change(with_scheme: false) DB.after_commit do - theme_ids = Theme.transform_ids([id]) + theme_ids = Theme.transform_ids(id) self.class.notify_theme_change(theme_ids, with_scheme: with_scheme) end end def self.refresh_message_for_targets(targets, theme_ids) - targets.map do |target| - Stylesheet::Manager.stylesheet_data(target.to_sym, theme_ids) + theme_ids = [theme_ids] unless theme_ids === Array + + targets.each_with_object([]) do |target, data| + theme_ids.each do |theme_id| + data << Stylesheet::Manager.new(theme_id: theme_id).stylesheet_data(target.to_sym) + end end end @@ -385,7 +398,8 @@ class Theme < ActiveRecord::Base end def list_baked_fields(target, name) - theme_ids = Theme.transform_ids([id], extend: name == :color_definitions) + theme_ids = Theme.transform_ids(id) + theme_ids = [theme_ids.first] if name != :color_definitions self.class.list_baked_fields(theme_ids, target, name) end @@ -435,7 +449,7 @@ class Theme < ActiveRecord::Base def all_theme_variables fields = {} - ids = Theme.transform_ids([id]) + ids = Theme.transform_ids(id) ThemeField.find_by_theme_ids(ids).where(type_id: ThemeField.theme_var_type_ids).each do |field| next if fields.key?(field.name) fields[field.name] = field @@ -530,7 +544,7 @@ class Theme < ActiveRecord::Base def included_settings hash = {} - Theme.where(id: Theme.transform_ids([id])).each do |theme| + Theme.where(id: Theme.transform_ids(id)).each do |theme| hash.merge!(theme.cached_settings) end @@ -641,11 +655,6 @@ class Theme < ActiveRecord::Base contents end - def has_scss(target) - name = target == :embedded_theme ? :embedded_scss : :scss - list_baked_fields(target, name).count > 0 - end - def convert_settings settings.each do |setting| setting_row = ThemeSetting.where(theme_id: self.id, name: setting.name.to_s).first diff --git a/app/views/common/_discourse_publish_stylesheet.html.erb b/app/views/common/_discourse_publish_stylesheet.html.erb index ddd5b9e40b5..90fe9a9f9e2 100644 --- a/app/views/common/_discourse_publish_stylesheet.html.erb +++ b/app/views/common/_discourse_publish_stylesheet.html.erb @@ -10,6 +10,6 @@ <%= discourse_stylesheet_link_tag(file) %> <%- end %> -<%- if theme_ids.present? %> +<%- if theme_id.present? %> <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_theme : :desktop_theme) %> <%- end %> diff --git a/app/views/common/_discourse_stylesheet.html.erb b/app/views/common/_discourse_stylesheet.html.erb index 700e28cf005..3ef9c170c36 100644 --- a/app/views/common/_discourse_stylesheet.html.erb +++ b/app/views/common/_discourse_stylesheet.html.erb @@ -14,7 +14,6 @@ <%= discourse_stylesheet_link_tag(file) %> <%- end %> -<%- if theme_ids.present? %> +<%- if theme_id.present? %> <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_theme : :desktop_theme) %> <%- end %> - diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 2d9445a3276..47159a614a5 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -5,7 +5,7 @@ <%= content_for?(:title) ? yield(:title) : SiteSetting.title %> - "> + <%= render partial: "layouts/head" %> <%= discourse_csrf_tags %> diff --git a/app/views/layouts/crawler.html.erb b/app/views/layouts/crawler.html.erb index 4e0a7e7bdd4..ec1f753d6ef 100644 --- a/app/views/layouts/crawler.html.erb +++ b/app/views/layouts/crawler.html.erb @@ -10,7 +10,7 @@ <%- else %> <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile : :desktop) %> <%- end %> - <%- if theme_ids.present? %> + <%- if theme_id.present? %> <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_theme : :desktop_theme) %> <%- end %> <%= theme_lookup("head_tag") %> diff --git a/app/views/layouts/no_ember.html.erb b/app/views/layouts/no_ember.html.erb index ca08de88c74..b36b9205b5d 100644 --- a/app/views/layouts/no_ember.html.erb +++ b/app/views/layouts/no_ember.html.erb @@ -13,8 +13,11 @@ <%= build_plugin_html 'server:before-head-close' %> - <%= theme_lookup("header") %> - <%= build_plugin_html 'server:header' %> + <%- unless customization_disabled? %> + <%= theme_lookup("header") %> + <%= build_plugin_html 'server:header' %> + <%- end %> +
<%= render partial: 'header', locals: { hide_auth_buttons: local_assigns[:hide_auth_buttons] } %>
diff --git a/config/routes.rb b/config/routes.rb index ee1b2644dde..052480ef8ab 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -519,7 +519,7 @@ Discourse::Application.routes.draw do get "letter_avatar_proxy/:version/letter/:letter/:color/:size.png" => "user_avatars#show_proxy_letter", constraints: { format: :png } - get "svg-sprite/:hostname/svg-:theme_ids-:version.js" => "svg_sprite#show", constraints: { hostname: /[\w\.-]+/, version: /\h{40}/, theme_ids: /([0-9]+(,[0-9]+)*)?/, format: :js } + get "svg-sprite/:hostname/svg-:theme_id-:version.js" => "svg_sprite#show", constraints: { hostname: /[\w\.-]+/, version: /\h{40}/, theme_id: /([0-9]+)?/, format: :js } get "svg-sprite/search/:keyword" => "svg_sprite#search", format: false, constraints: { keyword: /[-a-z0-9\s\%]+/ } get "svg-sprite/picker-search" => "svg_sprite#icon_picker_search", defaults: { format: :json } get "svg-sprite/:hostname/icon(/:color)/:name.svg" => "svg_sprite#svg_icon", constraints: { hostname: /[\w\.-]+/, name: /[-a-z0-9\s\%]+/, color: /(\h{3}{1,2})/, format: :svg } diff --git a/lib/content_security_policy.rb b/lib/content_security_policy.rb index 76d2246e5f8..0cfd309a4bc 100644 --- a/lib/content_security_policy.rb +++ b/lib/content_security_policy.rb @@ -4,15 +4,15 @@ require 'content_security_policy/extension' class ContentSecurityPolicy class << self - def policy(theme_ids = [], base_url: Discourse.base_url, path_info: "/") - new.build(theme_ids, base_url: base_url, path_info: path_info) + def policy(theme_id = nil, base_url: Discourse.base_url, path_info: "/") + new.build(theme_id, base_url: base_url, path_info: path_info) end end - def build(theme_ids, base_url:, path_info: "/") + def build(theme_id, base_url:, path_info: "/") builder = Builder.new(base_url: base_url) - Extension.theme_extensions(theme_ids).each { |extension| builder << extension } + Extension.theme_extensions(theme_id).each { |extension| builder << extension } Extension.plugin_extensions.each { |extension| builder << extension } builder << Extension.site_setting_extension builder << Extension.path_specific_extension(path_info) diff --git a/lib/content_security_policy/extension.rb b/lib/content_security_policy/extension.rb index 751e907fc59..51b59acda31 100644 --- a/lib/content_security_policy/extension.rb +++ b/lib/content_security_policy/extension.rb @@ -25,9 +25,9 @@ class ContentSecurityPolicy THEME_SETTING = 'extend_content_security_policy' - def theme_extensions(theme_ids) - key = "theme_extensions_#{Theme.transform_ids(theme_ids).join(',')}" - cache[key] ||= find_theme_extensions(theme_ids) + def theme_extensions(theme_id) + key = "theme_extensions_#{theme_id}" + cache[key] ||= find_theme_extensions(theme_id) end def clear_theme_extensions_cache! @@ -40,12 +40,11 @@ class ContentSecurityPolicy @cache ||= DistributedCache.new('csp_extensions') end - def find_theme_extensions(theme_ids) + def find_theme_extensions(theme_id) extensions = [] + theme_ids = Theme.transform_ids(theme_id) - resolved_ids = Theme.transform_ids(theme_ids) - - Theme.where(id: resolved_ids).find_each do |theme| + Theme.where(id: theme_ids).find_each do |theme| theme.cached_settings.each do |setting, value| extensions << build_theme_extension(value.split("|")) if setting.to_s == THEME_SETTING end @@ -54,7 +53,7 @@ class ContentSecurityPolicy extensions << build_theme_extension(ThemeModifierHelper.new(theme_ids: theme_ids).csp_extensions) html_fields = ThemeField.where( - theme_id: resolved_ids, + theme_id: theme_ids, target_id: ThemeField.basic_targets.map { |target| Theme.targets[target.to_sym] }, name: ThemeField.html_fields ) diff --git a/lib/content_security_policy/middleware.rb b/lib/content_security_policy/middleware.rb index d587f5994c6..0435529bff7 100644 --- a/lib/content_security_policy/middleware.rb +++ b/lib/content_security_policy/middleware.rb @@ -17,10 +17,10 @@ class ContentSecurityPolicy protocol = (SiteSetting.force_https || request.ssl?) ? "https://" : "http://" base_url = protocol + request.host_with_port + Discourse.base_path - theme_ids = env[:resolved_theme_ids] + theme_id = env[:resolved_theme_id] - headers['Content-Security-Policy'] = policy(theme_ids, base_url: base_url, path_info: env["PATH_INFO"]) if SiteSetting.content_security_policy - headers['Content-Security-Policy-Report-Only'] = policy(theme_ids, base_url: base_url, path_info: env["PATH_INFO"]) if SiteSetting.content_security_policy_report_only + headers['Content-Security-Policy'] = policy(theme_id, base_url: base_url, path_info: env["PATH_INFO"]) if SiteSetting.content_security_policy + headers['Content-Security-Policy-Report-Only'] = policy(theme_id, base_url: base_url, path_info: env["PATH_INFO"]) if SiteSetting.content_security_policy_report_only response end diff --git a/lib/middleware/anonymous_cache.rb b/lib/middleware/anonymous_cache.rb index ca869abb977..9fe8c8cd5b4 100644 --- a/lib/middleware/anonymous_cache.rb +++ b/lib/middleware/anonymous_cache.rb @@ -132,9 +132,9 @@ module Middleware def theme_ids ids, _ = @request.cookies['theme_ids']&.split('|') - ids = ids&.split(",")&.map(&:to_i) - if ids && Guardian.new.allow_themes?(ids) - Theme.transform_ids(ids) + id = ids&.split(",")&.map(&:to_i)&.first + if id && Guardian.new.allow_themes?([id]) + Theme.transform_ids(id) else [] end diff --git a/lib/stylesheet/importer.rb b/lib/stylesheet/importer.rb index 8b02e809c97..d9282465976 100644 --- a/lib/stylesheet/importer.rb +++ b/lib/stylesheet/importer.rb @@ -101,7 +101,7 @@ module Stylesheet end theme_id = @theme_id || SiteSetting.default_theme_id - resolved_ids = Theme.transform_ids([theme_id]) + resolved_ids = Theme.transform_ids(theme_id) if resolved_ids theme = Theme.find_by_id(theme_id) diff --git a/lib/stylesheet/manager.rb b/lib/stylesheet/manager.rb index 4461cfbacee..2f4b6791619 100644 --- a/lib/stylesheet/manager.rb +++ b/lib/stylesheet/manager.rb @@ -13,7 +13,7 @@ class Stylesheet::Manager THEME_REGEX ||= /_theme$/ COLOR_SCHEME_STYLESHEET ||= "color_definitions" - @lock = Mutex.new + @@lock = Mutex.new def self.cache @cache ||= DistributedCache.new("discourse_stylesheet") @@ -35,117 +35,6 @@ class Stylesheet::Manager cache.hash.keys.select { |k| k =~ /#{plugin}/ }.each { |k| cache.delete(k) } end - def self.stylesheet_data(target = :desktop, theme_ids = :missing) - stylesheet_details(target, "all", theme_ids) - end - - def self.stylesheet_link_tag(target = :desktop, media = 'all', theme_ids = :missing) - stylesheets = stylesheet_details(target, media, theme_ids) - stylesheets.map do |stylesheet| - href = stylesheet[:new_href] - theme_id = stylesheet[:theme_id] - data_theme_id = theme_id ? "data-theme-id=\"#{theme_id}\"" : "" - %[] - end.join("\n").html_safe - end - - def self.stylesheet_details(target = :desktop, media = 'all', theme_ids = :missing) - if theme_ids == :missing - theme_ids = [SiteSetting.default_theme_id] - end - - target = target.to_sym - - theme_ids = [theme_ids] unless Array === theme_ids - theme_ids = [theme_ids.first] unless target =~ THEME_REGEX - include_components = !!(target =~ THEME_REGEX) - - theme_ids = Theme.transform_ids(theme_ids, extend: include_components) - - current_hostname = Discourse.current_hostname - - array_cache_key = "array_themes_#{theme_ids.join(",")}_#{target}_#{current_hostname}" - stylesheets = cache[array_cache_key] - return stylesheets if stylesheets.present? - - @lock.synchronize do - stylesheets = [] - theme_ids.each do |theme_id| - data = { target: target } - cache_key = "path_#{target}_#{theme_id}_#{current_hostname}" - href = cache[cache_key] - - unless href - builder = self.new(target, theme_id) - is_theme = builder.is_theme? - has_theme = builder.theme.present? - - if is_theme && !has_theme - next - else - next if builder.theme&.component && !builder.theme&.has_scss(target) - data[:theme_id] = builder.theme.id if has_theme && is_theme - builder.compile unless File.exists?(builder.stylesheet_fullpath) - href = builder.stylesheet_path(current_hostname) - end - - cache.defer_set(cache_key, href) - end - - data[:theme_id] = theme_id if theme_id.present? && data[:theme_id].blank? - data[:new_href] = href - stylesheets << data - end - - cache.defer_set(array_cache_key, stylesheets.freeze) - stylesheets - end - end - - def self.color_scheme_stylesheet_details(color_scheme_id = nil, media, theme_id) - theme_id = theme_id || SiteSetting.default_theme_id - - color_scheme = begin - ColorScheme.find(color_scheme_id) - rescue - # don't load fallback when requesting dark color scheme - return false if media != "all" - - Theme.find_by_id(theme_id)&.color_scheme || ColorScheme.base - end - - return false if !color_scheme - - target = COLOR_SCHEME_STYLESHEET.to_sym - current_hostname = Discourse.current_hostname - cache_key = color_scheme_cache_key(color_scheme, theme_id) - stylesheets = cache[cache_key] - return stylesheets if stylesheets.present? - - stylesheet = { color_scheme_id: color_scheme&.id } - - builder = self.new(target, theme_id, color_scheme) - - builder.compile unless File.exists?(builder.stylesheet_fullpath) - - href = builder.stylesheet_path(current_hostname) - stylesheet[:new_href] = href - cache.defer_set(cache_key, stylesheet.freeze) - stylesheet - end - - def self.color_scheme_stylesheet_link_tag(color_scheme_id = nil, media = 'all', theme_ids = nil) - theme_id = theme_ids&.first - stylesheet = color_scheme_stylesheet_details(color_scheme_id, media, theme_id) - return '' if !stylesheet - - href = stylesheet[:new_href] - - css_class = media == 'all' ? "light-scheme" : "dark-scheme" - - %[].html_safe - end - def self.color_scheme_cache_key(color_scheme, theme_id = nil) color_scheme_name = Slug.for(color_scheme.name) + color_scheme&.id.to_s theme_string = theme_id ? "_theme#{theme_id}" : "" @@ -164,24 +53,30 @@ class Stylesheet::Manager targets += Discourse.find_plugin_css_assets(include_disabled: true, mobile_view: true, desktop_view: true) themes.each do |id, name, color_scheme_id| - targets.each do |target| - theme_id = id || SiteSetting.default_theme_id + theme_id = id || SiteSetting.default_theme_id + manager = self.new(theme_id: theme_id) + targets.each do |target| if target =~ THEME_REGEX next if theme_id == -1 - theme_ids = Theme.transform_ids([theme_id], extend: true) + scss_checker = ScssChecker.new(target, manager.theme_ids) + + manager.load_themes(manager.theme_ids).each do |theme| + builder = Stylesheet::Manager::Builder.new( + target: target, theme: theme, manager: manager + ) - theme_ids.each do |t_id| - builder = self.new(target, t_id) STDERR.puts "precompile target: #{target} #{builder.theme.name}" - next if builder.theme.component && !builder.theme.has_scss(target) + next if theme.component && !scss_checker.has_scss(theme.id) builder.compile(force: true) end else STDERR.puts "precompile target: #{target} #{name}" - builder = self.new(target, theme_id) - builder.compile(force: true) + + Stylesheet::Manager::Builder.new( + target: target, theme: manager.get_theme(theme_id), manager: manager + ).compile(force: true) end end @@ -190,8 +85,12 @@ class Stylesheet::Manager [theme_color_scheme, *color_schemes].uniq.each do |scheme| STDERR.puts "precompile target: #{COLOR_SCHEME_STYLESHEET} #{name} (#{scheme.name})" - builder = self.new(COLOR_SCHEME_STYLESHEET, id, scheme) - builder.compile(force: true) + Stylesheet::Manager::Builder.new( + target: COLOR_SCHEME_STYLESHEET, + theme: manager.get_theme(theme_id), + color_scheme: scheme, + manager: manager + ).compile(force: true) end clear_color_scheme_cache! end @@ -232,245 +131,165 @@ class Stylesheet::Manager "#{Rails.root}/#{CACHE_PATH}" end - def initialize(target = :desktop, theme_id = nil, color_scheme = nil) - @target = target - @theme_id = theme_id - @color_scheme = color_scheme + attr_reader :theme_ids + + def initialize(theme_id: nil) + @theme_id = theme_id || SiteSetting.default_theme_id + @theme_ids = Theme.transform_ids(@theme_id) + @themes_cache = {} end - def compile(opts = {}) - unless opts[:force] - if File.exists?(stylesheet_fullpath) - unless StylesheetCache.where(target: qualified_target, digest: digest).exists? - begin - source_map = begin - File.read(source_map_fullpath) - rescue Errno::ENOENT - end + def cache + self.class.cache + end - StylesheetCache.add(qualified_target, digest, File.read(stylesheet_fullpath), source_map) - rescue => e - Rails.logger.warn "Completely unexpected error adding contents of '#{stylesheet_fullpath}' to cache #{e}" - end + def get_theme(theme_id) + if theme = @themes_cache[theme_id] + theme + else + load_themes([theme_id]).first + end + end + + def load_themes(theme_ids) + themes = [] + to_load_theme_ids = [] + + theme_ids.each do |theme_id| + if @themes_cache[theme_id] + themes << @themes_cache[theme_id] + else + to_load_theme_ids << theme_id + end + end + + Theme + .where(id: to_load_theme_ids) + .includes(:yaml_theme_fields, :theme_settings, :upload_fields, :builder_theme_fields) + .each do |theme| + + @themes_cache[theme.id] = theme + themes << theme + end + + themes + end + + def stylesheet_data(target = :desktop) + stylesheet_details(target, "all") + end + + def stylesheet_link_tag(target = :desktop, media = 'all') + stylesheets = stylesheet_details(target, media) + + stylesheets.map do |stylesheet| + href = stylesheet[:new_href] + theme_id = stylesheet[:theme_id] + data_theme_id = theme_id ? "data-theme-id=\"#{theme_id}\"" : "" + %[] + end.join("\n").html_safe + end + + def stylesheet_details(target = :desktop, media = 'all') + target = target.to_sym + current_hostname = Discourse.current_hostname + + array_cache_key = "array_themes_#{@theme_ids.join(",")}_#{target}_#{current_hostname}" + stylesheets = cache[array_cache_key] + return stylesheets if stylesheets.present? + + @@lock.synchronize do + stylesheets = [] + stale_theme_ids = [] + + @theme_ids.each do |theme_id| + cache_key = "path_#{target}_#{theme_id}_#{current_hostname}" + + if href = cache[cache_key] + stylesheets << { + target: target, + theme_id: theme_id, + new_href: href + } + else + stale_theme_ids << theme_id end - return true end - end - rtl = @target.to_s =~ /_rtl$/ - css, source_map = with_load_paths do |load_paths| - Stylesheet::Compiler.compile_asset( - @target, - rtl: rtl, - theme_id: theme&.id, - theme_variables: theme&.scss_variables.to_s, - source_map_file: source_map_filename, - color_scheme_id: @color_scheme&.id, - load_paths: load_paths - ) - rescue SassC::SyntaxError => e - if Stylesheet::Importer::THEME_TARGETS.include?(@target.to_s) - # no special errors for theme, handled in theme editor - ["", nil] - elsif @target.to_s == COLOR_SCHEME_STYLESHEET - # log error but do not crash for errors in color definitions SCSS - Rails.logger.error "SCSS compilation error: #{e.message}" - ["", nil] - else - raise Discourse::ScssError, e.message + scss_checker = ScssChecker.new(target, stale_theme_ids) + + load_themes(stale_theme_ids).each do |theme| + theme_id = theme.id + data = { target: target, theme_id: theme_id } + builder = Builder.new(target: target, theme: theme, manager: self) + + next if builder.theme.component && !scss_checker.has_scss(theme_id) + builder.compile unless File.exists?(builder.stylesheet_fullpath) + href = builder.stylesheet_path(current_hostname) + + cache.defer_set("path_#{target}_#{theme_id}_#{current_hostname}", href) + + data[:new_href] = href + stylesheets << data end - end - FileUtils.mkdir_p(cache_fullpath) - - File.open(stylesheet_fullpath, "w") do |f| - f.puts css - end - - if source_map.present? - File.open(source_map_fullpath, "w") do |f| - f.puts source_map - end - end - - begin - StylesheetCache.add(qualified_target, digest, css, source_map) - rescue => e - Rails.logger.warn "Completely unexpected error adding item to cache #{e}" - end - css - end - - def cache_fullpath - self.class.cache_fullpath - end - - def stylesheet_fullpath - "#{cache_fullpath}/#{stylesheet_filename}" - end - - def source_map_fullpath - "#{cache_fullpath}/#{source_map_filename}" - end - - def source_map_filename - "#{stylesheet_filename}.map" - end - - def stylesheet_fullpath_no_digest - "#{cache_fullpath}/#{stylesheet_filename_no_digest}" - end - - def stylesheet_cdnpath(hostname) - "#{GlobalSetting.cdn_url}#{stylesheet_relpath}?__ws=#{hostname}" - end - - def stylesheet_path(hostname) - stylesheet_cdnpath(hostname) - end - - def root_path - "#{GlobalSetting.relative_url_root}/" - end - - def stylesheet_relpath - "#{root_path}stylesheets/#{stylesheet_filename}" - end - - def stylesheet_relpath_no_digest - "#{root_path}stylesheets/#{stylesheet_filename_no_digest}" - end - - def qualified_target - if is_theme? - "#{@target}_#{theme.id}" - elsif @color_scheme - "#{@target}_#{scheme_slug}_#{@color_scheme&.id.to_s}" - else - scheme_string = theme && theme.color_scheme ? "_#{theme.color_scheme.id}" : "" - "#{@target}#{scheme_string}" + cache.defer_set(array_cache_key, stylesheets.freeze) + stylesheets end end - def stylesheet_filename(with_digest = true) - digest_string = "_#{self.digest}" if with_digest - "#{qualified_target}#{digest_string}.css" - end + def color_scheme_stylesheet_details(color_scheme_id = nil, media) + theme_id = @theme_ids.first - def stylesheet_filename_no_digest - stylesheet_filename(_with_digest = false) - end + color_scheme = begin + ColorScheme.find(color_scheme_id) + rescue + # don't load fallback when requesting dark color scheme + return false if media != "all" - def is_theme? - !!(@target.to_s =~ THEME_REGEX) - end - - def scheme_slug - Slug.for(ActiveSupport::Inflector.transliterate(@color_scheme.name), 'scheme') - end - - # digest encodes the things that trigger a recompile - def digest - @digest ||= begin - if is_theme? - theme_digest - else - color_scheme_digest - end + get_theme(theme_id)&.color_scheme || ColorScheme.base end - end - def theme - @theme ||= Theme.find_by(id: @theme_id) || :nil - @theme == :nil ? nil : @theme - end + return false if !color_scheme + + target = COLOR_SCHEME_STYLESHEET.to_sym + current_hostname = Discourse.current_hostname + cache_key = self.class.color_scheme_cache_key(color_scheme, theme_id) + stylesheets = cache[cache_key] + return stylesheets if stylesheets.present? + + stylesheet = { color_scheme_id: color_scheme.id } + + theme = get_theme(theme_id) - def with_load_paths if theme - theme.with_scss_load_paths { |p| yield p } + builder = Builder.new( + target: target, + theme: get_theme(theme_id), + color_scheme: color_scheme, + manager: self + ) + + builder.compile unless File.exists?(builder.stylesheet_fullpath) + + href = builder.stylesheet_path(current_hostname) + stylesheet[:new_href] = href + cache.defer_set(cache_key, stylesheet.freeze) + stylesheet else - yield nil + {} end end - def theme_digest - if [:mobile_theme, :desktop_theme].include?(@target) - scss_digest = theme.resolve_baked_field(@target.to_s.sub("_theme", ""), :scss) - elsif @target == :embedded_theme - scss_digest = theme.resolve_baked_field(:common, :embedded_scss) - else - raise "attempting to look up theme digest for invalid field" - end + def color_scheme_stylesheet_link_tag(color_scheme_id = nil, media = 'all') + stylesheet = color_scheme_stylesheet_details(color_scheme_id, media) - Digest::SHA1.hexdigest(scss_digest.to_s + color_scheme_digest.to_s + settings_digest + plugins_digest + uploads_digest) - end + return '' if !stylesheet - # this protects us from situations where new versions of a plugin removed a file - # old instances may still be serving CSS and not aware of the change - # so we could end up poisoning the cache with a bad file that can not be removed - def plugins_digest - assets = [] - DiscoursePluginRegistry.stylesheets.each { |_, paths| assets += paths.to_a } - DiscoursePluginRegistry.mobile_stylesheets.each { |_, paths| assets += paths.to_a } - DiscoursePluginRegistry.desktop_stylesheets.each { |_, paths| assets += paths.to_a } - Digest::SHA1.hexdigest(assets.sort.join) - end + href = stylesheet[:new_href] - def settings_digest - theme_ids = Theme.components_for(@theme_id).dup - theme_ids << @theme_id + css_class = media == 'all' ? "light-scheme" : "dark-scheme" - fields = ThemeField.where( - name: "yaml", - type_id: ThemeField.types[:yaml], - theme_id: theme_ids - ).pluck(:updated_at) - - settings = ThemeSetting.where(theme_id: theme_ids).pluck(:updated_at) - timestamps = fields.concat(settings).map!(&:to_f).sort!.join(",") - - Digest::SHA1.hexdigest(timestamps) - end - - def uploads_digest - sha1s = - if (theme_ids = theme&.all_theme_variables).present? - ThemeField - .joins(:upload) - .where(id: theme_ids) - .pluck(:sha1) - .join(",") - else - "" - end - - Digest::SHA1.hexdigest(sha1s) - end - - def color_scheme_digest - cs = @color_scheme || theme&.color_scheme - - categories_updated = self.class.cache.defer_get_set("categories_updated") do - Category - .where("uploaded_background_id IS NOT NULL") - .pluck(:updated_at) - .map(&:to_i) - .sum - end - - fonts = "#{SiteSetting.base_font}-#{SiteSetting.heading_font}" - - if cs || categories_updated > 0 - theme_color_defs = theme&.resolve_baked_field(:common, :color_definitions) - Digest::SHA1.hexdigest "#{RailsMultisite::ConnectionManagement.current_db}-#{cs&.id}-#{cs&.version}-#{theme_color_defs}-#{Stylesheet::Manager.last_file_updated}-#{categories_updated}-#{fonts}" - else - digest_string = "defaults-#{Stylesheet::Manager.last_file_updated}-#{fonts}" - - if cdn_url = GlobalSetting.cdn_url - digest_string = "#{digest_string}-#{cdn_url}" - end - - Digest::SHA1.hexdigest digest_string - end + %[].html_safe end end diff --git a/lib/stylesheet/manager/builder.rb b/lib/stylesheet/manager/builder.rb new file mode 100644 index 00000000000..a04de17edea --- /dev/null +++ b/lib/stylesheet/manager/builder.rb @@ -0,0 +1,274 @@ +# frozen_string_literal: true + +class Stylesheet::Manager::Builder + attr_reader :theme + + def initialize(target: :desktop, theme:, color_scheme: nil, manager:) + @target = target + @theme = theme + @color_scheme = color_scheme + @manager = manager + end + + def compile(opts = {}) + if !opts[:force] + if File.exists?(stylesheet_fullpath) + unless StylesheetCache.where(target: qualified_target, digest: digest).exists? + begin + source_map = begin + File.read(source_map_fullpath) + rescue Errno::ENOENT + end + + StylesheetCache.add(qualified_target, digest, File.read(stylesheet_fullpath), source_map) + rescue => e + Rails.logger.warn "Completely unexpected error adding contents of '#{stylesheet_fullpath}' to cache #{e}" + end + end + return true + end + end + + rtl = @target.to_s =~ /_rtl$/ + css, source_map = with_load_paths do |load_paths| + Stylesheet::Compiler.compile_asset( + @target, + rtl: rtl, + theme_id: theme&.id, + theme_variables: theme&.scss_variables.to_s, + source_map_file: source_map_filename, + color_scheme_id: @color_scheme&.id, + load_paths: load_paths + ) + rescue SassC::SyntaxError => e + if Stylesheet::Importer::THEME_TARGETS.include?(@target.to_s) + # no special errors for theme, handled in theme editor + ["", nil] + elsif @target.to_s == Stylesheet::Manager::COLOR_SCHEME_STYLESHEET + # log error but do not crash for errors in color definitions SCSS + Rails.logger.error "SCSS compilation error: #{e.message}" + ["", nil] + else + raise Discourse::ScssError, e.message + end + end + + FileUtils.mkdir_p(cache_fullpath) + + File.open(stylesheet_fullpath, "w") do |f| + f.puts css + end + + if source_map.present? + File.open(source_map_fullpath, "w") do |f| + f.puts source_map + end + end + + begin + StylesheetCache.add(qualified_target, digest, css, source_map) + rescue => e + Rails.logger.warn "Completely unexpected error adding item to cache #{e}" + end + css + end + + def cache_fullpath + Stylesheet::Manager.cache_fullpath + end + + def stylesheet_fullpath + "#{cache_fullpath}/#{stylesheet_filename}" + end + + def source_map_fullpath + "#{cache_fullpath}/#{source_map_filename}" + end + + def source_map_filename + "#{stylesheet_filename}.map" + end + + def stylesheet_fullpath_no_digest + "#{cache_fullpath}/#{stylesheet_filename_no_digest}" + end + + def stylesheet_cdnpath(hostname) + "#{GlobalSetting.cdn_url}#{stylesheet_relpath}?__ws=#{hostname}" + end + + def stylesheet_path(hostname) + stylesheet_cdnpath(hostname) + end + + def root_path + "#{GlobalSetting.relative_url_root}/" + end + + def stylesheet_relpath + "#{root_path}stylesheets/#{stylesheet_filename}" + end + + def stylesheet_relpath_no_digest + "#{root_path}stylesheets/#{stylesheet_filename_no_digest}" + end + + def qualified_target + if is_theme? + "#{@target}_#{theme.id}" + elsif @color_scheme + "#{@target}_#{scheme_slug}_#{@color_scheme&.id.to_s}" + else + scheme_string = theme && theme.color_scheme ? "_#{theme.color_scheme.id}" : "" + "#{@target}#{scheme_string}" + end + end + + def stylesheet_filename(with_digest = true) + digest_string = "_#{self.digest}" if with_digest + "#{qualified_target}#{digest_string}.css" + end + + def stylesheet_filename_no_digest + stylesheet_filename(_with_digest = false) + end + + def is_theme? + !!(@target.to_s =~ Stylesheet::Manager::THEME_REGEX) + end + + def scheme_slug + Slug.for(ActiveSupport::Inflector.transliterate(@color_scheme.name), 'scheme') + end + + # digest encodes the things that trigger a recompile + def digest + @digest ||= begin + if is_theme? + theme_digest + else + color_scheme_digest + end + end + end + + def with_load_paths + if theme + theme.with_scss_load_paths { |p| yield p } + else + yield nil + end + end + + def scss_digest + if [:mobile_theme, :desktop_theme].include?(@target) + resolve_baked_field(@target.to_s.sub("_theme", ""), :scss) + elsif @target == :embedded_theme + resolve_baked_field(:common, :embedded_scss) + else + raise "attempting to look up theme digest for invalid field" + end + end + + def theme_digest + Digest::SHA1.hexdigest(scss_digest.to_s + color_scheme_digest.to_s + settings_digest + plugins_digest + uploads_digest) + end + + # this protects us from situations where new versions of a plugin removed a file + # old instances may still be serving CSS and not aware of the change + # so we could end up poisoning the cache with a bad file that can not be removed + def plugins_digest + assets = [] + DiscoursePluginRegistry.stylesheets.each { |_, paths| assets += paths.to_a } + DiscoursePluginRegistry.mobile_stylesheets.each { |_, paths| assets += paths.to_a } + DiscoursePluginRegistry.desktop_stylesheets.each { |_, paths| assets += paths.to_a } + Digest::SHA1.hexdigest(assets.sort.join) + end + + def settings_digest + theme_ids = Theme.is_parent_theme?(theme.id) ? @manager.theme_ids : [theme.id] + + themes = + if Theme.is_parent_theme?(theme.id) + @manager.load_themes(@manager.theme_ids) + else + [@manager.get_theme(theme.id)] + end + + fields = themes.each_with_object([]) do |theme, array| + array.concat(theme.yaml_theme_fields.map(&:updated_at)) + end + + settings = themes.each_with_object([]) do |theme, array| + array.concat(theme.theme_settings.map(&:updated_at)) + end + + timestamps = fields.concat(settings).map!(&:to_f).sort!.join(",") + + Digest::SHA1.hexdigest(timestamps) + end + + def uploads_digest + sha1s = [] + + theme.upload_fields.map do |upload_field| + sha1s << upload_field.upload.sha1 + end + + Digest::SHA1.hexdigest(sha1s.sort!.join("\n")) + end + + def color_scheme_digest + cs = @color_scheme || theme&.color_scheme + + categories_updated = Stylesheet::Manager.cache.defer_get_set("categories_updated") do + Category + .where("uploaded_background_id IS NOT NULL") + .pluck(:updated_at) + .map(&:to_i) + .sum + end + + fonts = "#{SiteSetting.base_font}-#{SiteSetting.heading_font}" + + if cs || categories_updated > 0 + theme_color_defs = resolve_baked_field(:common, :color_definitions) + Digest::SHA1.hexdigest "#{RailsMultisite::ConnectionManagement.current_db}-#{cs&.id}-#{cs&.version}-#{theme_color_defs}-#{Stylesheet::Manager.last_file_updated}-#{categories_updated}-#{fonts}" + else + digest_string = "defaults-#{Stylesheet::Manager.last_file_updated}-#{fonts}" + + if cdn_url = GlobalSetting.cdn_url + digest_string = "#{digest_string}-#{cdn_url}" + end + + Digest::SHA1.hexdigest digest_string + end + end + + def resolve_baked_field(target, name) + theme_ids = + if Theme.is_parent_theme?(theme.id) + @manager.theme_ids + else + [theme.id] + end + + theme_ids = [theme_ids.first] if name != :color_definitions + + baked_fields = [] + targets = [Theme.targets[target.to_sym], Theme.targets[:common]] + + @manager.load_themes(theme_ids).each do |theme| + theme.builder_theme_fields.each do |theme_field| + if theme_field.name == name.to_s && targets.include?(theme_field.target_id) + baked_fields << theme_field + end + end + end + + baked_fields.map do |f| + f.ensure_baked! + f.value_baked || f.value + end.join("\n") + end +end diff --git a/lib/stylesheet/manager/scss_checker.rb b/lib/stylesheet/manager/scss_checker.rb new file mode 100644 index 00000000000..117b4ccfe1e --- /dev/null +++ b/lib/stylesheet/manager/scss_checker.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class Stylesheet::Manager::ScssChecker + def initialize(target, theme_ids) + @target = target.to_sym + @theme_ids = theme_ids + end + + def has_scss(theme_id) + !!get_themes_with_scss[theme_id] + end + + private + + def get_themes_with_scss + @themes_with_scss ||= begin + theme_target = @target.to_sym + theme_target = :mobile if theme_target == :mobile_theme + theme_target = :desktop if theme_target == :desktop_theme + name = @target == :embedded_theme ? :embedded_scss : :scss + + results = Theme + .where(id: @theme_ids) + .left_joins(:theme_fields) + .where(theme_fields: { + target_id: [Theme.targets[theme_target], Theme.targets[:common]], + name: name + }) + .group(:id) + .size + + results + end + end +end diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb index b2010fe4105..0754ab7b2a4 100644 --- a/lib/svg_sprite/svg_sprite.rb +++ b/lib/svg_sprite/svg_sprite.rb @@ -228,12 +228,12 @@ module SvgSprite badge_icons end - def self.custom_svg_sprites(theme_ids = []) - get_set_cache("custom_svg_sprites_#{Theme.transform_ids(theme_ids).join(',')}") do + def self.custom_svg_sprites(theme_id) + get_set_cache("custom_svg_sprites_#{Theme.transform_ids(theme_id).join(',')}") do custom_sprite_paths = Dir.glob("#{Rails.root}/plugins/*/svg-icons/*.svg") - if theme_ids.present? - ThemeField.where(type_id: ThemeField.types[:theme_upload_var], name: THEME_SPRITE_VAR_NAME, theme_id: Theme.transform_ids(theme_ids)) + if theme_id.present? + ThemeField.where(type_id: ThemeField.types[:theme_upload_var], name: THEME_SPRITE_VAR_NAME, theme_id: Theme.transform_ids(theme_id)) .pluck(:upload_id).each do |upload_id| upload = Upload.find(upload_id) rescue nil @@ -253,15 +253,15 @@ module SvgSprite end end - def self.all_icons(theme_ids = []) - get_set_cache("icons_#{Theme.transform_ids(theme_ids).join(',')}") do + def self.all_icons(theme_id = nil) + get_set_cache("icons_#{Theme.transform_ids(theme_id).join(',')}") do Set.new() .merge(settings_icons) .merge(plugin_icons) .merge(badge_icons) .merge(group_icons) - .merge(theme_icons(theme_ids)) - .merge(custom_icons(theme_ids)) + .merge(theme_icons(theme_id)) + .merge(custom_icons(theme_id)) .delete_if { |i| i.blank? || i.include?("/") } .map! { |i| process(i.dup) } .merge(SVG_ICONS) @@ -269,25 +269,25 @@ module SvgSprite end end - def self.version(theme_ids = []) - get_set_cache("version_#{Theme.transform_ids(theme_ids).join(',')}") do - Digest::SHA1.hexdigest(bundle(theme_ids)) + def self.version(theme_id = nil) + get_set_cache("version_#{Theme.transform_ids(theme_id).join(',')}") do + Digest::SHA1.hexdigest(bundle(theme_id)) end end - def self.path(theme_ids = []) - "/svg-sprite/#{Discourse.current_hostname}/svg-#{theme_ids&.join(",")}-#{version(theme_ids)}.js" + def self.path(theme_id = nil) + "/svg-sprite/#{Discourse.current_hostname}/svg-#{theme_id}-#{version(theme_id)}.js" end def self.expire_cache cache&.clear end - def self.sprite_sources(theme_ids) + def self.sprite_sources(theme_id) sources = CORE_SVG_SPRITES - if theme_ids.present? - sources = sources + custom_svg_sprites(theme_ids) + if theme_id.present? + sources = sources + custom_svg_sprites(theme_id) end sources @@ -313,8 +313,8 @@ module SvgSprite end end - def self.bundle(theme_ids = []) - icons = all_icons(theme_ids) + def self.bundle(theme_id = nil) + icons = all_icons(theme_id) svg_subset = """