From b445d95174a1686bf33fbf3c63aeb23d4ff97b34 Mon Sep 17 00:00:00 2001 From: aifeelit Date: Tue, 8 Nov 2022 17:22:16 -0600 Subject: [PATCH] model-emoji --- app/models/email_style.rb | 41 +++++ app/models/email_token.rb | 128 ++++++++++++++ app/models/embeddable_host.rb | 88 ++++++++++ app/models/embedding.rb | 42 +++++ app/models/emoji.rb | 302 ++++++++++++++++++++++++++++++++++ 5 files changed, 601 insertions(+) create mode 100644 app/models/email_style.rb create mode 100644 app/models/email_token.rb create mode 100644 app/models/embeddable_host.rb create mode 100644 app/models/embedding.rb create mode 100644 app/models/emoji.rb diff --git a/app/models/email_style.rb b/app/models/email_style.rb new file mode 100644 index 00000000..9aeb483f --- /dev/null +++ b/app/models/email_style.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class EmailStyle + include ActiveModel::Serialization + + attr_accessor :html, :css, :default_html, :default_css + + def id + 'email-style' + end + + def html + SiteSetting.email_custom_template.presence || default_html + end + + def css + SiteSetting.email_custom_css || default_css + end + + def compiled_css + SiteSetting.email_custom_css_compiled.presence || css + end + + def default_html + self.class.default_template + end + + def default_css + self.class.default_css + end + + def self.default_template + @_default_template ||= File.read( + File.join(Rails.root, 'app', 'views', 'email', 'default_template.html') + ) + end + + def self.default_css + '' + end +end diff --git a/app/models/email_token.rb b/app/models/email_token.rb new file mode 100644 index 00000000..0907aacc --- /dev/null +++ b/app/models/email_token.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +class EmailToken < ActiveRecord::Base + class TokenAccessError < StandardError; end + + belongs_to :user + + validates :user_id, :email, :token_hash, presence: true + + scope :unconfirmed, -> { where(confirmed: false) } + scope :active, -> { where(expired: false).where('created_at >= ?', SiteSetting.email_token_valid_hours.hours.ago) } + + after_initialize do + if self.token_hash.blank? + @token ||= SecureRandom.hex + self.token_hash = self.class.hash_token(@token) + end + end + + after_create do + EmailToken + .where(user_id: self.user_id) + .where(scope: [nil, self.scope]) + .where.not(id: self.id) + .update_all(expired: true) + end + + before_validation do + self.email = self.email.downcase if self.email + end + + before_save do + if self.scope.blank? + Discourse.deprecate("EmailToken#scope cannot be empty.", output_in_test: true) + end + end + + # TODO(2022-01-01): Remove + self.ignored_columns = %w{token} + + def self.scopes + @scopes ||= Enum.new( + signup: 1, + password_reset: 2, + email_login: 3, + email_update: 4, + ) + end + + def token + raise TokenAccessError.new if @token.blank? + + @token + end + + def self.confirm(token, scope: nil, skip_reviewable: false) + User.transaction do + email_token = confirmable(token, scope: scope) + return if email_token.blank? + + email_token.update!(confirmed: true) + + user = email_token.user + user.send_welcome_message = !user.active? + user.email = email_token.email + user.active = true + user.custom_fields.delete('activation_reminder') + user.save! + user.create_reviewable if !skip_reviewable + user.set_automatic_groups + DiscourseEvent.trigger(:user_confirmed_email, user) + Invite.redeem_from_email(user.email) if scope == EmailToken.scopes[:signup] + + user.reload + end + rescue ActiveRecord::RecordInvalid + # If the user's email is already taken, just return nil (failure) + end + + def self.confirmable(token, scope: nil) + return nil if token.blank? + + relation = unconfirmed.active + .includes(:user) + .where(token_hash: hash_token(token)) + + # TODO(2022-01-01): All email tokens should have scopes by now + if !scope + relation.first + else + relation.where(scope: scope).first || relation.where(scope: nil).first + end + end + + def self.enqueue_signup_email(email_token, to_address: nil) + Jobs.enqueue( + :critical_user_email, + type: "signup", + user_id: email_token.user_id, + email_token: email_token.token, + to_address: to_address + ) + end + + def self.hash_token(token) + Digest::SHA256.hexdigest(token) + end +end + +# == Schema Information +# +# Table name: email_tokens +# +# id :integer not null, primary key +# user_id :integer not null +# email :string not null +# confirmed :boolean default(FALSE), not null +# expired :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# token_hash :string not null +# scope :integer +# +# Indexes +# +# index_email_tokens_on_token_hash (token_hash) UNIQUE +# index_email_tokens_on_user_id (user_id) +# diff --git a/app/models/embeddable_host.rb b/app/models/embeddable_host.rb new file mode 100644 index 00000000..f638ff23 --- /dev/null +++ b/app/models/embeddable_host.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +class EmbeddableHost < ActiveRecord::Base + validate :host_must_be_valid + belongs_to :category + after_destroy :reset_embedding_settings + + before_validation do + self.host.sub!(/^https?:\/\//, '') + self.host.sub!(/\/.*$/, '') + end + + # TODO(2021-07-23): Remove + self.ignored_columns = ["path_whitelist"] + + def self.record_for_url(uri) + if uri.is_a?(String) + uri = begin + URI(UrlHelper.normalized_encode(uri)) + rescue URI::Error, Addressable::URI::InvalidURIError + end + end + + return false unless uri.present? + + host = uri.host + return false unless host.present? + + if uri.port.present? && uri.port != 80 && uri.port != 443 + host << ":#{uri.port}" + end + + path = uri.path + path << "?" << uri.query if uri.query.present? + + where("lower(host) = ?", host).each do |eh| + return eh if eh.allowed_paths.blank? + + path_regexp = Regexp.new(eh.allowed_paths) + return eh if path_regexp.match(path) || path_regexp.match(UrlHelper.unencode(path)) + end + + nil + end + + def self.url_allowed?(url) + return false if url.nil? + + # Work around IFRAME reload on WebKit where the referer will be set to the Forum URL + return true if url&.starts_with?(Discourse.base_url) && EmbeddableHost.exists? + + uri = begin + URI(UrlHelper.normalized_encode(url)) + rescue URI::Error + end + + uri.present? && record_for_url(uri).present? + end + + private + + def reset_embedding_settings + unless EmbeddableHost.exists? + Embedding.settings.each { |s| SiteSetting.set(s.to_s, SiteSetting.defaults[s]) } + end + end + + def host_must_be_valid + if host !~ /\A[a-z0-9]+([\-\.]+{1}[a-z0-9]+)*\.[a-z]{2,24}(:[0-9]{1,5})?(\/.*)?\Z/i && + host !~ /\A(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(:[0-9]{1,5})?(\/.*)?\Z/ && + host !~ /\A([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.)?localhost(\:[0-9]{1,5})?(\/.*)?\Z/i + errors.add(:host, I18n.t('errors.messages.invalid')) + end + end +end + +# == Schema Information +# +# Table name: embeddable_hosts +# +# id :integer not null, primary key +# host :string not null +# category_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# class_name :string +# allowed_paths :string +# diff --git a/app/models/embedding.rb b/app/models/embedding.rb new file mode 100644 index 00000000..909ba1c2 --- /dev/null +++ b/app/models/embedding.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'has_errors' + +class Embedding < OpenStruct + include HasErrors + + def self.settings + %i(embed_by_username + embed_post_limit + embed_title_scrubber + embed_truncate + embed_unlisted + allowed_embed_selectors + blocked_embed_selectors + allowed_embed_classnames) + end + + def base_url + Discourse.base_url + end + + def save + Embedding.settings.each do |s| + SiteSetting.set(s, public_send(s)) + end + true + rescue Discourse::InvalidParameters => p + errors.add :base, p.to_s + false + end + + def embeddable_hosts + EmbeddableHost.all.order(:host) + end + + def self.find + embedding_args = { id: 'default' } + Embedding.settings.each { |s| embedding_args[s] = SiteSetting.get(s) } + Embedding.new(embedding_args) + end +end diff --git a/app/models/emoji.rb b/app/models/emoji.rb new file mode 100644 index 00000000..2333a913 --- /dev/null +++ b/app/models/emoji.rb @@ -0,0 +1,302 @@ +# frozen_string_literal: true + +class Emoji + # update this to clear the cache + EMOJI_VERSION = "12" + + FITZPATRICK_SCALE ||= [ "1f3fb", "1f3fc", "1f3fd", "1f3fe", "1f3ff" ] + + DEFAULT_GROUP ||= "default" + + include ActiveModel::SerializerSupport + + attr_accessor :name, :url, :tonable, :group + + def self.global_emoji_cache + @global_emoji_cache ||= DistributedCache.new("global_emoji_cache", namespace: false) + end + + def self.site_emoji_cache + @site_emoji_cache ||= DistributedCache.new("site_emoji_cache") + end + + def self.all + Discourse.cache.fetch(cache_key("all_emojis")) { standard | custom } + end + + def self.standard + Discourse.cache.fetch(cache_key("standard_emojis")) { load_standard } + end + + def self.aliases + Discourse.cache.fetch(cache_key("aliases_emojis")) { db['aliases'] } + end + + def self.search_aliases + Discourse.cache.fetch(cache_key("search_aliases_emojis")) { db['searchAliases'] } + end + + def self.translations + Discourse.cache.fetch(cache_key("translations_emojis")) { load_translations } + end + + def self.custom + Discourse.cache.fetch(cache_key("custom_emojis")) { load_custom } + end + + def self.tonable_emojis + Discourse.cache.fetch(cache_key("tonable_emojis")) { db['tonableEmojis'] } + end + + def self.custom?(name) + name = name.delete_prefix(':').delete_suffix(':') + Emoji.custom.detect { |e| e.name == name }.present? + end + + def self.exists?(name) + Emoji[name].present? + end + + def self.[](name) + name = name.delete_prefix(':').delete_suffix(':') + is_toned = name.match?(/.+:t[1-6]/) + normalized_name = name.gsub(/(.+):t[1-6]/, '\1') + + found_emoji = nil + + [[global_emoji_cache, :standard], [site_emoji_cache, :custom]].each do |cache, list_key| + cache_postfix, found_emoji = cache.defer_get_set(normalized_name) do + emoji = Emoji.public_send(list_key).detect do |e| + e.name == normalized_name && + (!is_toned || (is_toned && e.tonable)) + end + [self.cache_postfix, emoji] + end + + if found_emoji && (cache_postfix != self.cache_postfix) + cache.delete(normalized_name) + redo + end + + if found_emoji + break + end + end + + found_emoji + end + + def self.create_from_db_item(emoji) + name = emoji["name"] + filename = emoji['filename'] || name + + Emoji.new.tap do |e| + e.name = name + e.tonable = Emoji.tonable_emojis.include?(name) + e.url = Emoji.url_for(filename) + e.group = groups[name] || DEFAULT_GROUP + end + end + + def self.url_for(name) + name = name.delete_prefix(':').delete_suffix(':').gsub(/(.+):t([1-6])/, '\1/\2') + if SiteSetting.external_emoji_url.blank? + "#{Discourse.base_path}/images/emoji/#{SiteSetting.emoji_set}/#{name}.png?v=#{EMOJI_VERSION}" + else + "#{SiteSetting.external_emoji_url}/#{SiteSetting.emoji_set}/#{name}.png?v=#{EMOJI_VERSION}" + end + end + + def self.cache_key(name) + "#{name}#{cache_postfix}" + end + + def self.cache_postfix + ":v#{EMOJI_VERSION}:#{Plugin::CustomEmoji.cache_key}" + end + + def self.clear_cache + %w{custom standard aliases search_aliases translations all tonable}.each do |key| + Discourse.cache.delete(cache_key("#{key}_emojis")) + end + global_emoji_cache.clear + site_emoji_cache.clear + end + + def self.groups_file + @groups_file ||= "#{Rails.root}/lib/emoji/groups.json" + end + + def self.groups + @groups ||= begin + groups = {} + + File.open(groups_file, "r:UTF-8") { |f| JSON.parse(f.read) }.each do |group| + group["icons"].each do |icon| + groups[icon["name"]] = group["name"] + end + end + + groups + end + end + + def self.db_file + @db_file ||= "#{Rails.root}/lib/emoji/db.json" + end + + def self.db + @db ||= File.open(db_file, "r:UTF-8") { |f| JSON.parse(f.read) } + end + + def self.load_standard + db['emojis'].map { |e| Emoji.create_from_db_item(e) } + end + + def self.load_custom + result = [] + + if !GlobalSetting.skip_db? + CustomEmoji.includes(:upload).order(:name).each do |emoji| + result << Emoji.new.tap do |e| + e.name = emoji.name + e.url = emoji.upload&.url + e.group = emoji.group || DEFAULT_GROUP + end + end + end + + Plugin::CustomEmoji.emojis.each do |group, emojis| + emojis.each do |name, url| + result << Emoji.new.tap do |e| + e.name = name + url = (Discourse.base_path + url) if url[/^\/[^\/]/] + e.url = url + e.group = group || DEFAULT_GROUP + end + end + end + + result + end + + def self.load_translations + db["translations"] + end + + def self.base_directory + "public#{base_url}" + end + + def self.base_url + db = RailsMultisite::ConnectionManagement.current_db + "#{Discourse.base_path}/uploads/#{db}/_emoji" + end + + def self.replacement_code(code) + code + .split('-') + .map!(&:hex) + .pack("U*") + end + + def self.unicode_replacements + @unicode_replacements ||= begin + replacements = {} + is_tonable_emojis = Emoji.tonable_emojis + fitzpatrick_scales = FITZPATRICK_SCALE.map { |scale| scale.to_i(16) } + + db['emojis'].each do |e| + name = e['name'] + + # special cased as we prefer to keep these as symbols + next if name == 'registered' + next if name == 'copyright' + next if name == 'tm' + next if name == 'left_right_arrow' + + code = replacement_code(e['code']) + next unless code + + replacements[code] = name + if is_tonable_emojis.include?(name) + fitzpatrick_scales.each_with_index do |scale, index| + toned_code = code.codepoints.insert(1, scale).pack("U*") + replacements[toned_code] = "#{name}:t#{index + 2}" + end + end + end + + replacements["\u{2639}"] = 'frowning' + replacements["\u{263B}"] = 'slight_smile' + replacements["\u{2661}"] = 'heart' + replacements["\u{2665}"] = 'heart' + replacements["\u{263A}"] = 'relaxed' + + replacements + end + end + + def self.unicode_unescape(string) + PrettyText.escape_emoji(string) + end + + def self.gsub_emoji_to_unicode(str) + if str + str.gsub(/:([\w\-+]*(?::t\d)?):/) { |name| Emoji.lookup_unicode($1) || name } + end + end + + def self.lookup_unicode(name) + @reverse_map ||= begin + map = {} + is_tonable_emojis = Emoji.tonable_emojis + + db['emojis'].each do |e| + next if e['name'] == 'tm' + + code = replacement_code(e['code']) + next unless code + + map[e['name']] = code + if is_tonable_emojis.include?(e['name']) + FITZPATRICK_SCALE.each_with_index do |scale, index| + toned_code = (code.codepoints.insert(1, scale.to_i(16))).pack("U*") + map["#{e['name']}:t#{index + 2}"] = toned_code + end + end + end + + Emoji.aliases.each do |key, alias_names| + next unless alias_code = map[key] + alias_names.each do |alias_name| + map[alias_name] = alias_code + end + end + + map + end + @reverse_map[name] + end + + def self.unicode_replacements_json + @unicode_replacements_json ||= unicode_replacements.to_json + end + + def self.codes_to_img(str) + return if str.blank? + + str = str.gsub(/:([\w\-+]*(?::t\d)?):/) do |name| + code = $1 + + if code && Emoji.custom?(code) + emoji = Emoji[code] + "\"#{code}\"" + elsif code && Emoji.exists?(code) + "\"#{code}\"" + else + name + end + end + end +end