Module: Discourse

Defined in:
lib/version.rb,
lib/discourse.rb

Defined Under Namespace

Modules: VERSION Classes: CSRF, Deprecation, ImageMagickMissing, InvalidAccess, InvalidMigration, InvalidParameters, InvalidVersionListError, NotFound, NotLoggedIn, ReadOnly, ScssError, SiteSettingMissing, TooManyMatches, Utils

Constant Summary collapse

VERSION_REGEXP =
/\A\d+\.\d+\.\d+(\.beta\d+)?\z/
VERSION_COMPATIBILITY_FILENAME =
".discourse-compatibility"
DB_POST_MIGRATE_PATH =
"db/post_migrate"
REQUESTED_HOSTNAME =
"REQUESTED_HOSTNAME"
MAX_METADATA_FILE_SIZE =
64.kilobytes
PIXEL_RATIOS =

list of pixel ratios Discourse tries to optimize for

[1, 1.5, 2, 3]
BUILTIN_AUTH =
[
  Auth::AuthProvider.new(
    authenticator: Auth::FacebookAuthenticator.new,
    frame_width: 580,
    frame_height: 400,
    icon: "fab-facebook",
  ),
  Auth::AuthProvider.new(
    authenticator: Auth::GoogleOAuth2Authenticator.new,
    frame_width: 850,
    frame_height: 500,
  ), # Custom icon implemented in client
  Auth::AuthProvider.new(authenticator: Auth::GithubAuthenticator.new, icon: "fab-github"),
  Auth::AuthProvider.new(authenticator: Auth::TwitterAuthenticator.new, icon: "fab-twitter"),
  Auth::AuthProvider.new(authenticator: Auth::DiscordAuthenticator.new, icon: "fab-discord"),
  Auth::AuthProvider.new(
    authenticator: Auth::LinkedInOidcAuthenticator.new,
    icon: "fab-linkedin-in",
  ),
]
LAST_POSTGRES_READONLY_KEY =
"postgres:last_readonly"
READONLY_MODE_KEY_TTL =
60
READONLY_MODE_KEY =
"readonly_mode"
PG_READONLY_MODE_KEY =
"readonly_mode:postgres"
PG_READONLY_MODE_KEY_TTL =
300
USER_READONLY_MODE_KEY =
"readonly_mode:user"
PG_FORCE_READONLY_MODE_KEY =
"readonly_mode:postgres_force"
STAFF_WRITES_ONLY_MODE_KEY =

Pseudo readonly mode, where staff can still write

"readonly_mode:staff_writes_only"
READONLY_KEYS =
[
  READONLY_MODE_KEY,
  PG_READONLY_MODE_KEY,
  USER_READONLY_MODE_KEY,
  PG_FORCE_READONLY_MODE_KEY,
]
SYSTEM_USER_ID =
-1
SIDEKIQ_NAMESPACE =
"sidekiq"
CDN_REQUEST_METHODS =
%w[GET HEAD OPTIONS]

Class Method Summary collapse

Class Method Details

.activate_plugins!Object



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
# File 'lib/discourse.rb', line 340

def self.activate_plugins!
  @plugins = []
  @plugins_by_name = {}
  Plugin::Instance
    .find_all("#{Rails.root}/plugins")
    .each do |p|
      v = p..required_version || Discourse::VERSION::STRING
      if Discourse.has_needed_version?(Discourse::VERSION::STRING, v)
        p.activate!
        @plugins << p
        @plugins_by_name[p.name] = p

        # The plugin directory name and metadata name should match, but that
        # is not always the case
        dir_name = p.path.split("/")[-2]
        if p.name != dir_name
          STDERR.puts "Plugin name is '#{p.name}', but plugin directory is named '#{dir_name}'"
          # Plugins are looked up by directory name in SiteSettingExtension
          # because SiteSetting.load_settings uses directory name as plugin
          # name. We alias the two names just to make sure the look up works
          @plugins_by_name[dir_name] = p
        end
      else
        STDERR.puts "Could not activate #{p..name}, discourse does not meet required version (#{v})"
      end
    end
  DiscourseEvent.trigger(:after_plugin_activation)
end

.after_forkObject

all forking servers must call this after fork, otherwise Discourse will be in a bad state



922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
# File 'lib/discourse.rb', line 922

def self.after_fork
  # note: some of this reconnecting may no longer be needed per https://github.com/redis/redis-rb/pull/414
  MessageBus.after_fork
  SiteSetting.after_fork
  Discourse.redis.reconnect
  Rails.cache.reconnect
  Discourse.cache.reconnect
  Logster.store.redis.reconnect
  # shuts down all connections in the pool
  Sidekiq.redis_pool.shutdown { |conn| conn.disconnect! }
  # re-establish
  Sidekiq.redis = sidekiq_redis_config

  # in case v8 was initialized we want to make sure it is nil
  PrettyText.reset_context

  DiscourseJsProcessor::Transpiler.reset_context if defined?(DiscourseJsProcessor::Transpiler)

  # warm up v8 after fork, that way we do not fork a v8 context
  # it may cause issues if bg threads in a v8 isolate randomly stop
  # working due to fork
  begin
    # Skip warmup in development mode - it makes boot take ~2s longer
    PrettyText.cook("warm up **pretty text**") if !Rails.env.development?
  rescue => e
    Rails.logger.error("Failed to warm up pretty text: #{e}\n#{e.backtrace.join("\n")}")
  end

  nil
end

.allow_dev_populate?Boolean

Returns:

  • (Boolean)


1203
1204
1205
# File 'lib/discourse.rb', line 1203

def self.allow_dev_populate?
  Rails.env.development? || ENV["ALLOW_DEV_POPULATE"] == "1"
end

.anonymous_filtersObject



320
321
322
# File 'lib/discourse.rb', line 320

def self.anonymous_filters
  @anonymous_filters ||= %i[latest top categories hot]
end

.anonymous_locale(request) ⇒ Object



1217
1218
1219
1220
1221
1222
1223
1224
1225
# File 'lib/discourse.rb', line 1217

def self.anonymous_locale(request)
  locale =
    HttpLanguageParser.parse(request.cookies["locale"]) if SiteSetting.set_locale_from_cookie
  locale ||=
    HttpLanguageParser.parse(
      request.env["HTTP_ACCEPT_LANGUAGE"],
    ) if SiteSetting.set_locale_from_accept_language_header
  locale
end

.anonymous_top_menu_itemsObject



328
329
330
# File 'lib/discourse.rb', line 328

def self.anonymous_top_menu_items
  @anonymous_top_menu_items ||= Discourse.anonymous_filters + %i[categories top]
end

.apply_asset_filters(plugins, type, request) ⇒ Object



410
411
412
413
# File 'lib/discourse.rb', line 410

def self.apply_asset_filters(plugins, type, request)
  filter_opts = asset_filter_options(type, request)
  plugins.select { |plugin| plugin.asset_filters.all? { |b| b.call(type, request, filter_opts) } }
end

.apply_cdn_headers(headers) ⇒ Object



1197
1198
1199
1200
1201
# File 'lib/discourse.rb', line 1197

def self.apply_cdn_headers(headers)
  headers["Access-Control-Allow-Origin"] = "*"
  headers["Access-Control-Allow-Methods"] = CDN_REQUEST_METHODS.join(", ")
  headers
end

.asset_filter_options(type, request) ⇒ Object



415
416
417
418
419
420
421
422
423
# File 'lib/discourse.rb', line 415

def self.asset_filter_options(type, request)
  result = {}
  return result if request.blank?

  path = request.fullpath
  result[:path] = path if path.present?

  result
end

.asset_hostObject



897
898
899
# File 'lib/discourse.rb', line 897

def self.asset_host
  Rails.configuration.action_controller.asset_host
end

.assets_digestObject



467
468
469
470
471
472
473
474
475
476
477
478
# File 'lib/discourse.rb', line 467

def self.assets_digest
  @assets_digest ||=
    begin
      digest = Digest::MD5.hexdigest(ActionView::Base.assets_manifest.assets.values.sort.join)

      channel = "/global/asset-version"
      message = MessageBus.last_message(channel)

      MessageBus.publish channel, digest unless message && message.data == digest
      digest
    end
end

.auth_providersObject



501
502
503
# File 'lib/discourse.rb', line 501

def self.auth_providers
  BUILTIN_AUTH + DiscoursePluginRegistry.auth_providers.to_a
end

.authenticatorsObject



509
510
511
512
513
# File 'lib/discourse.rb', line 509

def self.authenticators
  # NOTE: this bypasses the site settings and gives a list of everything, we need to register every middleware
  #  for the cases of multisite
  auth_providers.map(&:authenticator)
end

.avatar_sizesObject



335
336
337
338
# File 'lib/discourse.rb', line 335

def self.avatar_sizes
  # TODO: should cache these when we get a notification system for site settings
  Set.new(SiteSetting.avatar_sizes.split("|").map(&:to_i))
end

.base_path(default_value = "") ⇒ Object



553
554
555
# File 'lib/discourse.rb', line 553

def self.base_path(default_value = "")
  ActionController::Base.config.relative_url_root.presence || default_value
end

.base_protocolObject



562
563
564
# File 'lib/discourse.rb', line 562

def self.base_protocol
  SiteSetting.force_https? ? "https" : "http"
end

.base_uri(default_value = "") ⇒ Object



557
558
559
560
# File 'lib/discourse.rb', line 557

def self.base_uri(default_value = "")
  deprecate("Discourse.base_uri is deprecated, use Discourse.base_path instead")
  base_path(default_value)
end

.base_urlObject



582
583
584
# File 'lib/discourse.rb', line 582

def self.base_url
  base_url_no_prefix + base_path
end

.base_url_no_prefixObject Also known as: base_url_no_path



578
579
580
# File 'lib/discourse.rb', line 578

def self.base_url_no_prefix
  "#{base_protocol}://#{current_hostname_with_port}"
end

.before_forkObject

all forking servers must call this before forking, otherwise the forked process might be in a bad state



908
909
910
911
912
913
914
915
916
917
# File 'lib/discourse.rb', line 908

def self.before_fork
  # V8 does not support forking, make sure all contexts are disposed
  ObjectSpace.each_object(MiniRacer::Context) { |c| c.dispose }

  # get rid of rubbish so we don't share it
  # longer term we will use compact! here
  GC.start
  GC.start
  GC.start
end

.cacheObject



519
520
521
522
523
524
525
526
527
528
# File 'lib/discourse.rb', line 519

def self.cache
  @cache ||=
    begin
      if GlobalSetting.skip_redis?
        ActiveSupport::Cache::MemoryStore.new
      else
        Cache.new
      end
    end
end

.capture_exceptions(message: "", env: nil) ⇒ Object



1009
1010
1011
1012
1013
1014
# File 'lib/discourse.rb', line 1009

def self.capture_exceptions(message: "", env: nil)
  yield
rescue Exception => e
  Discourse.warn_exception(e, message: message, env: env)
  nil
end

.catch_job_exceptions!Object



197
198
199
200
# File 'lib/discourse.rb', line 197

def self.catch_job_exceptions!
  raise "tests only" if !Rails.env.test?
  @catch_job_exceptions = true
end

.clear_all_theme_cache!Object

warning: this method is very expensive and shouldn’t be called in places where performance matters. it’s meant to be called manually (e.g. in the rails console) when dealing with an emergency that requires invalidating theme cache



1211
1212
1213
1214
1215
# File 'lib/discourse.rb', line 1211

def self.clear_all_theme_cache!
  ThemeField.force_recompilation!
  Theme.all.each(&:update_javascript_cache!)
  Theme.expire_site_cache!
end

.clear_postgres_readonly!Object



806
807
808
809
# File 'lib/discourse.rb', line 806

def self.clear_postgres_readonly!
  redis.del(LAST_POSTGRES_READONLY_KEY)
  postgres_last_read_only.clear(after_commit: false)
end

.clear_readonly!Object



819
820
821
822
823
824
# File 'lib/discourse.rb', line 819

def self.clear_readonly!
  clear_redis_readonly!
  clear_postgres_readonly!
  Site.clear_anon_cache!
  true
end

.clear_redis_readonly!Object



815
816
817
# File 'lib/discourse.rb', line 815

def self.clear_redis_readonly!
  redis_last_read_only[Discourse.redis.namespace] = nil
end

.clear_site_creation_date_cacheObject



1071
1072
1073
# File 'lib/discourse.rb', line 1071

def self.clear_site_creation_date_cache
  @creation_dates = {}
end

.clear_urls!Object



655
656
657
# File 'lib/discourse.rb', line 655

def self.clear_urls!
  urls_cache.clear
end

.current_hostnameObject

Get the current base URL for the current site



549
550
551
# File 'lib/discourse.rb', line 549

def self.current_hostname
  SiteSetting.force_hostname.presence || RailsMultisite::ConnectionManagement.current_hostname
end

.current_hostname_with_portObject



566
567
568
569
570
571
572
573
574
575
576
# File 'lib/discourse.rb', line 566

def self.current_hostname_with_port
  default_port = SiteSetting.force_https? ? 443 : 80
  result = +"#{current_hostname}"
  if SiteSetting.port.to_i > 0 && SiteSetting.port.to_i != default_port
    result << ":#{SiteSetting.port}"
  end

  result << ":#{ENV["UNICORN_PORT"] || 3000}" if Rails.env.development? && SiteSetting.port.blank?

  result
end

.current_user_providerObject



889
890
891
# File 'lib/discourse.rb', line 889

def self.current_user_provider
  @current_user_provider || Auth::DefaultCurrentUserProvider
end

.current_user_provider=(val) ⇒ Object



893
894
895
# File 'lib/discourse.rb', line 893

def self.current_user_provider=(val)
  @current_user_provider = val
end

.deprecate(warning, drop_from: nil, since: nil, raise_error: false, output_in_test: false) ⇒ Object

Raises:



1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
# File 'lib/discourse.rb', line 1016

def self.deprecate(warning, drop_from: nil, since: nil, raise_error: false, output_in_test: false)
  location = caller_locations[1].yield_self { |l| "#{l.path}:#{l.lineno}:in \`#{l.label}\`" }
  warning = ["Deprecation notice:", warning]
  warning << "(deprecated since Discourse #{since})" if since
  warning << "(removal in Discourse #{drop_from})" if drop_from
  warning << "\nAt #{location}"
  warning = warning.join(" ")

  raise Deprecation.new(warning) if raise_error

  STDERR.puts(warning) if Rails.env.development?

  STDERR.puts(warning) if output_in_test && Rails.env.test?

  digest = Digest::MD5.hexdigest(warning)
  redis_key = "deprecate-notice-#{digest}"

  if !Rails.env.development? && Rails.logger && !GlobalSetting.skip_redis? &&
       !Discourse.redis.without_namespace.get(redis_key)
    Rails.logger.warn(warning)
    begin
      Discourse.redis.without_namespace.setex(redis_key, 3600, "x")
    rescue Redis::CommandError => e
      raise unless e.message =~ /READONLY/
    end
  end
  warning
end

.disable_pg_force_readonly_modeObject



755
756
757
758
759
760
761
# File 'lib/discourse.rb', line 755

def self.disable_pg_force_readonly_mode
  RailsMultisite::ConnectionManagement.each_connection do
    disable_readonly_mode(PG_FORCE_READONLY_MODE_KEY)
  end

  true
end

.disable_readonly_mode(key = READONLY_MODE_KEY) ⇒ Object



737
738
739
740
741
742
743
744
745
# File 'lib/discourse.rb', line 737

def self.disable_readonly_mode(key = READONLY_MODE_KEY)
  if key == PG_READONLY_MODE_KEY || key == PG_FORCE_READONLY_MODE_KEY
    Sidekiq.unpause! if Sidekiq.paused?
  end

  Discourse.redis.del(key)
  MessageBus.publish(readonly_channel, false)
  true
end

.disable_sidekiq_loggingObject

For test environment only



1233
1234
1235
# File 'lib/discourse.rb', line 1233

def self.disable_sidekiq_logging
  @@sidekiq_logging_enabled = false
end

.enable_pg_force_readonly_modeObject



747
748
749
750
751
752
753
# File 'lib/discourse.rb', line 747

def self.enable_pg_force_readonly_mode
  RailsMultisite::ConnectionManagement.each_connection do
    enable_readonly_mode(PG_FORCE_READONLY_MODE_KEY)
  end

  true
end

.enable_readonly_mode(key = READONLY_MODE_KEY, expires: nil) ⇒ Object



678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
# File 'lib/discourse.rb', line 678

def self.enable_readonly_mode(key = READONLY_MODE_KEY, expires: nil)
  if key == PG_READONLY_MODE_KEY || key == PG_FORCE_READONLY_MODE_KEY
    Sidekiq.pause!("pg_failover") if !Sidekiq.paused?
  end

  if expires.nil?
    expires = [
      USER_READONLY_MODE_KEY,
      PG_FORCE_READONLY_MODE_KEY,
      STAFF_WRITES_ONLY_MODE_KEY,
    ].exclude?(key)
  end

  if expires
    ttl =
      case key
      when PG_READONLY_MODE_KEY
        PG_READONLY_MODE_KEY_TTL
      else
        READONLY_MODE_KEY_TTL
      end

    Discourse.redis.setex(key, ttl, 1)
    keep_readonly_mode(key, ttl: ttl) if !Rails.env.test?
  else
    Discourse.redis.set(key, 1)
  end

  MessageBus.publish(readonly_channel, true)
  true
end

.enable_sidekiq_loggingObject

For test environment only



1228
1229
1230
# File 'lib/discourse.rb', line 1228

def self.enable_sidekiq_logging
  @@sidekiq_logging_enabled = true
end

.enable_sidekiq_logging?Boolean

Returns:

  • (Boolean)


1237
1238
1239
1240
# File 'lib/discourse.rb', line 1237

def self.enable_sidekiq_logging?
  ENV["DISCOURSE_LOG_SIDEKIQ"] == "1" ||
    (defined?(@@sidekiq_logging_enabled) && @@sidekiq_logging_enabled)
end

.enabled_auth_providersObject



505
506
507
# File 'lib/discourse.rb', line 505

def self.enabled_auth_providers
  auth_providers.select { |provider| provider.authenticator.enabled? }
end

.enabled_authenticatorsObject



515
516
517
# File 'lib/discourse.rb', line 515

def self.enabled_authenticators
  authenticators.select { |authenticator| authenticator.enabled? }
end

.filtersObject



316
317
318
# File 'lib/discourse.rb', line 316

def self.filters
  @filters ||= %i[latest unread new unseen top read posted bookmarks hot]
end

.find_compatible_git_resource(path) ⇒ Object

Find a compatible resource from a git repo



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/version.rb', line 93

def self.find_compatible_git_resource(path)
  return unless File.directory?("#{path}/.git")

  tree_info =
    Discourse::Utils.execute_command(
      "git",
      "-C",
      path,
      "ls-tree",
      "-l",
      "HEAD",
      Discourse::VERSION_COMPATIBILITY_FILENAME,
    )
  blob_size = tree_info.split[3].to_i

  if blob_size > Discourse::MAX_METADATA_FILE_SIZE
    $stderr.puts "#{Discourse::VERSION_COMPATIBILITY_FILENAME} file in #{path} too big"
    return
  end

  compat_resource =
    Discourse::Utils.execute_command(
      "git",
      "-C",
      path,
      "show",
      "HEAD@{upstream}:#{Discourse::VERSION_COMPATIBILITY_FILENAME}",
    )

  Discourse.find_compatible_resource(compat_resource)
rescue InvalidVersionListError => e
  $stderr.puts "Invalid version list in #{path}"
rescue Discourse::Utils::CommandError => e
  nil
end

.find_compatible_resource(version_list, target_version = ::Discourse::VERSION::STRING) ⇒ Object

lookup an external resource (theme/plugin)‘s best compatible version compatible resource files are YAML, in the format: `discourse_version: plugin/theme git reference.` For example:

2.5.0.beta6: c4a6c17
2.5.0.beta4: d1d2d3f
2.5.0.beta2: bbffee
2.4.4.beta6: some-other-branch-ref
2.4.2.beta1: v1-tag


38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/version.rb', line 38

def self.find_compatible_resource(version_list, target_version = ::Discourse::VERSION::STRING)
  return if version_list.blank?

  begin
    version_list = YAML.safe_load(version_list)
  rescue Psych::SyntaxError, Psych::DisallowedClass => e
  end

  raise InvalidVersionListError unless version_list.is_a?(Hash)

  version_list =
    version_list
      .transform_keys do |v|
        Gem::Requirement.parse(v)
      rescue Gem::Requirement::BadRequirementError => e
        raise InvalidVersionListError, "Invalid version specifier: #{v}"
      end
      .sort_by do |parsed_requirement, _|
        operator, version = parsed_requirement
        [version, operator == "<" ? 0 : 1]
      end

  parsed_target_version = Gem::Version.new(target_version)

  lowest_matching_entry =
    version_list.find do |parsed_requirement, target|
      req_operator, req_version = parsed_requirement
      req_operator = "<=" if req_operator == "="

      if !%w[<= <].include?(req_operator)
        raise InvalidVersionListError,
              "Invalid version specifier operator for '#{req_operator} #{req_version}'. Operator must be one of <= or <"
      end

      resolved_requirement = Gem::Requirement.new("#{req_operator} #{req_version}")
      resolved_requirement.satisfied_by?(parsed_target_version)
    end

  return if lowest_matching_entry.nil?

  checkout_version = lowest_matching_entry[1]

  begin
    Discourse::Utils.execute_command "git",
                                     "check-ref-format",
                                     "--allow-onelevel",
                                     checkout_version
  rescue RuntimeError
    raise InvalidVersionListError, "Invalid ref name: #{checkout_version}"
  end

  checkout_version
end

.find_plugin_css_assets(args) ⇒ Object



425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
# File 'lib/discourse.rb', line 425

def self.find_plugin_css_assets(args)
  plugins = apply_asset_filters(self.find_plugins(args), :css, args[:request])

  assets = []

  targets = [nil]
  targets << :mobile if args[:mobile_view]
  targets << :desktop if args[:desktop_view]

  targets.each do |target|
    assets +=
      plugins
        .find_all { |plugin| plugin.css_asset_exists?(target) }
        .map do |plugin|
          target.nil? ? plugin.directory_name : "#{plugin.directory_name}_#{target}"
        end
  end

  assets.map! { |asset| "#{asset}_rtl" } if args[:rtl]
  assets
end

.find_plugin_js_assets(args) ⇒ Object



447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
# File 'lib/discourse.rb', line 447

def self.find_plugin_js_assets(args)
  plugins =
    self
      .find_plugins(args)
      .select do |plugin|
        plugin.js_asset_exists? || plugin.extra_js_asset_exists? || plugin.admin_js_asset_exists?
      end

  plugins = apply_asset_filters(plugins, :js, args[:request])

  plugins.flat_map do |plugin|
    assets = []
    assets << "plugins/#{plugin.directory_name}" if plugin.js_asset_exists?
    assets << "plugins/#{plugin.directory_name}_extra" if plugin.extra_js_asset_exists?
    # TODO: make admin asset only load for admins
    assets << "plugins/#{plugin.directory_name}_admin" if plugin.admin_js_asset_exists?
    assets
  end
end

.find_plugins(args) ⇒ Object



400
401
402
403
404
405
406
407
408
# File 'lib/discourse.rb', line 400

def self.find_plugins(args)
  plugins.select do |plugin|
    next if args[:include_official] == false && plugin..official?
    next if args[:include_unofficial] == false && !plugin..official?
    next if !args[:include_disabled] && !plugin.enabled?

    true
  end
end

.full_versionObject



846
847
848
# File 'lib/discourse.rb', line 846

def self.full_version
  @full_version ||= GitUtils.full_version
end

.git_branchObject



842
843
844
# File 'lib/discourse.rb', line 842

def self.git_branch
  @git_branch ||= GitUtils.git_branch
end

.git_versionObject



838
839
840
# File 'lib/discourse.rb', line 838

def self.git_version
  @git_version ||= GitUtils.git_version
end

.handle_job_exception(ex, context = {}, parent_logger = nil) ⇒ Object

Log an exception.

If your code is in a scheduled job, it is recommended to use the error_context() method in Jobs::Base to pass the job arguments and any other desired context. See app/jobs/base.rb for the error_context function.



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/discourse.rb', line 214

def self.handle_job_exception(ex, context = {}, parent_logger = nil)
  return if ex.class == Jobs::HandledExceptionWrapper

  context ||= {}
  parent_logger ||= Sidekiq

  job = context[:job]

  # mini_scheduler direct reporting
  if Hash === job
    job_class = job["class"]
    job_exception_stats[job_class] += 1 if job_class
  end

  # internal reporting
  job_exception_stats[job] += 1 if job.class == Class && ::Jobs::Base > job

  cm = RailsMultisite::ConnectionManagement
  parent_logger.handle_exception(
    ex,
    { current_db: cm.current_db, current_hostname: cm.current_hostname }.merge(context),
  )

  raise ex if Rails.env.test? && !@catch_job_exceptions
end

.has_needed_version?(current, needed) ⇒ Boolean

Returns:

  • (Boolean)


26
27
28
# File 'lib/version.rb', line 26

def self.has_needed_version?(current, needed)
  Gem::Version.new(current) >= Gem::Version.new(needed)
end

.is_cdn_request?(env, request_method) ⇒ Boolean

Returns:

  • (Boolean)


1187
1188
1189
1190
1191
1192
1193
1194
1195
# File 'lib/discourse.rb', line 1187

def self.is_cdn_request?(env, request_method)
  return if CDN_REQUEST_METHODS.exclude?(request_method)

  cdn_hostnames = GlobalSetting.cdn_hostnames
  return if cdn_hostnames.blank?

  requested_hostname = env[REQUESTED_HOSTNAME] || env[Rack::HTTP_HOST]
  cdn_hostnames.include?(requested_hostname)
end

.is_parallel_test?Boolean

Returns:

  • (Boolean)


1181
1182
1183
# File 'lib/discourse.rb', line 1181

def self.is_parallel_test?
  ENV["RAILS_ENV"] == "test" && ENV["TEST_ENV_NUMBER"]
end

.job_exception_statsObject



186
187
188
# File 'lib/discourse.rb', line 186

def self.job_exception_stats
  @job_exception_stats
end

.keep_readonly_mode(key, ttl:) ⇒ Object



710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
# File 'lib/discourse.rb', line 710

def self.keep_readonly_mode(key, ttl:)
  # extend the expiry by ttl minute every ttl/2 seconds
  @mutex ||= Mutex.new

  @mutex.synchronize do
    @dbs ||= Set.new
    @dbs << RailsMultisite::ConnectionManagement.current_db
    @threads ||= {}

    unless @threads[key]&.alive?
      @threads[key] = Thread.new do
        while @dbs.size > 0
          sleep ttl / 2

          @mutex.synchronize do
            @dbs.each do |db|
              RailsMultisite::ConnectionManagement.with_connection(db) do
                @dbs.delete(db) if !Discourse.redis.expire(key, ttl)
              end
            end
          end
        end
      end
    end
  end
end

.last_commit_dateObject



850
851
852
# File 'lib/discourse.rb', line 850

def self.last_commit_date
  @last_commit_date ||= GitUtils.last_commit_date
end

.official_pluginsObject



392
393
394
# File 'lib/discourse.rb', line 392

def self.official_plugins
  plugins.find_all { |p| p..official? }
end

.os_hostnameObject

hostname of the server, operating system level called os_hostname so we do no confuse it with current_hostname



532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
# File 'lib/discourse.rb', line 532

def self.os_hostname
  @os_hostname ||=
    begin
      require "socket"
      Socket.gethostname
    rescue => e
      warn_exception(e, message: "Socket.gethostname is not working")
      begin
        `hostname`.strip
      rescue => e
        warn_exception(e, message: "hostname command is not working")
        "unknown_host"
      end
    end
end

.pg_readonly_mode?Boolean

Returns:

  • (Boolean)


771
772
773
# File 'lib/discourse.rb', line 771

def self.pg_readonly_mode?
  Discourse.redis.get(PG_READONLY_MODE_KEY).present?
end

.plugin_themesObject



388
389
390
# File 'lib/discourse.rb', line 388

def self.plugin_themes
  @plugin_themes ||= plugins.map(&:themes).flatten
end

.pluginsObject



369
370
371
# File 'lib/discourse.rb', line 369

def self.plugins
  @plugins ||= []
end

.plugins_by_nameObject



373
374
375
# File 'lib/discourse.rb', line 373

def self.plugins_by_name
  @plugins_by_name ||= {}
end

.plugins_sorted_by_name(enabled_only: true) ⇒ Object



381
382
383
384
385
386
# File 'lib/discourse.rb', line 381

def self.plugins_sorted_by_name(enabled_only: true)
  if enabled_only
    return visible_plugins.filter(&:enabled?).sort_by { |plugin| plugin.humanized_name.downcase }
  end
  visible_plugins.sort_by { |plugin| plugin.humanized_name.downcase }
end

.postgres_last_read_onlyObject

Shared between processes



776
777
778
# File 'lib/discourse.rb', line 776

def self.postgres_last_read_only
  @postgres_last_read_only ||= DistributedCache.new("postgres_last_read_only")
end

.postgres_recently_readonly?Boolean

Returns:

  • (Boolean)


785
786
787
788
789
790
# File 'lib/discourse.rb', line 785

def self.postgres_recently_readonly?
  seconds =
    postgres_last_read_only.defer_get_set("timestamp") { redis.get(LAST_POSTGRES_READONLY_KEY) }

  seconds ? Time.zone.at(seconds.to_i) > 15.seconds.ago : false
end

.preload_rails!Object

this is used to preload as much stuff as possible prior to forking in turn this can conserve large amounts of memory on forking servers



1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
# File 'lib/discourse.rb', line 1109

def self.preload_rails!
  return if @preloaded_rails

  if !Rails.env.development?
    # Skipped in development because the schema cache gets reset on every code change anyway
    # Better to rely on the filesystem-based db:schema:cache:dump

    # load up all models and schema
    (ActiveRecord::Base.connection.tables - %w[schema_migrations versions]).each do |table|
      begin
        table.classify.constantize.first
      rescue StandardError
        nil
      end
    end

    # ensure we have a full schema cache in case we missed something above
    ActiveRecord::Base.connection.data_sources.each do |table|
      ActiveRecord::Base.connection.schema_cache.add(table)
    end
  end

  RailsMultisite::ConnectionManagement.safe_each_connection do
    I18n.t(:posts)

    # this will force Cppjieba to preload if any site has it
    # enabled allowing it to be reused between all child processes
    Search.prepare_data("test")

    JsLocaleHelper.load_translations(SiteSetting.default_locale)
    Site.json_for(Guardian.new)
    SvgSprite.preload

    begin
      SiteSetting.client_settings_json
    rescue => e
      # Rescue from Redis related errors so that we can still boot the
      # application even if Redis is down.
      warn_exception(e, message: "Error while preloading client settings json")
    end
  end

  [
    Thread.new do
      # router warm up
      begin
        Rails.application.routes.recognize_path("abc")
      rescue StandardError
        nil
      end
    end,
    Thread.new do
      # preload discourse version
      Discourse.git_version
      Discourse.git_branch
      Discourse.full_version
      Discourse.plugins.each { |p| p.commit_url }
    end,
    Thread.new do
      require "actionview_precompiler"
      ActionviewPrecompiler.precompile
    end,
    Thread.new { LetterAvatar.image_magick_version },
    Thread.new { SvgSprite.core_svgs },
    Thread.new { EmberCli.script_chunks },
  ].each(&:join)
ensure
  @preloaded_rails = true
end

.privacy_policy_urlObject



636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
# File 'lib/discourse.rb', line 636

def self.privacy_policy_url
  if SiteSetting.privacy_policy_url.present?
    SiteSetting.privacy_policy_url
  else
    return urls_cache["privacy_policy"] if urls_cache["privacy_policy"].present?

    privacy_policy_url =
      if SiteSetting.privacy_topic_id > 0 && Topic.exists?(id: SiteSetting.privacy_topic_id)
        "#{Discourse.base_path}/privacy"
      end

    if privacy_policy_url
      urls_cache["privacy_policy"] = privacy_policy_url
    else
      urls_cache.delete("privacy_policy")
    end
  end
end

.readonly_channelObject



901
902
903
# File 'lib/discourse.rb', line 901

def self.readonly_channel
  "/site/read-only"
end

.readonly_mode?(keys = READONLY_KEYS) ⇒ Boolean

Returns:

  • (Boolean)


763
764
765
# File 'lib/discourse.rb', line 763

def self.readonly_mode?(keys = READONLY_KEYS)
  recently_readonly? || GlobalSetting.pg_force_readonly_mode || Discourse.redis.exists?(*keys)
end

.received_postgres_readonly!Object



798
799
800
801
802
803
804
# File 'lib/discourse.rb', line 798

def self.received_postgres_readonly!
  time = Time.zone.now
  redis.set(LAST_POSTGRES_READONLY_KEY, time.to_i.to_s)
  postgres_last_read_only.clear(after_commit: false)

  time
end

.received_redis_readonly!Object



811
812
813
# File 'lib/discourse.rb', line 811

def self.received_redis_readonly!
  redis_last_read_only[Discourse.redis.namespace] = Time.zone.now
end

.recently_readonly?Boolean

Returns:

  • (Boolean)


792
793
794
795
796
# File 'lib/discourse.rb', line 792

def self.recently_readonly?
  redis_read_only = redis_last_read_only[Discourse.redis.namespace]

  (redis_read_only.present? && redis_read_only > 15.seconds.ago) || postgres_recently_readonly?
end

.redis_last_read_onlyObject

Per-process



781
782
783
# File 'lib/discourse.rb', line 781

def self.redis_last_read_only
  @redis_last_read_only ||= {}
end

.request_refresh!(user_ids: nil) ⇒ Object



826
827
828
829
830
831
832
833
834
835
836
# File 'lib/discourse.rb', line 826

def self.request_refresh!(user_ids: nil)
  # Causes refresh on next click for all clients
  #
  # This is better than `MessageBus.publish "/file-change", ["refresh"]` because
  # it spreads the refreshes out over a time period
  if user_ids
    MessageBus.publish("/refresh_client", "clobber", user_ids: user_ids)
  else
    MessageBus.publish("/global/asset-version", "clobber")
  end
end

.reset_active_record_cacheObject



1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
# File 'lib/discourse.rb', line 1087

def self.reset_active_record_cache
  ActiveRecord::Base.connection.query_cache.clear
  (ActiveRecord::Base.connection.tables - %w[schema_migrations versions]).each do |table|
    begin
      table.classify.constantize.reset_column_information
    rescue StandardError
      nil
    end
  end
  nil
end

.reset_active_record_cache_if_needed(e) ⇒ Object



1077
1078
1079
1080
1081
1082
1083
1084
1085
# File 'lib/discourse.rb', line 1077

def self.reset_active_record_cache_if_needed(e)
  last_cache_reset = Discourse.last_ar_cache_reset
  if e && e.message =~ /UndefinedColumn/ &&
       (last_cache_reset.nil? || last_cache_reset < 30.seconds.ago)
    Rails.logger.warn "Clearing Active Record cache, this can happen if schema changed while site is running or in a multisite various databases are running different schemas. Consider running rake multisite:migrate."
    Discourse.last_ar_cache_reset = Time.zone.now
    Discourse.reset_active_record_cache
  end
end

.reset_catch_job_exceptions!Object



202
203
204
205
# File 'lib/discourse.rb', line 202

def self.reset_catch_job_exceptions!
  raise "tests only" if !Rails.env.test?
  remove_instance_variable(:@catch_job_exceptions)
end

.reset_job_exception_stats!Object



190
191
192
# File 'lib/discourse.rb', line 190

def self.reset_job_exception_stats!
  @job_exception_stats = Hash.new(0)
end

.route_for(uri) ⇒ Object



586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
# File 'lib/discourse.rb', line 586

def self.route_for(uri)
  unless uri.is_a?(URI)
    uri =
      begin
        URI(uri)
      rescue ArgumentError, URI::Error
      end
  end

  return unless uri

  path = +(uri.path || "")
  if !uri.host ||
       (uri.host == Discourse.current_hostname && path.start_with?(Discourse.base_path))
    path.slice!(Discourse.base_path)
    return Rails.application.routes.recognize_path(path)
  end

  nil
rescue ActionController::RoutingError
  nil
end

.running_in_rack?Boolean

Returns:

  • (Boolean)


1099
1100
1101
# File 'lib/discourse.rb', line 1099

def self.running_in_rack?
  ENV["DISCOURSE_RUNNING_IN_RACK"] == "1"
end

.sidekiq_redis_configObject



1047
1048
1049
1050
1051
# File 'lib/discourse.rb', line 1047

def self.sidekiq_redis_config
  conf = GlobalSetting.redis_config.dup
  conf[:namespace] = SIDEKIQ_NAMESPACE
  conf
end

.site_contact_userObject

Either returns the site_contact_username user or the first admin.



859
860
861
862
863
864
865
# File 'lib/discourse.rb', line 859

def self.site_contact_user
  user =
    User.find_by(
      username_lower: SiteSetting.site_contact_username.downcase,
    ) if SiteSetting.site_contact_username.present?
  user ||= (system_user || User.admins.real.order(:id).first)
end

.site_creation_dateObject



1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
# File 'lib/discourse.rb', line 1057

def self.site_creation_date
  @creation_dates ||= {}
  current_db = RailsMultisite::ConnectionManagement.current_db
  @creation_dates[current_db] ||= begin
    result = DB.query_single <<~SQL
        SELECT created_at
        FROM schema_migration_details
        ORDER BY created_at
        LIMIT 1
      SQL
    result.first
  end
end

.skip_post_deployment_migrations?Boolean

Returns:

  • (Boolean)


1103
1104
1105
# File 'lib/discourse.rb', line 1103

def self.skip_post_deployment_migrations?
  %w[1 true].include?(ENV["SKIP_POST_DEPLOYMENT_MIGRATIONS"]&.to_s)
end

.staff_writes_only_mode?Boolean

Returns:

  • (Boolean)


767
768
769
# File 'lib/discourse.rb', line 767

def self.staff_writes_only_mode?
  Discourse.redis.get(STAFF_WRITES_ONLY_MODE_KEY).present?
end

.static_doc_topic_idsObject



1053
1054
1055
# File 'lib/discourse.rb', line 1053

def self.static_doc_topic_ids
  [SiteSetting.tos_topic_id, SiteSetting.guidelines_topic_id, SiteSetting.privacy_topic_id]
end

.statsObject



885
886
887
# File 'lib/discourse.rb', line 885

def self.stats
  PluginStore.new("stats")
end

.storeObject



875
876
877
878
879
880
881
882
883
# File 'lib/discourse.rb', line 875

def self.store
  if SiteSetting.Upload.enable_s3_uploads
    @s3_store_loaded ||= require "file_store/s3_store"
    FileStore::S3Store.new
  else
    @local_store_loaded ||= require "file_store/local_store"
    FileStore::LocalStore.new
  end
end

.system_userObject



869
870
871
872
873
# File 'lib/discourse.rb', line 869

def self.system_user
  @system_users ||= {}
  current_db = RailsMultisite::ConnectionManagement.current_db
  @system_users[current_db] ||= User.find_by(id: SYSTEM_USER_ID)
end

.top_menu_itemsObject



324
325
326
# File 'lib/discourse.rb', line 324

def self.top_menu_items
  @top_menu_items ||= Discourse.filters + [:categories]
end

.tos_urlObject



617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
# File 'lib/discourse.rb', line 617

def self.tos_url
  if SiteSetting.tos_url.present?
    SiteSetting.tos_url
  else
    return urls_cache["tos"] if urls_cache["tos"].present?

    tos_url =
      if SiteSetting.tos_topic_id > 0 && Topic.exists?(id: SiteSetting.tos_topic_id)
        "#{Discourse.base_path}/tos"
      end

    if tos_url
      urls_cache["tos"] = tos_url
    else
      urls_cache.delete("tos")
    end
  end
end

.try_git(git_cmd, default_value) ⇒ Object



854
855
856
# File 'lib/discourse.rb', line 854

def self.try_git(git_cmd, default_value)
  GitUtils.try_git(git_cmd, default_value)
end

.unofficial_pluginsObject



396
397
398
# File 'lib/discourse.rb', line 396

def self.unofficial_plugins
  plugins.find_all { |p| !p..official? }
end

.urls_cacheObject



613
614
615
# File 'lib/discourse.rb', line 613

def self.urls_cache
  @urls_cache ||= DistributedCache.new("urls_cache")
end

.visible_pluginsObject



377
378
379
# File 'lib/discourse.rb', line 377

def self.visible_plugins
  plugins.filter(&:visible?)
end

.warn(message, env = nil) ⇒ Object

you can use Discourse.warn when you want to report custom environment with the error, this helps with grouping



955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
# File 'lib/discourse.rb', line 955

def self.warn(message, env = nil)
  append = env ? (+" ") << env.map { |k, v| "#{k}: #{v}" }.join(" ") : ""

  loggers = Rails.logger.broadcasts
  logster_env = env

  if old_env = Thread.current[Logster::Logger::LOGSTER_ENV]
    logster_env = Logster::Message.populate_from_env(old_env)

    # a bit awkward by try to keep the new params
    env.each { |k, v| logster_env[k] = v }
  end

  loggers.each do |logger|
    if !(Logster::Logger === logger)
      logger.warn("#{message} #{append}")
      next
    end

    logger.store.report(::Logger::Severity::WARN, "discourse", message, env: logster_env)
  end

  if old_env
    env.each do |k, v|
      # do not leak state
      logster_env.delete(k)
    end
  end

  nil
end

.warn_exception(e, message: "", env: nil) ⇒ Object

report a warning maintaining backtrack for logster



988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
# File 'lib/discourse.rb', line 988

def self.warn_exception(e, message: "", env: nil)
  if Rails.logger.respond_to? :add_with_opts
    env ||= {}
    env[:current_db] ||= RailsMultisite::ConnectionManagement.current_db

    # logster
    Rails.logger.add_with_opts(
      ::Logger::Severity::WARN,
      "#{message} : #{e.class.name} : #{e}",
      "discourse-exception",
      backtrace: e.backtrace.join("\n"),
      env: env,
    )
  else
    # no logster ... fallback
    Rails.logger.warn("#{message} #{e}\n#{e.backtrace.join("\n")}")
  end
rescue StandardError
  STDERR.puts "Failed to report exception #{e} #{message}"
end