Class: ThemeField

Inherits:
ActiveRecord::Base
  • Object
show all
Defined in:
app/models/theme_field.rb

Defined Under Namespace

Classes: ThemeFileMatcher

Constant Summary collapse

MIGRATION_NAME_PART_MAX_LENGTH =
150
CSP_NONCE_PLACEHOLDER =

This string is not ‘secret’. It’s just randomized to avoid accidental clashes with genuine theme field content.

"__CSP__NONCE__PLACEHOLDER__f72bff1b1768168a34ee092ce759f192__"
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" },
)

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.basic_targetsObject



394
395
396
# File 'app/models/theme_field.rb', line 394

def self.basic_targets
  @basic_targets ||= %w[common desktop mobile]
end

.css_theme_type_idsObject



100
101
102
# File 'app/models/theme_field.rb', line 100

def self.css_theme_type_ids
  @css_theme_type_ids ||= [0, 1]
end

.force_recompilation!Object



104
105
106
107
108
109
# File 'app/models/theme_field.rb', line 104

def self.force_recompilation!
  find_each do |field|
    field.compiler_version = 0
    field.ensure_baked!
  end
end

.guess_type(name:, target:) ⇒ Object



372
373
374
375
376
377
378
379
380
381
382
383
384
# File 'app/models/theme_field.rb', line 372

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

.html_fieldsObject



386
387
388
# File 'app/models/theme_field.rb', line 386

def self.html_fields
  @html_fields ||= %w[body_tag head_tag header footer after_header embedded_header]
end

.opts_from_file_path(filename) ⇒ Object



677
678
679
680
681
682
683
684
# File 'app/models/theme_field.rb', line 677

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

.scss_fieldsObject



390
391
392
# File 'app/models/theme_field.rb', line 390

def self.scss_fields
  @scss_fields ||= %w[scss embedded_scss color_definitions]
end

.theme_var_type_idsObject



96
97
98
# File 'app/models/theme_field.rb', line 96

def self.theme_var_type_ids
  @theme_var_type_ids ||= [2]
end

.typesObject



82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'app/models/theme_field.rb', line 82

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

Instance Method Details

#basic_html_field?Boolean

Returns:

  • (Boolean)


398
399
400
401
# File 'app/models/theme_field.rb', line 398

def basic_html_field?
  ThemeField.basic_targets.include?(Theme.targets[self.target_id].to_s) &&
    ThemeField.html_fields.include?(self.name)
end

#basic_scss_field?Boolean

Returns:

  • (Boolean)


411
412
413
414
# File 'app/models/theme_field.rb', line 411

def basic_scss_field?
  ThemeField.basic_targets.include?(Theme.targets[self.target_id].to_s) &&
    ThemeField.scss_fields.include?(self.name)
end

#compile_scss(prepended_scss = nil) ⇒ Object



483
484
485
486
487
488
489
490
491
492
493
494
# File 'app/models/theme_field.rb', line 483

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

#compiled_css(prepended_scss) ⇒ Object



496
497
498
499
500
501
502
503
504
505
506
507
# File 'app/models/theme_field.rb', line 496

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

#contains_ember_css_selector?(text) ⇒ Boolean

Returns:

  • (Boolean)


535
536
537
# File 'app/models/theme_field.rb', line 535

def contains_ember_css_selector?(text)
  text.match(/#ember\d+|[.]ember-view/)
end

#contains_optimized_link?(text) ⇒ Boolean

Returns:

  • (Boolean)


531
532
533
# File 'app/models/theme_field.rb', line 531

def contains_optimized_link?(text)
  OptimizedImage::URL_REGEX.match?(text)
end

#dependent_fieldsObject



686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
# File 'app/models/theme_field.rb', line 686

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

#ensure_baked!Object



437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
# File 'app/models/theme_field.rb', line 437

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

#ensure_scss_compiles!Object



509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
# File 'app/models/theme_field.rb', line 509

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

#extra_js_field?Boolean

Returns:

  • (Boolean)


403
404
405
# File 'app/models/theme_field.rb', line 403

def extra_js_field?
  Theme.targets[self.target_id] == :extra_js
end

#extra_scss_field?Boolean

Returns:

  • (Boolean)


416
417
418
# File 'app/models/theme_field.rb', line 416

def extra_scss_field?
  Theme.targets[self.target_id] == :extra_scss
end

#file_pathObject

For now just work for standard fields



662
663
664
665
666
667
668
669
670
671
672
673
674
675
# File 'app/models/theme_field.rb', line 662

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

#invalidate_baked!Object



707
708
709
710
# File 'app/models/theme_field.rb', line 707

def invalidate_baked!
  update_column(:value_baked, nil)
  dependent_fields.update_all(value_baked: nil)
end

#js_tests_field?Boolean

Returns:

  • (Boolean)


407
408
409
# File 'app/models/theme_field.rb', line 407

def js_tests_field?
  Theme.targets[self.target_id] == :tests_js
end

#migration_field?Boolean

Returns:

  • (Boolean)


433
434
435
# File 'app/models/theme_field.rb', line 433

def migration_field?
  Theme.targets[:migrations] == self.target_id
end

#process_html(html) ⇒ Object



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'app/models/theme_field.rb', line 119

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

#process_translationObject



283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'app/models/theme_field.rb', line 283

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

#raw_translation_data(internal: false) ⇒ Object



250
251
252
253
# File 'app/models/theme_field.rb', line 250

def raw_translation_data(internal: false)
  # Might raise ThemeTranslationParser::InvalidYaml
  ThemeTranslationParser.new(self, internal: internal).load
end

#settings_field?Boolean

Returns:

  • (Boolean)


420
421
422
# File 'app/models/theme_field.rb', line 420

def settings_field?
  Theme.targets[:settings] == self.target_id
end

#svg_sprite_field?Boolean

Returns:

  • (Boolean)


428
429
430
431
# File 'app/models/theme_field.rb', line 428

def svg_sprite_field?
  ThemeField.theme_var_type_ids.include?(self.type_id) &&
    self.name == SvgSprite.theme_sprite_variable_name
end

#target_nameObject



527
528
529
# File 'app/models/theme_field.rb', line 527

def target_name
  Theme.targets[target_id].to_s
end

#translation_data(with_overrides: true, internal: false, fallback_fields: nil) ⇒ Object



255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'app/models/theme_field.rb', line 255

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

#translation_field?Boolean

Returns:

  • (Boolean)


424
425
426
# File 'app/models/theme_field.rb', line 424

def translation_field?
  Theme.targets[:translations] == self.target_id
end

#upload_urlObject



744
745
746
# File 'app/models/theme_field.rb', line 744

def upload_url
  self.upload&.url
end

#upsert_svg_sprite!Object



725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
# File 'app/models/theme_field.rb', line 725

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

#validate_svg_sprite_xmlObject



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'app/models/theme_field.rb', line 220

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

#validate_yaml!Object



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
# File 'app/models/theme_field.rb', line 328

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