PixNyanNyan/PixNyanNyan-api

View on GitHub
app/models/post.rb

Summary

Maintainability
A
45 mins
Test Coverage
class Post < ApplicationRecord
  include NullifyBlankAttributes

  # image Attachment
  has_attached_file :image, {
    styles: { small: ['125x125>', :jpg], medium: ['250x250>', :jpg], original: '' },
    convert_options: { 
      small: '-quality 80 -interlace Plane -strip',
      medium: '-quality 80 -interlace Plane -strip',
      original: '-strip'
    }
  }
  validates_attachment_content_type :image, content_type: /\Aimage\/(png|gif|jpeg|pjpeg)\z/
  validates_attachment_size :image, in: 0..MAX_IMAGE_KB_SIZE.kilobytes
  serialize :image_dimensions
  before_save :extract_image_dimensions

  # association
  has_many :replies, class_name: 'Post',
    foreign_key: 'parent_post_id', dependent: :destroy
  belongs_to :parent_post, class_name: 'Post', optional: true,
    foreign_key: 'parent_post_id', counter_cache: :reply_count
  has_many :complaints, dependent: :destroy
  belongs_to :admin, optional: true

  # callbacks
  before_validation :generate_id_hash
  before_validation :parse_tripcode
  before_create :prevent_chubou
  before_save :avoid_locked_record
  before_destroy :avoid_locked_record
  after_create_commit -> { broadcast_to_everyone('create') }
  after_update_commit -> { broadcast_to_everyone('update') }
  after_destroy_commit -> { broadcast_to_everyone('destroy') }
  
  # validations
  validates :message, length: { maximum: MAX_POST_MESSAGE_WORDCOUNT }
  validates :title, length: { maximum: 200 }
  validates :author, length: { maximum: 200 }
  validates :email, length: { maximum: 200 }
  validates :client_id, length: { maximum: 128 }
  validates :ip, presence: true
  validates :admin, presence: true, if: -> { admin_id.present? }
  validate :parent_post_presence, if: -> { parent_post_id.present? }
  validate :content_presence

  # scopes
  default_scope { order(id: :asc) }
  scope :recent, -> { reorder(id: :desc) }
  scope :threads, -> { where(parent_post_id: nil) }
  scope :before, -> cursor { where('id < ?', cursor) if cursor.present? }
  scope :after, -> cursor { where('id > ?', cursor) if cursor.present? }
  scope :in_range, -> (lower, upper) { after(lower).before(upper) }
  scope :by_identity_hash, -> keyword { where(identity_hash: keyword) }
  scope :by_tripcode, -> keyword { where(tripcode: keyword) }
  scope :by_client_id, -> keyword { where(client_id: keyword) }
  scope :by_title, -> keyword { where('title @@ ?', keyword) }
  scope :by_author, -> keyword { where('author @@ ?', keyword) }
  scope :by_email, -> keyword { where('email @@ ?', keyword) }
  scope :by_message, -> keyword { where('message @@ ?', keyword) }

  def self.send_chain(methods)
    methods.inject(self) do |chain, scope|
      chain.send(*scope)
    end
  end

  def self.latest_replies(parents, limit)
    return [] unless parents.present?

    parent_ids = parents.map{|p| p.id.to_i }.join(',')
    limit = limit.to_i
    sql = <<-SQL
      select * from posts
      join lateral (
        select * from posts p_inner
        where p_inner.parent_post_id = posts.id
        order by p_inner.id desc
        limit #{limit}
      ) p on true
      where posts.id in (#{parent_ids})
      order by posts.id asc, p.id asc
    SQL

    find_by_sql(sql).group_by{|x| x.parent_post_id}
  end

  def self.gen_passwd(passwd)
    Digest::SHA1.base64digest(passwd)
  end

  def is_admin
    admin_id.present?
  end

  def delete_password=(passwd)
    self[:delete_password] = passwd.blank? ? nil : self.class.gen_passwd(passwd)
  end
  
  protected

  ## Validators

  def parent_post_presence
    unless Post.threads.find_by(id: self[:parent_post_id])
      errors.add(:parent_post_id, 'incorrect reference')
    end
  end

  def content_presence
    if message.blank? && image.blank?
      errors.add(:base, 'Message and image are both empty')
    end
  end

  ## Callbacks

  def generate_id_hash
    ip = self[:ip]
    date = Time.now.to_date.to_s
    id_hash = Digest::SHA1.base64digest(ip + date + Rails.application.secrets.secret_key_base)
    
    self[:identity_hash] = id_hash[0...8]
  end

  def parse_tripcode
    author = self[:author].to_s
    match = author.match(/\A(.*)#(.*)\z/)
    if match.nil?
      self[:tripcode] = nil
      return
    end

    secret = match[2].force_encoding(Encoding::ASCII_8BIT)
    salt = "#{secret}H."[1..2]
    salt.gsub!(/[^\.-z]/, '.')
    salt.tr!(':-@[-`', 'A-Ga-f')

    self[:author] = match[1]
    self[:tripcode] = secret.crypt(salt)[-10..-1]
  end

  def prevent_chubou
    if Blocklist.where('ip >>= ?', self[:ip]).size > 0
      throw :abort
    end

    if client_id && Blocklist.where(client_id: client_id).size > 0
      throw :abort
    end

    image_file = image.staged_path || image.path
    image_hash = image_file.nil? ? nil : Digest::SHA1.file(image_file).base64digest
    if image_hash && Blocklist.where(image_hash: image_hash).size > 0
      throw :abort
    end
  end

  def avoid_locked_record
    if (!locked_changed? && locked) || (parent_post && parent_post.locked)
      throw :abort
    end
  end

  def extract_image_dimensions
    return if image_content_type.nil?

    tempfile = image.queued_for_write[:original]
    unless tempfile.nil?
      geometry = Paperclip::Geometry.from_file(tempfile)
      self[:image_dimensions] = [geometry.width.to_i, geometry.height.to_i]
    end
  end

  def broadcast_to_everyone(action)
    content = {action: action, obj: ApplicationController.render(json: self)}
    ActionCable.server.broadcast('posts_channel', content)
  end
end