# frozen_string_literal: true require_dependency 'enum' class Group < ActiveRecord::Base include HasCustomFields include AnonCacheInvalidator has_many :category_groups, dependent: :destroy has_many :group_users, dependent: :destroy has_many :group_mentions, dependent: :destroy has_many :group_archived_messages, dependent: :destroy has_many :categories, through: :category_groups has_many :users, through: :group_users has_many :group_histories, dependent: :destroy has_and_belongs_to_many :web_hooks before_save :downcase_incoming_email before_save :cook_bio after_save :destroy_deletions after_save :automatic_group_membership after_save :update_primary_group after_save :update_title after_save :enqueue_update_mentions_job, if: Proc.new { |g| g.name_was && g.name_changed? } after_save :expire_cache after_destroy :expire_cache def expire_cache ApplicationSerializer.expire_cache_fragment!("group_names") end validate :name_format_validator validates_uniqueness_of :name, case_sensitive: false validate :automatic_membership_email_domains_format_validator validate :incoming_email_validator validate :can_allow_membership_requests validates :flair_url, url: true, if: Proc.new { |g| g.flair_url && g.flair_url[0,3] != 'fa-' } AUTO_GROUPS = { :everyone => 0, :admins => 1, :moderators => 2, :staff => 3, :trust_level_0 => 10, :trust_level_1 => 11, :trust_level_2 => 12, :trust_level_3 => 13, :trust_level_4 => 14 } AUTO_GROUP_IDS = Hash[*AUTO_GROUPS.to_a.flatten.reverse] STAFF_GROUPS = [:admins, :moderators, :staff] ALIAS_LEVELS = { :nobody => 0, :only_admins => 1, :mods_and_admins => 2, :members_mods_and_admins => 3, :everyone => 99 } def self.visibility_levels @visibility_levels = Enum.new( public: 0, members: 1, staff: 2, owners: 3 ) end validates :alias_level, inclusion: { in: ALIAS_LEVELS.values} scope :visible_groups, ->(user) { groups = Group.order(name: :asc).where("groups.id > 0") unless user&.admin sql = <<~SQL groups.id IN ( SELECT g.id FROM groups g WHERE g.visibility_level = :public UNION ALL SELECT g.id FROM groups g JOIN group_users gu ON gu.group_id = g.id AND gu.user_id = :user_id WHERE g.visibility_level = :members UNION ALL SELECT g.id FROM groups g LEFT JOIN group_users gu ON gu.group_id = g.id AND gu.user_id = :user_id AND gu.owner WHERE g.visibility_level = :staff AND (gu.id IS NOT NULL OR :is_staff) UNION ALL SELECT g.id FROM groups g JOIN group_users gu ON gu.group_id = g.id AND gu.user_id = :user_id AND gu.owner WHERE g.visibility_level = :owners ) SQL groups = groups.where( sql, Group.visibility_levels.to_h.merge(user_id: user&.id, is_staff: !!user&.staff?) ) end groups } scope :mentionable, lambda {|user| levels = [ALIAS_LEVELS[:everyone]] if user && user.admin? levels = [ALIAS_LEVELS[:everyone], ALIAS_LEVELS[:only_admins], ALIAS_LEVELS[:mods_and_admins], ALIAS_LEVELS[:members_mods_and_admins]] elsif user && user.moderator? levels = [ALIAS_LEVELS[:everyone], ALIAS_LEVELS[:mods_and_admins], ALIAS_LEVELS[:members_mods_and_admins]] end where("alias_level in (:levels) OR ( alias_level = #{ALIAS_LEVELS[:members_mods_and_admins]} AND id in ( SELECT group_id FROM group_users WHERE user_id = :user_id) )", levels: levels, user_id: user && user.id ) } def downcase_incoming_email self.incoming_email = (incoming_email || "").strip.downcase.presence end def cook_bio if !self.bio_raw.blank? self.bio_cooked = PrettyText.cook(self.bio_raw) end end def incoming_email_validator return if self.automatic || self.incoming_email.blank? incoming_email.split("|").each do |email| escaped = Rack::Utils.escape_html(email) if !Email.is_valid?(email) self.errors.add(:base, I18n.t('groups.errors.invalid_incoming_email', email: escaped)) elsif group = Group.where.not(id: self.id).find_by_email(email) self.errors.add(:base, I18n.t('groups.errors.email_already_used_in_group', email: escaped, group_name: Rack::Utils.escape_html(group.name))) elsif category = Category.find_by_email(email) self.errors.add(:base, I18n.t('groups.errors.email_already_used_in_category', email: escaped, category_name: Rack::Utils.escape_html(category.name))) end end end def posts_for(guardian, before_post_id=nil) user_ids = group_users.map { |gu| gu.user_id } result = Post.includes(:user, :topic, topic: :category) .references(:posts, :topics, :category) .where(user_id: user_ids) .where('topics.archetype <> ?', Archetype.private_message) .where(post_type: Post.types[:regular]) result = guardian.filter_allowed_categories(result) result = result.where('posts.id < ?', before_post_id) if before_post_id result.order('posts.created_at desc') end def messages_for(guardian, before_post_id=nil) result = Post.includes(:user, :topic, topic: :category) .references(:posts, :topics, :category) .where('topics.archetype = ?', Archetype.private_message) .where(post_type: Post.types[:regular]) .where('topics.id IN (SELECT topic_id FROM topic_allowed_groups WHERE group_id = ?)', self.id) result = guardian.filter_allowed_categories(result) result = result.where('posts.id < ?', before_post_id) if before_post_id result.order('posts.created_at desc') end def mentioned_posts_for(guardian, before_post_id=nil) result = Post.joins(:group_mentions) .includes(:user, :topic, topic: :category) .references(:posts, :topics, :category) .where('topics.archetype <> ?', Archetype.private_message) .where(post_type: Post.types[:regular]) .where('group_mentions.group_id = ?', self.id) result = guardian.filter_allowed_categories(result) result = result.where('posts.id < ?', before_post_id) if before_post_id result.order('posts.created_at desc') end def self.trust_group_ids (10..19).to_a end def self.refresh_automatic_group!(name) return unless id = AUTO_GROUPS[name] unless group = self.lookup_group(name) group = Group.new(name: name.to_s, automatic: true) group.default_notification_level = 2 if AUTO_GROUPS[:moderators] == id group.id = id group.save! end # don't allow shoddy localization to break this localized_name = I18n.t("groups.default_names.#{name}").downcase validator = UsernameValidator.new(localized_name) if !Group.where("LOWER(name) = ?", localized_name).exists? && validator.valid_format? group.name = localized_name end # the everyone group is special, it can include non-users so there is no # way to have the membership in a table if name == :everyone group.visibility_level = Group.visibility_levels[:owners] group.save! return group end # Remove people from groups they don't belong in. remove_subquery = case name when :admins "SELECT id FROM users WHERE NOT admin" when :moderators "SELECT id FROM users WHERE NOT moderator" when :staff "SELECT id FROM users WHERE NOT admin AND NOT moderator" when :trust_level_0, :trust_level_1, :trust_level_2, :trust_level_3, :trust_level_4 "SELECT id FROM users WHERE trust_level < #{id - 10}" end exec_sql <<-SQL DELETE FROM group_users USING (#{remove_subquery}) X WHERE group_id = #{group.id} AND user_id = X.id SQL # Add people to groups insert_subquery = case name when :admins "SELECT id FROM users WHERE admin" when :moderators "SELECT id FROM users WHERE moderator" when :staff "SELECT id FROM users WHERE moderator OR admin" when :trust_level_1, :trust_level_2, :trust_level_3, :trust_level_4 "SELECT id FROM users WHERE trust_level >= #{id - 10}" when :trust_level_0 "SELECT id FROM users" end exec_sql <<-SQL INSERT INTO group_users (group_id, user_id, created_at, updated_at) SELECT #{group.id}, X.id, now(), now() FROM group_users RIGHT JOIN (#{insert_subquery}) X ON X.id = user_id AND group_id = #{group.id} WHERE user_id IS NULL SQL group.save! # we want to ensure consistency Group.reset_counters(group.id, :group_users) group end def self.ensure_consistency! reset_all_counters! refresh_automatic_groups! end def self.reset_all_counters! exec_sql <<-SQL WITH X AS ( SELECT group_id , COUNT(user_id) users FROM group_users GROUP BY group_id ) UPDATE groups SET user_count = X.users FROM X WHERE id = X.group_id AND user_count <> X.users SQL end def self.refresh_automatic_groups!(*args) args = AUTO_GROUPS.keys if args.empty? args.each { |group| refresh_automatic_group!(group) } end def self.ensure_automatic_groups! AUTO_GROUPS.each_key do |name| refresh_automatic_group!(name) unless lookup_group(name) end end def self.[](name) lookup_group(name) || refresh_automatic_group!(name) end def self.search_group(name) Group.where(visibility_level: visibility_levels[:public]).where( "name ILIKE :term_like OR full_name ILIKE :term_like", term_like: "#{name}%" ) end def self.lookup_group(name) if id = AUTO_GROUPS[name] Group.find_by(id: id) else unless group = Group.find_by(name: name) raise ArgumentError, "unknown group" end group end end def self.lookup_group_ids(opts) if group_ids = opts[:group_ids] group_ids = group_ids.split(",").map(&:to_i) group_ids = Group.where(id: group_ids).pluck(:id) end group_ids ||= [] if group_names = opts[:group_names] group_names = group_names.split(",") if group_names.present? group_ids += Group.where(name: group_names).pluck(:id) end end group_ids end def self.desired_trust_level_groups(trust_level) trust_group_ids.keep_if do |id| id == AUTO_GROUPS[:trust_level_0] || (trust_level + 10) >= id end end def self.user_trust_level_change!(user_id, trust_level) desired = desired_trust_level_groups(trust_level) undesired = trust_group_ids - desired GroupUser.where(group_id: undesired, user_id: user_id).delete_all desired.each do |id| if group = find_by(id: id) unless GroupUser.where(group_id: id, user_id: user_id).exists? group.group_users.create!(user_id: user_id) end else name = AUTO_GROUP_IDS[trust_level] refresh_automatic_group!(name) end end end def self.builtin Enum.new(:moderators, :admins, :trust_level_1, :trust_level_2) end def usernames=(val) current = usernames.split(",") expected = val.split(",") additions = expected - current deletions = current - expected map = Hash[*User.where(username: additions+deletions) .select('id,username') .map{|u| [u.username,u.id]}.flatten] deletions = Set.new(deletions.map{|d| map[d]}) @deletions = [] group_users.each do |gu| @deletions << gu if deletions.include?(gu.user_id) end additions.each do |a| group_users.build(user_id: map[a]) end end def usernames users.pluck(:username).join(",") end def add(user) self.users.push(user) unless self.users.include?(user) MessageBus.publish('/categories', { categories: ActiveModel::ArraySerializer.new(self.categories).as_json }, user_ids: [user.id]) self end def remove(user) self.group_users.where(user: user).each(&:destroy) user.update_columns(primary_group_id: nil) if user.primary_group_id == self.id end def add_owner(user) if group_user = self.group_users.find_by(user: user) group_user.update_attributes!(owner: true) if !group_user.owner else GroupUser.create!(user: user, group: self, owner: true) end end def self.find_by_email(email) self.where("string_to_array(incoming_email, '|') @> ARRAY[?]", Email.downcase(email)).first end def bulk_add(user_ids) if user_ids.present? Group.exec_sql("INSERT INTO group_users (group_id, user_id, created_at, updated_at) SELECT #{self.id}, u.id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM users AS u WHERE u.id IN (#{user_ids.join(', ')}) AND NOT EXISTS(SELECT 1 FROM group_users AS gu WHERE gu.user_id = u.id AND gu.group_id = #{self.id})") if self.primary_group? User.where(id: user_ids).update_all(primary_group_id: self.id) end if self.title.present? User.where(id: user_ids).update_all(title: self.title) end if self.grant_trust_level.present? Jobs.enqueue(:bulk_grant_trust_level, user_ids: user_ids, trust_level: self.grant_trust_level) end end true end def staff? STAFF_GROUPS.include?(self.name.to_sym) end protected def name_format_validator UsernameValidator.perform_validation(self, 'name') end def automatic_membership_email_domains_format_validator return if self.automatic_membership_email_domains.blank? domains = self.automatic_membership_email_domains.split("|") domains.each do |domain| domain.sub!(/^https?:\/\//, '') domain.sub!(/\/.*$/, '') self.errors.add :base, (I18n.t('groups.errors.invalid_domain', domain: domain)) unless domain =~ /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,24}(:[0-9]{1,5})?(\/.*)?\Z/i end self.automatic_membership_email_domains = domains.join("|") end # hack around AR def destroy_deletions if @deletions @deletions.each do |gu| gu.destroy User.where('id = ? AND primary_group_id = ?', gu.user_id, gu.group_id).update_all 'primary_group_id = NULL' end end @deletions = nil end def automatic_group_membership if self.automatic_membership_retroactive Jobs.enqueue(:automatic_group_membership, group_id: self.id) end end def update_title return if new_record? && !self.title.present? if self.title_changed? sql = <<-SQL.squish UPDATE users SET title = :title WHERE (title = :title_was OR title = '' OR title IS NULL) AND COALESCE(title,'') <> COALESCE(:title,'') AND id IN (SELECT user_id FROM group_users WHERE group_id = :id) SQL self.class.exec_sql(sql, title: title, title_was: title_was, id: id) end end def update_primary_group return if new_record? && !self.primary_group? if self.primary_group_changed? sql = <<~SQL UPDATE users /*set*/ /*where*/ SQL builder = SqlBuilder.new(sql) builder.where(" id IN ( SELECT user_id FROM group_users WHERE group_id = :id )", id: id) if primary_group builder.set("primary_group_id = :id") else builder.set("primary_group_id = NULL") builder.where("primary_group_id = :id") end builder.exec end end private def can_allow_membership_requests if self.allow_membership_requests && !self.group_users.where(owner: true).exists? self.errors.add(:base, I18n.t('groups.errors.cant_allow_membership_requests')) end end def enqueue_update_mentions_job Jobs.enqueue(:update_group_mentions, previous_name: self.name_was, group_id: self.id ) end end # == Schema Information # # Table name: groups # # id :integer not null, primary key # name :string not null # created_at :datetime not null # updated_at :datetime not null # automatic :boolean default(FALSE), not null # user_count :integer default(0), not null # alias_level :integer default(0) # automatic_membership_email_domains :text # automatic_membership_retroactive :boolean default(FALSE) # primary_group :boolean default(FALSE), not null # title :string # grant_trust_level :integer # incoming_email :string # has_messages :boolean default(FALSE), not null # flair_url :string # flair_bg_color :string # flair_color :string # bio_raw :text # bio_cooked :text # public :boolean default(FALSE), not null # allow_membership_requests :boolean default(FALSE), not null # full_name :string # default_notification_level :integer default(3), not null # visibility_level :integer default(0) # # Indexes # # index_groups_on_incoming_email (incoming_email) UNIQUE # index_groups_on_name (name) UNIQUE #