publiclab/plots2

View on GitHub
app/models/comment.rb

Summary

Maintainability
F
3 days
Test Coverage
class Comment < ApplicationRecord
  include CommentsShared
  extend RawStats

  belongs_to :node, foreign_key: 'nid', touch: true, counter_cache: true
  belongs_to :user, foreign_key: 'uid'
  has_many :likes, as: :likeable

  has_many :replied_comments, class_name: "Comment", foreign_key: 'reply_to', dependent: :destroy

  validates :comment, presence: true

  scope :published, -> { where(status: 1) }

  self.table_name = 'comments'
  self.primary_key = 'cid'

  COMMENT_FILTER = "<!-- @@$$%% Trimmed Content @@$$%% -->".freeze

  def self.inheritance_column
    'rails_type'
  end

  def self.search(query)
    Comment.where('MATCH(comment) AGAINST(?)', query)
      .where(status: 1)
  end

  def self.comment_weekly_tallies(span = 52, time = Time.current)
    weeks = {}
    (0..span).each do |week|
      weeks[span - week] = Comment.select(:timestamp)
        .where(timestamp: time.to_i - week.weeks.to_i..time.to_i - (week - 1).weeks.to_i)
        .size
    end
    weeks
  end

  def self.contribution_graph_making(start = Time.now - 1.year, fin = Time.now)
    date_hash = {}
    week = start.to_date.step(fin.to_date, 7).count

    while week >= 1
      month = (fin - (week * 7 - 1).days)
      range = (fin.to_i - week.weeks.to_i)..(fin.to_i - (week - 1).weeks.to_i)

      weekly_comments = Comment.select(:status, :timestamp)
                         .where(status: 1, timestamp: range)
                         .size
      date_hash[month.to_f * 1000] = weekly_comments
      week -= 1
    end
    date_hash
  end

  def id
    cid
  end

  def created_at
    Time.at(timestamp)
  end

  def body
    finder = comment.gsub(Callouts.const_get(:FINDER), Callouts.const_get(:PRETTYLINKMD))
    finder = finder.gsub(Callouts.const_get(:HASHTAGNUMBER), Callouts.const_get(:NODELINKMD))
    finder = finder.gsub(Callouts.const_get(:HASHTAG), Callouts.const_get(:HASHLINKMD))
    ApplicationController.helpers.emojify(finder)
  end

  def body_markdown
    RDiscount.new(body, :autolink).to_html
  end

  def icon
    "<i class='icon-comment'></i>"
  end

  def type
    'comment'
  end

  def tags
    []
  end

  def next_thread
    (thread.split('/').first.to_i(16) + 1).to_s(16).rjust(2, '0') + '/'
  end

  def parent
    node
  end

  def status_value
    if status == 0
      'Banned'
    elsif status == 1
      'Normal'
    elsif status == 4
      'Moderated'
    else
      'Not Defined'
    end
  end

  def mentioned_users
    usernames = comment.scan(Callouts.const_get(:FINDER))
    User.where(username: usernames.map { |m| m[1] }).distinct
  end

  def followers_of_mentioned_tags
    tagnames = comment.scan(Callouts.const_get(:HASHTAG))
    tagnames.map { |tagname| Tag.followers(tagname[1]) }.flatten.uniq
  end

  def notify_callout_users
    # notify mentioned users
    mentioned_users.each do |user|
      CommentMailer.notify_callout(self, user).deliver_later if user.username != author.username
    end
  end

  def notify_tag_followers(already_mailed_uids = [])
    # notify users who follow the tags mentioned in the comment
    followers_of_mentioned_tags.each do |user|
      CommentMailer.notify_tag_followers(self, user).deliver_later unless already_mailed_uids.include?(user.uid)
    end
  end

  def notify_users(uids, current_user)
    User.where('id IN (?)', uids).find_each do |user|
      if user.uid != current_user.uid
        CommentMailer.notify(user, self).deliver_later
      end
    end
  end

  # email all users in this thread
  # plus all who've starred it
  def notify(current_user)
    if status == 4
      AdminMailer.notify_comment_moderators(self).deliver_later!(wait_until: 24.hours.from_now)
    else
      if parent.uid != current_user.uid && !UserTag.exists?(parent.uid, 'notify-comment-direct:false')
        CommentMailer.notify_note_author(parent.author, self).deliver_later
      end

      if parent.status != 3 # if it's not a draft
        notify_callout_users

        # notify other commenters, revisers, and likers, but not those already @called out
        already = mentioned_users.collect(&:uid) + [parent.uid]
        uids = uids_to_notify - already
        uids+= current_user.followers.collect(&:uid)
        uids.uniq!

        # Send Browser Notification Using Action Cable
        notify_user_ids = uids_to_notify + already
        notify_user_ids = notify_user_ids.uniq
        send_browser_notification notify_user_ids

        uids = uids.select { |i| i != 0 } # remove bad comments (some early ones lack uid)

        notify_users(uids, current_user)
        notify_tag_followers(already + uids)
      end
    end
  end

  def send_browser_notification(users_ids)
    notification = Hash.new
    notification[:title] = "New Comment on #{parent.title}"
    option = {
      data: parent.path,
      body: comment,
      icon: "https://publiclab.org/logo.png"
    }
    notification[:option] = option
    users_ids.each do |uid|
      if UserTag.where(value: 'notifications:all', uid: uid).any?
        ActionCable.server.broadcast "users:notification:#{uid}", notification: notification
      end
    end
  end

  def spam
    self.status = 0
    save
    self
  end

  def publish
    self.status = 1
    save
    self
  end

  def flag_comment
    self.flag += 1
    save
    self
  end

  def unflag_comment
    self.flag = 0
    save
    self
  end

  def liked_by(user_id)
    likes.where(user_id: user_id).present?
  end

  def likers
    User.where(id: likes.pluck(:user_id))
  end

  def user_reactions_map
    # select likes from users that aren't banned (status = 0)
    likes_map = likes.joins(:user).select(:emoji_type, :username, :status).where("emoji_type IS NOT NULL").where("status != 0").group_by(&:emoji_type)
    user_like_map = {}
    likes_map.each do |reaction, likes|
      users = []
      likes.each do |like|
        users << like.username
      end
      emoji_type = reaction.underscore.humanize.downcase
      users_string = (users.size > 1 ? users[0..-2].join(", ") + " and " + users[-1] : users[0]) + " reacted with " + emoji_type + " emoji"
      like_data = {}
      like_data[:users_string] = users_string
      like_data[:likes_num] = users.size
      user_like_map[reaction] = like_data
    end
    user_like_map
  end

  def self.new_comment_from_email(mail)
    user = User.where(email: mail.from.first).first
    if user
      node_id = mail.subject[/#([\d]+)/, 1] # This tooks out the node ID from the subject line
      comment_id = mail.subject[/#c([\d]+)/, 1] # This tooks out the comment ID from the subject line if it exists
      unless Comment.where(message_id: mail.message_id).any?
        if node_id.present? && !comment_id.present?
          parse_comment_from_email(mail, node_id, user)
        elsif comment_id.present?
          comment = Comment.find comment_id
          parse_comment_from_email(mail, comment.nid, user, [true, comment.id])
        end
      end
      return node_id
    else
      email = mail.select { |s| s.match(/.*@.*/) }
      ActionMailer::Base.mail(
        from: "do-not-reply@publiclab.org",
        to: email,
        subject: "Could not post your reply",
        body: "Your reply wasn't posted since we couldn't find your account on publiclab.org. Please sign up at https://publiclab.org or try sending email from the account matching your username. Thank you!"
      ).deliver
    end
  end

  # parse mail and add comments based on emailed replies
  def self.parse_comment_from_email(mail, node_id, user, reply_to = [false, nil])
    node = Node.where(nid: node_id).first
    if node && mail&.html_part
      mail_doc = Nokogiri::HTML(mail&.html_part&.body&.decoded) # To parse the mail to extract comment content and reply content
      domain = get_domain mail.from.first
      content = if domain == "gmail"
                  gmail_parsed_mail mail_doc
                elsif domain == "yahoo"
                  yahoo_parsed_mail mail_doc
                elsif domain == "outlook"
                  outlook_parsed_mail mail_doc
                elsif gmail_quote_present?(mail_doc)
                  gmail_parsed_mail mail_doc
                else
                  {
                    comment_content: mail_doc,
                    extra_content: nil
                  }
                end
      if content[:extra_content].nil?
        comment_content_markdown = ReverseMarkdown.convert content[:comment_content]
      else
        extra_content_markdown = ReverseMarkdown.convert content[:extra_content]
        comment_content_markdown = ReverseMarkdown.convert content[:comment_content]
        comment_content_markdown = comment_content_markdown + COMMENT_FILTER + extra_content_markdown
      end
      message_id = mail.message_id

      # only process the email if it passese our auto-reply filters; no out-of-office responses!
      unless is_autoreply(mail)
        comment = node.add_comment(uid: user.uid, body: comment_content_markdown, comment_via: 1, message_id: message_id)
        if reply_to[0]
          comment.reply_to = reply_to[1]
          comment.save
        end
        comment.notify user
      end
    end
  end

  # parses emails to detect whether they are "autoreplies" or "out of office" messages
  def self.is_autoreply(mail)
    autoreply = false
    autoreply = true if mail.header['Precedence'] && mail.header['Precedence'].value == "list"
    autoreply = true if mail.header['Precedence'] && mail.header['Precedence'].value == "junk"
    autoreply = true if mail.header['Precedence'] && mail.header['Precedence'].value == "bulk"
    autoreply = true if mail.header['Precedence'] && mail.header['Precedence'].value == "auto_reply"
    autoreply = true if mail.from.join(',').include?('mailer-daemon')
    autoreply = true if mail.from.join(',').include?('postmaster')
    autoreply = true if mail.from.join(',').include?('noreply')
    autoreply = true if mail.header.collect(&:value).join(',').downcase.include?('auto-submitted')
    autoreply = true if mail.header.collect(&:value).join(',').downcase.include?('auto-replied')
    autoreply = true if mail.header.collect(&:value).join(',').downcase.include?('auto-reply')
    autoreply = true if mail.header.collect(&:value).join(',').downcase.include?('auto-generated')
    autoreply = true if mail.header.collect(&:name).join(',').downcase.include?('auto-submitted')
    autoreply = true if mail.header.collect(&:name).join(',').downcase.include?('auto-replied')
    autoreply = true if mail.header.collect(&:name).join(',').downcase.include?('auto-reply')
    autoreply = true if mail.header.collect(&:name).join(',').downcase.include?('auto-generated')
    autoreply
  end

  def self.gmail_quote_present?(mail_doc)
    mail_doc.css(".gmail_quote").any?
  end

  def self.get_domain(email)
    email[/(?<=@)[^.]+(?=\.)/, 0]
  end

  def self.yahoo_quote_present?(mail_doc)
    mail_doc.css(".yahoo_quoted").any?
  end

  def self.yahoo_parsed_mail(mail_doc)
    if yahoo_quote_present?(mail_doc)
      extra_content = mail_doc.css(".yahoo_quoted")[0]
      mail_doc.css(".yahoo_quoted")[0].remove
      comment_content = mail_doc
    else
      comment_content = mail_doc
      extra_content = nil
    end

    {
      comment_content: comment_content,
      extra_content: extra_content
    }
  end

  def self.gmail_parsed_mail(mail_doc)
    if mail_doc.css(".gmail_quote").any?
      extra_content = mail_doc.css(".gmail_quote")[0]
      mail_doc.css(".gmail_quote")[0].remove
      comment_content = mail_doc
    else
      comment_content = mail_doc
      extra_content = nil
    end

    {
      comment_content: comment_content,
      extra_content: extra_content
    }
  end

  def self.outlook_parsed_mail(mail_doc)
    separator = mail_doc.inner_html.match(/(.+)(<div id="appendonsend"><\/div>)(.+)/m)
    if separator.nil?
      comment_content = mail_doc
      extra_content = nil
    else
      body_message = separator[1].match(/(.+)(<body dir="ltr">)(.+)/m)
      comment_content = Nokogiri::HTML(body_message[3])
      trimmed_message = separator[3].match(/(.+)(<\/body>)(.+)/m)
      extra_content = Nokogiri::HTML(trimmed_message[1])
    end

    {
      comment_content: comment_content,
      extra_content: extra_content
    }
  end

  def trimmed_content?
    comment.include?(COMMENT_FILTER)
  end

  def self.receive_tweet
    comments = Comment.where.not(tweet_id: nil)
    if comments.any?
      receive_tweet_using_since comments
    else
      receive_tweet_without_using_since
    end
  end

  def self.receive_tweet_using_since(comments)
    comment = comments.last
    since_id = comment.tweet_id
    tweets = Client.search(ENV["TWEET_SEARCH"], since_id: since_id).collect do |tweet|
      tweet
    end
    tweets.each do |tweet|
      puts tweet.text
    end
    tweets = tweets.reverse
    check_and_add_tweets tweets
  end

  def self.receive_tweet_without_using_since
    tweets = Client.search(ENV["TWEET_SEARCH"]).collect do |tweet|
      tweet
    end
    tweets = tweets.reverse
    check_and_add_tweets tweets
    tweets.each do |tweet|
    end
  end

  def self.check_and_add_tweets(tweets)
    tweets.each do |tweet|
      next unless tweet.reply?

      in_reply_to_tweet_id = tweet.in_reply_to_tweet_id
      next unless in_reply_to_tweet_id.class == Integer

      parent_tweet = Client.status(in_reply_to_tweet_id, tweet_mode: "extended")
      parent_tweet_full_text = parent_tweet.attrs[:text] || parent_tweet.attrs[:full_text]
      urls = URI.extract(parent_tweet_full_text)
      node = get_node_from_urls_present(urls)
      next if node.nil?

      twitter_user_name = tweet.user.screen_name
      tweet_email = find_email(twitter_user_name)
      users = User.where(email: tweet_email)
      next unless users.any?

      user = users.first
      replied_tweet_text = tweet.text

      if tweet.truncated?
        replied_tweet = Client.status(tweet.id, tweet_mode: "extended")
        replied_tweet_text = replied_tweet.attrs[:text] || replied_tweet.attrs[:full_text]
      end
      replied_tweet_text = replied_tweet_text.gsub(/@(\S+)/) { |m| "[#{m}](https://twitter.com/#{m})" }
      replied_tweet_text = replied_tweet_text.delete('@')
      comment = node.add_comment(uid: user.uid, body: replied_tweet_text, comment_via: 2, tweet_id: tweet.id)
      comment.notify user
    end
  end

  def self.get_node_from_urls_present(urls)
    urls.each do |url|
      next unless url.include? "https://"

      if url.last == "."
        url = url[0...url.size - 1]
      end
      response = Net::HTTP.get_response(URI(url))
      redirected_url = response['location']

      next unless !redirected_url.nil? && redirected_url.include?(ENV["WEBSITE_HOST_PATTERN"])

      node_id = redirected_url.split("/")[-1]

      next if node_id.nil?

      node = Node.where(nid: node_id.to_i)
      if node.any?
        return node.first
      end
    end
    nil
  end

  def self.find_email(twitter_user_name)
    UserTag.where('value LIKE (?)', 'oauth:twitter%').where.not(data: nil).each do |user_tag|
      data = user_tag["data"]
      if !data.nil? && !data["info"].nil? && !data["info"]["nickname"].nil? && data["info"]["nickname"].to_s == twitter_user_name
        return user_tag.user.email
      end
    end
  end

  def parse_quoted_text
    if regex_match = body.match(/(.+)(On .+<.+@.+> wrote:)(.+)/m)
      {
        body: regex_match[1],     # The new message text
        boundary: regex_match[2], # Quote delimeter, i.e. "On Tuesday, 3 July 2018, 11:20:57 PM IST, RP <rp@email.com> wrote:"
        quote: regex_match[3]     # Quoted text from prior email chain
      }
    else
      {}
    end
  end

  def scrub_quoted_text
    parse_quoted_text[:body]
  end

  def render_body
    body = RDiscount.new(title_suggestion(self)).to_html
    # if it has quoted email text that wasn't caught by the yahoo and gmail filters,
    # manually insert the comment filter delimeter:
    parsed = parse_quoted_text
    if !trimmed_content? && parsed.present?
      body = parsed[:body] + COMMENT_FILTER + parsed[:boundary] + parsed[:quote]
    end

    allowed_tags = %w(a acronym b strong i em li ul ol h1 h2 h3 h4 h5 h6 blockquote br cite sub sup ins p iframe del hr img input code table thead tbody tr th td span dl dt dd div)

    # Sanitize the HTML (remove malicious attributes, unallowed tags...)
    sanitized_body = ActionController::Base.helpers.sanitize(body, tags: allowed_tags)

    # Properly parse HTML (close incomplete tags...)
    Nokogiri::HTML::DocumentFragment.parse(sanitized_body).to_html
  end

  def self.find_by_tag_and_author(tagname, userid)
    Comment.where(uid: userid)
      .where(node: Node.where(status: 1)
        .includes(:node_tag, :tag)
        .references(:node, :term_data)
        .where('term_data.name = ?', tagname))
      .order('timestamp DESC')
  end
end