# frozen_string_literal: true class APIImage < ApplicationRecord belongs_to_creator attr_accessor :file, :exception, :deletion_reason, :no_webhook_messages validates :category, presence: true, inclusion: { in: -> { APIImage.categories.map(&:db) } } validates :id, uniqueness: true, on: :file validate on: :file do |image| ext, mime = file_header_info(file.path) image.errors.add(:file, "type is invalid (#{mime})") if ext == mime end validate do |image| image.errors.add(:base, exception.message) if exception end after_create :invalidate_cache after_create :send_created, unless: :no_webhook_messages after_create :initialize_short_url after_update :update_files, if: :saved_change_to_category? after_update :send_updated, unless: :no_webhook_messages after_destroy :delete_files after_destroy :send_deleted, unless: :no_webhook_messages def delete_files Websites.config.yiffy2_storage.delete(path) Websites.config.yiffy2_backup_storage.delete(path) invalidate_cache end def update_files file = Websites.config.yiffy2_storage.get(path_before_last_save) Websites.config.yiffy2_storage.put(path, file) Websites.config.yiffy2_storage.delete(path_before_last_save) Websites.config.yiffy2_backup_storage.put(path, file) Websites.config.yiffy2_backup_storage.delete(path_before_last_save) invalidate_cache end def file_header_info(file_path) File.open(file_path) do |bin| mime_type = Marcel::MimeType.for(bin) ext = case mime_type when "image/jpeg" "jpg" when "image/gif" "gif" when "image/png" "png" else mime_type end [mime_type, ext] end end def invalidate_cache Cache.redis.del("yiffy2:images:#{category_before_last_save}") Cache.redis.del("yiffy2:images:#{category}") end module SearchMethods def random(category, limit, size_limit = nil) q = where(category: category) q = q.where(file_size: ..size_limit) if size_limit q.ids.sample(limit).map(&method(:find)) end def bulk(category_map, size_limit = nil) data = {} category_map.each do |category, limit| data[category] = random(category, limit, size_limit) end data end def artists_like(name) where(id: APIImage.from("unnest(artists) AS artist").where("artist LIKE ?", name.to_escaped_for_sql_like)) end def search(params) q = super q = q.attribute_matches(:category, params[:category]) q = q.attribute_matches(:original_url, params[:original_url]) q = q.artists_like(params[:artist]) if params[:artist].present? q.order(created_at: :desc) end end extend SearchMethods def md5 id.gsub("-", "") end alias stripped_md5 md5 def path_before_last_save "/#{category_before_last_save.tr('.', '/')}/#{md5}.#{file_ext}" end def path "/#{category.tr('.', '/')}/#{md5}.#{file_ext}" end def url Websites.config.yiffy2_storage.url_for(self) end def short_url ShortUrl.override(md5, url).shorturl end def initialize_short_url ShortUrl.override(md5, url) end def sources_string sources.join("\n") end def sources_string=(str) self.sources = str.split("\n").map(&:strip) end def artists_string artists.join("\n") end def artists_string=(str) self.artists = str.split("\n").map(&:strip) end def serializable_hash(*) { artists: artists, sources: sources, width: width, height: height, url: url, type: mime_type, name: "#{md5}.#{file_ext}", id: md5, ext: file_ext, size: file_size, reportURL: nil, shortURL: short_url, } end def self.categories sfw = %w[animals.birb animals.blep animals.dikdik furry.boop furry.cuddle furry.flop furry.fursuit furry.hold furry.howl furry.hug furry.kiss furry.lick furry.propose] nsfw = %w[furry.butts furry.bulge furry.yiff.andromorph furry.yiff.gay furry.yiff.gynomorph furry.yiff.lesbian furry.yiff.straight] titles = { **sfw.index_with { |c| c.split(".").map(&:capitalize).join(" > ") }, "animals.dikdik" => "Animals > Dik Dik", **nsfw.index_with { |c| c.split(".").map(&:capitalize).join(" > ") }, } sfw.map { |cat| APICategory.new(titles[cat], cat, true) } .concat(nsfw.map { |cat| APICategory.new(titles[cat], cat, false) }) end def self.category_title(db) categories.find { |k| k == db }.try(:db) || db.split(".").map(&:capitalize).join(" > ") end def self.cached_count(category) count = Cache.redis.get("yiffy2:images:#{category}") if count.nil? count = APIImage.where(category: category).count # images aren't changing so we really don't need any expiry, but I still want an expiry just in case Cache.redis.set("yiffy2:images:#{category}", count.to_s, ex: 60 * 60 * 24 * 7) end count.to_i end def self.state data = group(:category).count.map do |k, v| { count: v, name: category_title(k), category: k, state: v < 5 ? "red" : v < 20 ? "yellow" : "green", } end [*data, { count: data.pluck(:count).reduce(:+), name: "Total", category: "total", state: "total", },] end def self.sync_all sync_e621 end # TODO def self.sync_e621 images = joins("CROSS JOIN unnest(sources) AS source").where("source ILIKE ?", "%e621.net%").distinct.limit(300) ids = [] md5s = [] images.each do |img| img.sources.each do |source| next unless source.include?("e621.net") id = source.split("/").last ids << id.to_i if id.to_i.to_s == id end md5s << img.md5 end Requests::E621.get_all_posts(ids: ids, md5s: md5s) => { posts:, missing: } Rails.logger.info("Failed to find #{missing[:ids].length} posts") unless missing[:ids].empty? mismatch = images.select { |img| posts.none? { |post| post["file"]["md5"] == img.md5 } } Rails.logger.info("Found #{mismatch.length} mismatched images") unless mismatch.empty? posts end def sync APIImageSyncJob.perform_later(self) end module WebhookMethods GREEN = 0x008000 YELLOW = 0xFFA500 RED = 0xFF0000 GREEN_TICK = "<:GreenTick:1235058920762904576>" RED_TICK = "<:RedTick:1235058898549870724>" def execute(content) Websites.config.yiffyapi_image_logs_webhook.execute({ embeds: [content], }) end def send_created execute({ title: "Image Uploaded", description: <<~DESC, ID: `#{md5}` Category: `#{category}` Artists: `#{artists.join(', ')}` Sources: #{sources.map { |s| "* #{s}" }.join("\n")} Upload Type: #{original_url.present? ? "[url](#{original_url})" : 'file'} Uploaded By: <@#{creator.id}> (`#{creator.name}`) Created At: Updated At: DESC color: GREEN, timestamp: Time.now.iso8601, }) end def send_deleted execute({ title: "Image Deleted", description: <<~DESC, ID: `#{md5}` Category: `#{category}` Artists: `#{artists.join(', ')}` Sources: #{sources.map { |s| "* #{s}" }.join("\n")} Upload Type: #{original_url.present? ? "[url](#{original_url})" : 'file'} Uploaded By: <@#{creator.id}> (`#{creator.name}`) Reason: #{deletion_reason || 'None Provided'} Created At: Updated At: DESC color: RED, timestamp: Time.now.iso8601, }) end def check_change(attr, changes) changes << "#{attr.to_s.titleize}: **#{send("#{attr}_before_last_save")}** -> **#{send(attr)}**" if send("saved_change_to_#{attr}?") end def send_updated changes = [] check_change(:artists, changes) check_change(:category, changes) if sources != sources_before_last_save diff = [] sources_before_last_save.each do |source| diff << "- #{source}" unless sources.include?(source) end sources.each do |source| diff << "+ #{source}" unless sources_before_last_save.include?(source) end changes << "Sources:\n```diff\n#{diff.join("\n")}\n```" end return if changes.empty? changes << "Blame: #{blame}" execute({ title: "Image Updated", description: <<~DESC, ID: `#{md5}` #{changes.join("\n")} DESC color: YELLOW, timestamp: Time.now.iso8601, }) end def blame if CurrentUser.user.present? "<@#{CurrentUser.id}> (`#{CurrentUser.name}`)" elsif Rails.const_defined?("Console") "**Console**" elsif CurrentUser.ip_addr "**#{CurrentUser.ip_addr}**" else "**Unknown**" end end end include WebhookMethods end