discourse/app/models/theme_field.rb

806 lines
24 KiB
Ruby

# frozen_string_literal: true
class ThemeField < ActiveRecord::Base
MIGRATION_NAME_PART_MAX_LENGTH = 150
# This string is not 'secret'. It's just randomized to avoid accidental clashes with genuine theme field content.
CSP_NONCE_PLACEHOLDER = "__CSP__NONCE__PLACEHOLDER__f72bff1b1768168a34ee092ce759f192__"
belongs_to :upload
has_one :javascript_cache, dependent: :destroy
has_one :upload_reference, as: :target, dependent: :destroy
has_one :theme_settings_migration
validates :value, { length: { maximum: 1024**2 } }
validate :migration_filename_is_valid, if: :migration_field?
after_save do
if (
self.type_id == ThemeField.types[:theme_screenshot_upload_var] ||
self.type_id == ThemeField.types[:theme_upload_var]
) && saved_change_to_upload_id?
UploadReference.ensure_exist!(upload_ids: [self.upload_id], target: self)
end
end
after_save do
dependent_fields.each(&:invalidate_baked!)
if upload && svg_sprite_field?
upsert_svg_sprite!
SvgSprite.expire_cache
end
end
after_destroy do
if svg_sprite_field?
ThemeSvgSprite.where(theme_id: theme_id).delete_all
SvgSprite.expire_cache
end
end
scope :find_by_theme_ids,
->(theme_ids) do
return none if theme_ids.blank?
where(theme_id: theme_ids).joins(
"JOIN (
SELECT #{theme_ids.map.with_index { |id, idx| "#{id.to_i} AS theme_id, #{idx} AS theme_sort_column" }.join(" UNION ALL SELECT ")}
) as X ON X.theme_id = theme_fields.theme_id",
).order("theme_sort_column")
end
scope :filter_locale_fields,
->(locale_codes) do
return none if locale_codes.blank?
where(target_id: Theme.targets[:translations], name: locale_codes).joins(
DB.sql_fragment(
"JOIN (
SELECT * FROM (VALUES #{locale_codes.map { "(?)" }.join(",")}) as Y (locale_code, locale_sort_column)
) as Y ON Y.locale_code = theme_fields.name",
*locale_codes.map.with_index { |code, index| [code, index] },
),
).order("Y.locale_sort_column")
end
scope :find_first_locale_fields,
->(theme_ids, locale_codes) do
find_by_theme_ids(theme_ids)
.filter_locale_fields(locale_codes)
.reorder("X.theme_sort_column", "Y.locale_sort_column")
.select("DISTINCT ON (X.theme_sort_column) *")
end
scope :svg_sprite_fields,
-> do
where(type_id: ThemeField.theme_var_type_ids, name: SvgSprite.theme_sprite_variable_name)
end
def self.types
@types ||=
Enum.new(
html: 0,
scss: 1,
theme_upload_var: 2,
theme_color_var: 3, # No longer used
theme_var: 4, # No longer used
yaml: 5,
js: 6,
theme_screenshot_upload_var: 7,
)
end
def self.theme_var_type_ids
@theme_var_type_ids ||= [2]
end
def self.css_theme_type_ids
@css_theme_type_ids ||= [0, 1]
end
def self.force_recompilation!
find_each do |field|
field.compiler_version = 0
field.ensure_baked!
end
end
validates :name,
format: {
with: /\A[a-z_][a-z0-9_-]*\z/i,
},
if: Proc.new { |field| ThemeField.theme_var_type_ids.include?(field.type_id) }
belongs_to :theme
def process_html(html)
errors = []
javascript_cache || build_javascript_cache
errors << I18n.t("themes.errors.optimized_link") if contains_optimized_link?(html)
js_compiler = ThemeJavascriptCompiler.new(theme_id, self.theme.name)
doc = Nokogiri::HTML5.fragment(html)
doc
.css('script[type="text/x-handlebars"]')
.each do |node|
name = node["name"] || node["data-template-name"] || "broken"
is_raw = name =~ /\.(raw|hbr)\z/
hbs_template = node.inner_html
begin
if is_raw
js_compiler.append_raw_template(name, hbs_template)
else
js_compiler.append_ember_template(
"discourse/templates/#{name.delete_prefix("/")}",
hbs_template,
)
end
rescue ThemeJavascriptCompiler::CompileError => ex
js_compiler.append_js_error("discourse/templates/#{name}", ex.message)
errors << ex.message
end
node.remove
end
doc
.css('script[type="text/discourse-plugin"]')
.each_with_index do |node, index|
version = node["version"]
next if version.blank?
initializer_name =
"theme-field" + "-#{self.id}" + "-#{Theme.targets[self.target_id]}" +
"-#{ThemeField.types[self.type_id]}" + "-script-#{index + 1}"
begin
js = <<~JS
import { withPluginApi } from "discourse/lib/plugin-api";
export default {
name: #{initializer_name.inspect},
after: "inject-objects",
initialize() {
withPluginApi(#{version.inspect}, (api) => {
#{node.inner_html}
});
}
};
JS
js_compiler.append_module(
js,
"discourse/initializers/#{initializer_name}",
"js",
include_variables: true,
)
rescue ThemeJavascriptCompiler::CompileError => ex
js_compiler.append_js_error("discourse/initializers/#{initializer_name}", ex.message)
errors << ex.message
end
node.remove
end
doc
.css("script")
.each_with_index do |node, index|
if inline_javascript?(node)
js_compiler.append_raw_script(
"_html/#{Theme.targets[self.target_id]}/#{name}_#{index + 1}.js",
node.inner_html,
)
node.remove
else
node["nonce"] = CSP_NONCE_PLACEHOLDER
end
end
settings_hash = theme.build_settings_hash
if js_compiler.has_content? && settings_hash.present?
js_compiler.prepend_settings(settings_hash)
end
javascript_cache.content = js_compiler.content
javascript_cache.source_map = js_compiler.source_map
javascript_cache.save!
doc.add_child(<<~HTML.html_safe) if javascript_cache.content.present?
<script defer src='#{javascript_cache.url}' data-theme-id='#{theme_id}' nonce="#{CSP_NONCE_PLACEHOLDER}"></script>
HTML
[doc.to_s, errors&.join("\n")]
end
def validate_svg_sprite_xml
upload =
begin
Upload.find(self.upload_id)
rescue StandardError
nil
end
if Discourse.store.external?
external_copy = Discourse.store.download_safe(upload)
path = external_copy&.path
else
path = Discourse.store.path_for(upload)
end
error = nil
begin
content = File.read(path)
if content.to_s.bytesize > SvgSprite::MAX_THEME_SPRITE_SIZE
error = "Error with #{self.name}: Icon sprite file is too large"
else
Nokogiri.XML(content) { |config| config.options = Nokogiri::XML::ParseOptions::NOBLANKS }
end
rescue => e
error = "Error with #{self.name}: #{e.inspect}"
end
error
end
def raw_translation_data(internal: false)
# Might raise ThemeTranslationParser::InvalidYaml
ThemeTranslationParser.new(self, internal: internal).load
end
def translation_data(with_overrides: true, internal: false, fallback_fields: nil)
fallback_fields ||= theme.theme_fields.filter_locale_fields(I18n.fallbacks[name])
fallback_data =
fallback_fields.each_with_index.map do |field, index|
begin
field.raw_translation_data(internal: internal)
rescue ThemeTranslationParser::InvalidYaml
# If this is the locale with the error, raise it.
# If not, let the other theme_field raise the error when it processes itself
raise if field.id == id
{}
end
end
# TODO: Deduplicate the fallback data in the same way as JSLocaleHelper#load_translations_merged
# this would reduce the size of the payload, without affecting functionality
data = {}
fallback_data.each { |hash| data.merge!(hash) }
if with_overrides
overrides = theme.translation_override_hash.deep_symbolize_keys
data.deep_merge!(overrides)
end
data
end
def process_translation
errors = []
javascript_cache || build_javascript_cache
js_compiler = ThemeJavascriptCompiler.new(theme_id, self.theme.name)
begin
data = translation_data
js = <<~JS
export default {
name: "theme-#{theme_id}-translations",
initialize() {
/* Translation data for theme #{self.theme_id} (#{self.name})*/
const data = #{data.to_json};
for (let lang in data){
let cursor = I18n.translations;
for (let key of [lang, "js", "theme_translations"]){
cursor = cursor[key] = cursor[key] || {};
}
cursor[#{self.theme_id}] = data[lang];
}
}
};
JS
js_compiler.append_module(
js,
"discourse/pre-initializers/theme-#{theme_id}-translations",
"js",
include_variables: false,
)
rescue ThemeTranslationParser::InvalidYaml => e
errors << e.message
end
javascript_cache.content = js_compiler.content
javascript_cache.source_map = js_compiler.source_map
javascript_cache.save!
doc = ""
doc = <<~HTML.html_safe if javascript_cache.content.present?
<script defer src="#{javascript_cache.url}" data-theme-id="#{theme_id}" nonce="#{ThemeField::CSP_NONCE_PLACEHOLDER}"></script>
HTML
[doc, errors&.join("\n")]
end
def validate_yaml!
return unless self.name == "yaml"
errors = []
begin
ThemeSettingsParser
.new(self)
.load do |name, default, type, opts|
setting = ThemeSetting.new(name: name, data_type: type, theme: theme)
translation_key = "themes.settings_errors"
if setting.invalid?
setting.errors.details.each_pair do |attribute, _errors|
_errors.each do |hash|
errors << I18n.t("#{translation_key}.#{attribute}_#{hash[:error]}", name: name)
end
end
end
unless ThemeSettingsValidator.is_value_present?(default)
errors << I18n.t("#{translation_key}.default_value_missing", name: name)
next
end
unless ThemeSettingsValidator.is_valid_value_type?(default, type)
errors << I18n.t("#{translation_key}.default_not_match_type", name: name)
end
if (setting_errors = ThemeSettingsValidator.validate_value(default, type, opts)).present?
errors << I18n.t(
"#{translation_key}.default_value_not_valid",
name: name,
error_messages: setting_errors.join(" "),
)
end
end
rescue ThemeSettingsParser::InvalidYaml => e
errors << e.message
end
self.error = errors.join("\n").presence
end
def self.guess_type(name:, target:)
if basic_targets.include?(target.to_s) && html_fields.include?(name.to_s)
types[:html]
elsif basic_targets.include?(target.to_s) && scss_fields.include?(name.to_s)
types[:scss]
elsif target.to_s == "extra_scss"
types[:scss]
elsif %w[migrations extra_js].include?(target.to_s)
types[:js]
elsif target.to_s == "settings" || target.to_s == "translations"
types[:yaml]
end
end
def self.html_fields
@html_fields ||= %w[body_tag head_tag header footer after_header embedded_header]
end
def self.scss_fields
@scss_fields ||= %w[scss embedded_scss color_definitions]
end
def self.basic_targets
@basic_targets ||= %w[common desktop mobile]
end
def basic_html_field?
ThemeField.basic_targets.include?(Theme.targets[self.target_id].to_s) &&
ThemeField.html_fields.include?(self.name)
end
def extra_js_field?
Theme.targets[self.target_id] == :extra_js
end
def js_tests_field?
Theme.targets[self.target_id] == :tests_js
end
def basic_scss_field?
ThemeField.basic_targets.include?(Theme.targets[self.target_id].to_s) &&
ThemeField.scss_fields.include?(self.name)
end
def extra_scss_field?
Theme.targets[self.target_id] == :extra_scss
end
def settings_field?
Theme.targets[:settings] == self.target_id
end
def translation_field?
Theme.targets[:translations] == self.target_id
end
def svg_sprite_field?
ThemeField.theme_var_type_ids.include?(self.type_id) &&
self.name == SvgSprite.theme_sprite_variable_name
end
def migration_field?
Theme.targets[:migrations] == self.target_id
end
def ensure_baked!
needs_baking = !self.value_baked || compiler_version != Theme.compiler_version
return unless needs_baking
if basic_html_field? || translation_field?
self.value_baked, self.error =
translation_field? ? process_translation : process_html(self.value)
self.error = nil if self.error.blank?
self.compiler_version = Theme.compiler_version
CSP::Extension.clear_theme_extensions_cache!
elsif extra_js_field? || js_tests_field?
self.error = nil
self.value_baked = "baked"
self.compiler_version = Theme.compiler_version
elsif basic_scss_field?
ensure_scss_compiles!
Stylesheet::Manager.clear_theme_cache!
elsif settings_field?
validate_yaml!
CSP::Extension.clear_theme_extensions_cache!
SvgSprite.expire_cache
self.value_baked = "baked"
self.compiler_version = Theme.compiler_version
elsif svg_sprite_field?
SvgSprite.expire_cache
self.error = validate_svg_sprite_xml
self.value_baked = "baked"
self.compiler_version = Theme.compiler_version
elsif migration_field?
self.value_baked = "baked"
self.compiler_version = Theme.compiler_version
end
if self.will_save_change_to_value_baked? || self.will_save_change_to_compiler_version? ||
self.will_save_change_to_error?
self.update_columns(
value_baked: value_baked,
compiler_version: compiler_version,
error: error,
)
end
rescue ActiveRecord::ReadOnlyError
# Just noop if ActiveRecord is preventing writes for now. In an ideal world, this method will not be called in GET
# requests.
end
def compile_scss(prepended_scss = nil)
prepended_scss ||= Stylesheet::Importer.new({}).prepended_scss
self.theme.with_scss_load_paths do |load_paths|
Stylesheet::Compiler.compile(
"#{prepended_scss} #{self.theme.scss_variables} #{self.value}",
"#{Theme.targets[self.target_id]}.scss",
theme: self.theme,
load_paths: load_paths,
)
end
end
def compiled_css(prepended_scss)
css, _source_map =
begin
compile_scss(prepended_scss)
rescue SassC::SyntaxError => e
# We don't want to raise a blocking error here
# admin theme editor or discourse_theme CLI will show it nonetheless
Rails.logger.error "SCSS compilation error: #{e.message}"
["", nil]
end
css
end
def ensure_scss_compiles!
result = ["failed"]
begin
result = compile_scss
if contains_optimized_link?(self.value)
self.error = I18n.t("themes.errors.optimized_link")
elsif contains_ember_css_selector?(self.value)
self.error = I18n.t("themes.ember_selector_error")
else
self.error = nil unless error.nil?
end
rescue SassC::SyntaxError, SassC::NotRenderedError => e
self.error = e.message unless self.destroyed?
end
self.compiler_version = Theme.compiler_version
self.value_baked = Digest::SHA1.hexdigest(result.join(",")) # We don't use the compiled CSS here, we just use it to invalidate the stylesheet cache
end
def target_name
Theme.targets[target_id].to_s
end
def contains_optimized_link?(text)
OptimizedImage::URL_REGEX.match?(text)
end
def contains_ember_css_selector?(text)
text.match(/#ember\d+|[.]ember-view/)
end
class ThemeFileMatcher
OPTIONS = %i[name type target]
# regex: used to match file names to fields (import).
# can contain named capture groups for name/type/target
# canonical: a lambda which converts name/type/target
# to filename (export)
# targets/names/types: can be nil if any value is allowed
# single value
# array of allowed values
def initialize(regex:, canonical:, targets:, names:, types:)
@allowed_values = {}
@allowed_values[:names] = Array(names) if names
@allowed_values[:targets] = Array(targets) if targets
@allowed_values[:types] = Array(types) if types
@canonical = canonical
@regex = regex
end
def opts_from_filename(filename)
match = @regex.match(filename)
return false unless match
hash = {}
OPTIONS.each do |option|
plural = :"#{option}s"
hash[option] = @allowed_values[plural][0] if @allowed_values[plural]&.length == 1
hash[option] = match[option] if hash[option].nil?
end
hash
end
def filename_from_opts(opts)
is_match =
OPTIONS.all? do |option|
plural = :"#{option}s"
next true if @allowed_values[plural] == nil # Allows any value
next true if @allowed_values[plural].include?(opts[option]) # Value is allowed
end
is_match ? @canonical.call(opts) : nil
end
end
FILE_MATCHERS = [
ThemeFileMatcher.new(
regex:
%r{\A(?<target>(?:mobile|desktop|common))/(?<name>(?:head_tag|header|after_header|body_tag|footer))\.html\z},
targets: %i[mobile desktop common],
names: %w[head_tag header after_header body_tag footer],
types: :html,
canonical: ->(h) { "#{h[:target]}/#{h[:name]}.html" },
),
ThemeFileMatcher.new(
regex: %r{\A(?<target>(?:mobile|desktop|common))/(?:\k<target>)\.scss\z},
targets: %i[mobile desktop common],
names: "scss",
types: :scss,
canonical: ->(h) { "#{h[:target]}/#{h[:target]}.scss" },
),
ThemeFileMatcher.new(
regex: %r{\Acommon/embedded\.scss\z},
targets: :common,
names: "embedded_scss",
types: :scss,
canonical: ->(h) { "common/embedded.scss" },
),
ThemeFileMatcher.new(
regex: %r{\Acommon/color_definitions\.scss\z},
targets: :common,
names: "color_definitions",
types: :scss,
canonical: ->(h) { "common/color_definitions.scss" },
),
ThemeFileMatcher.new(
regex: %r{\A(?:scss|stylesheets)/(?<name>.+)\.scss\z},
targets: :extra_scss,
names: nil,
types: :scss,
canonical: ->(h) { "stylesheets/#{h[:name]}.scss" },
),
ThemeFileMatcher.new(
regex: %r{\Ajavascripts/(?<name>.+)\z},
targets: :extra_js,
names: nil,
types: :js,
canonical: ->(h) { "javascripts/#{h[:name]}" },
),
ThemeFileMatcher.new(
regex: %r{\Atest/(?<name>.+)\z},
targets: :tests_js,
names: nil,
types: :js,
canonical: ->(h) { "test/#{h[:name]}" },
),
ThemeFileMatcher.new(
regex: /\Asettings\.ya?ml\z/,
names: "yaml",
types: :yaml,
targets: :settings,
canonical: ->(h) { "settings.yml" },
),
ThemeFileMatcher.new(
regex: %r{\Alocales/(?<name>(?:#{I18n.available_locales.join("|")}))\.yml\z},
names: I18n.available_locales.map(&:to_s),
types: :yaml,
targets: :translations,
canonical: ->(h) { "locales/#{h[:name]}.yml" },
),
ThemeFileMatcher.new(
regex: /(?!)/, # Never match uploads by filename, they must be named in about.json
names: nil,
types: :theme_upload_var,
targets: :common,
canonical: ->(h) { "assets/#{h[:name]}#{File.extname(h[:filename])}" },
),
ThemeFileMatcher.new(
regex: %r{\Amigrations/settings/(?<name>[^/]+)\.js\z},
names: nil,
types: :js,
targets: :migrations,
canonical: ->(h) { "migrations/settings/#{h[:name]}.js" },
),
]
# For now just work for standard fields
def file_path
FILE_MATCHERS.each do |matcher|
if filename =
matcher.filename_from_opts(
target: target_name.to_sym,
name: name,
type: ThemeField.types[type_id],
filename: upload&.original_filename,
)
return filename
end
end
nil # Not a file (e.g. a theme variable/color)
end
def self.opts_from_file_path(filename)
FILE_MATCHERS.each do |matcher|
if opts = matcher.opts_from_filename(filename)
return opts
end
end
nil
end
def dependent_fields
if extra_scss_field?
return(
theme.theme_fields.where(
target_id: ThemeField.basic_targets.map { |t| Theme.targets[t.to_sym] },
name: ThemeField.scss_fields,
)
)
elsif settings_field?
return(
theme.theme_fields.where(
target_id: ThemeField.basic_targets.map { |t| Theme.targets[t.to_sym] },
name: ThemeField.scss_fields + ThemeField.html_fields,
)
)
elsif translation_field? && name == "en" # en is fallback for all other locales
return theme.theme_fields.where(target_id: Theme.targets[:translations]).where.not(name: "en")
end
ThemeField.none
end
def invalidate_baked!
update_column(:value_baked, nil)
dependent_fields.update_all(value_baked: nil)
end
before_save do
if (will_save_change_to_value? || will_save_change_to_upload_id?) &&
!will_save_change_to_value_baked?
self.value_baked = nil
end
if upload && upload.extension == "js"
if will_save_change_to_upload_id? || !javascript_cache
javascript_cache ||= build_javascript_cache
javascript_cache.content = upload.content
end
end
end
def upsert_svg_sprite!
begin
content = upload.content
rescue => e
Discourse.warn_exception(e, message: "Failed to fetch svg sprite for theme field #{id}")
else
if content.length > SvgSprite::MAX_THEME_SPRITE_SIZE
Rails.logger.warn(
"can't store theme svg sprite for theme #{theme_id} and upload #{upload_id}, sprite too big",
)
else
ThemeSvgSprite.upsert(
{ theme_id: theme_id, upload_id: upload_id, sprite: content },
unique_by: :theme_id,
)
end
end
end
def upload_url
self.upload&.url
end
private
JAVASCRIPT_TYPES = %w[text/javascript application/javascript application/ecmascript]
def inline_javascript?(node)
if node["src"].present?
false
elsif node["type"].present?
JAVASCRIPT_TYPES.include?(node["type"].downcase)
else
true
end
end
def migration_filename_is_valid
if !name.match?(/\A\d{4}-[a-zA-Z0-9]+/)
self.errors.add(
:base,
I18n.t("themes.import_error.migrations.invalid_filename", filename: name),
)
return
end
# the 5 here is the length of the first 4 digits and the dash that follows
# them
if name.size - 5 > MIGRATION_NAME_PART_MAX_LENGTH
self.errors.add(
:base,
I18n.t(
"themes.import_error.migrations.name_too_long",
count: MIGRATION_NAME_PART_MAX_LENGTH,
),
)
end
end
end
# == Schema Information
#
# Table name: theme_fields
#
# id :integer not null, primary key
# theme_id :integer not null
# target_id :integer not null
# name :string(255) not null
# value :text not null
# value_baked :text
# created_at :datetime not null
# updated_at :datetime not null
# compiler_version :string(50) default("0"), not null
# error :string
# upload_id :integer
# type_id :integer default(0), not null
#
# Indexes
#
# theme_field_unique_index (theme_id,target_id,type_id,name) UNIQUE
#