# frozen_string_literal: true module YiffRest class APIV2Controller < APIV2::ApplicationController include ::ApplicationController::ReadonlyMethods before_action :handle_ratelimit, except: %i[robots] before_action :validate_images_access, only: %i[index] before_action :validate_images_bulk_access, only: %i[bulk] before_action -> { track_usage("images") }, only: %i[index categories category image] def index return render_error(YiffyAPIErrorCodes::IMAGES_IMAGE_RESPONSE_DISABLED, error: "Image response has been disabled. Please use the json response.") if params[:category]&.ends_with?("/image") category = params[:category]&.downcase&.gsub("/", ".") || "" category = category[3..] if category.start_with?("v2.") return redirect_to("https://yiff.rest") if category.blank? limit = params[:amount]&.to_i || 1 return render_error(YiffyAPIErrorCodes::IMAGES_AMOUNT_LT_ONE, error: "Amount must be 1 or more.") if limit < 1 return render_error(YiffyAPIErrorCodes::IMAGES_AMOUNT_LT_ONE, error: "Amount must be 5 or less.") if limit > 5 size_limit = params[:sizeLimit].blank? ? nil : Filesize.from(params[:sizeLimit].to_s).to_i return render_error(YiffyAPIErrorCodes::IMAGES_CATEGORY_NOT_FOUND, error: "Category not found.") unless api_categories.include?(category) images = APIImage.random(category, limit, size_limit) return render_error(YiffyAPIErrorCodes::IMAGES_NO_RESULTS, error: "No results were found. Try changing your search parameters.") if images.empty? Cache.redis.multi do |r| r.incr("yiffy2:stats:images:#{category}") r.incr("yiffy2:stats:images:ip:#{request.remote_ip}") r.incr("yiffy2:stats:images:ip:#{request.remote_ip}:#{category}") r.incr("yiffy2:stats:images:total") r.incr("yiffy2:stats:images:total:#{category}") images.each do |img| r.incr("yiffy2:stats:image:#{img.md5}") end if @apikey.present? && !@apikey&.is_anon? r.incr("yiffy2:stats:images:key:#{@apikey.id}") r.incr("yiffy2:stats:images:key:#{@apikey.id}:#{category}") end end execute_webhook(category) render(json: { "$schema": "https://schema.yiff.rest/V2.json", images: images, success: true, notes: notes_for_request, }) end def robots render(plain: <<~ROBOTS) User-agent: * Disallow: / ROBOTS end def stats render(json: { success: true, data: APIKey.stats(ip: request.remote_ip, key: @apikey), }) end def categories render(json: { success: true, data: APIImage.categories, }) end def category category = params[:category]&.downcase&.gsub("/", ".") || "" return render_error(YiffyAPIErrorCodes::IMAGES_CATEGORY_NOT_FOUND, error: "Category not found.") unless api_categories.include?(category) count = APIImage.cached_count(category) render(json: { success: true, data: { **APIImage.categories.find { |c| c.db == category }.to_h, count: count, }, }) end def image image = APIImage.search({ md5: params[:id].gsub("-", "") }).first return render_error(YiffyAPIErrorCodes::IMAGES_NOT_FOUND, error: "Image not found.") unless image render(json: { success: true, data: { **image.as_json, category: image.category, }, }) end def bulk req = params[:api_v2] || {} return render_error(YiffyAPIErrorCodes::BULK_IMAGES_INVALID_BODY, error: "Invalid body, or no categories specified.") if req.blank? size_limit = params[:sizeLimit].blank? ? nil : Filesize.from(params[:sizeLimit].to_s).to_i total = 0 valid = true req.each do |category, amount| total += amount.to_i next if api_categories.include?(category) render_error(YiffyAPIErrorCodes::IMAGES_CATEGORY_NOT_FOUND, error: "Invalid category specified: #{category}") valid = false break end return unless valid return render_error(YiffyAPIErrorCodes::BULK_IMAGES_NUMBER_GT_MAX, error: "Total amount of images requested is greater than #{@apikey.bulk_limit} (#{total}).") if total > @apikey.bulk_limit images = APIImage.bulk(req, size_limit) return render_error(YiffyAPIErrorCodes::IMAGES_NO_RESULTS, error: "No results were found. Try changing your search parameters.") if images.empty? Cache.redis.multi do |r| r.incrby("yiffy2:stats:images:ip:#{request.remote_ip}", total) r.incr("yiffy2:stats:images:ip:#{request.remote_ip}:bulk") r.incrby("yiffy2:stats:images:total", total) r.incr("yiffy2:stats:images:total:bulk") r.incrby("yiffy2:stats:images:key:#{@apikey.id}", total) r.incr("yiffy2:stats:images:key:#{@apikey.id}:bulk") images.each do |img| r.incr("yiffy2:stats:image:#{img.md5}") end req.each do |category, amount| r.incrby("yiffy2:stats:images:#{category}", amount.to_i) r.incr("yiffy2:stats:images:#{category}:bulk") r.incrby("yiffy2:stats:images:ip:#{request.remote_ip}:#{category}", amount.to_i) r.incr("yiffy2:stats:images:ip:#{request.remote_ip}:#{category}:bulk") r.incrby("yiffy2:stats:images:total:#{category}", amount.to_i) r.incr("yiffy2:stats:images:total:#{category}:bulk") r.incrby("yiffy2:stats:images:key:#{@apikey.id}:#{category}", amount.to_i) r.incr("yiffy2:stats:images:key:#{@apikey.id}:#{category}:bulk") end end execute_webhook("bulk", req) render(json: { success: true, data: images, }) end private def api_categories categories = APIImage.categories.map(&:db) [*categories, "chris"] # gay little polar cutie end def category_info [*APIImage.categories, APICategory.new("Gay Polar Cutie", "chris", true)] # gay little polar cutie end def notes_for_request return [] if params[:notes].to_s.downcase == "disabled" notes = [] # notes << { id: 1, content: "This api host (api.furry.bot) is being removed on June 9th, 2021. Please migrate to https://yiff.rest." } if headers["Host"] == "api.furry.bot" notes << { id: 2, content: "We've moved to using subdomains for api versioning! e.g. https://v2.yiff.rest. They have the same functionality, just without the version in the path. The /V2 route will not be removed, but v3 and forward will only use the subdomain." } if request.path.start_with?("/V2") notes << { id: 3, content: "Hey, we see you aren't using an api key. They're free! To get one, visit https://yiff.rest/apikeys." } if headers["Authorization"].blank? # notes << { id: 4, content: "WARNING! This list is STATIC, it can be inaccurate! We recommended parsing the dot notation in https://v2.yiff.rest/categories instead of this!" } # notes << { id: 5, content: "Since images are getting bigger, we're adding a size limit parameter. Add ?sizeLimit= to limit the size of images we provide you." } if params[:sizeLimit].blank? notes << { id: 6, content: "You can now hide these notes by setting the notes parameter to disabled. (ex: ?notes=disabled)" } notes << { id: 7, content: "We now have proper documentation: https://docs.yiff.rest" } notes << { id: 8, content: "We have a new service available, a thumbnailer for e621. You can see its documentation at https://docs.yiff.rest/thumbnails." } end def execute_webhook(category, bulk_categories = nil) return unless Rails.env.production? bulk = "" color = 0x008000 if bulk_categories.present? bulk = <<~BULK Total: **#{bulk_categories.values.sum}** **Categories:** #{bulk_categories.permit!.to_h.map { |c, l| "- **#{c}**: #{l}" }.join("\n")} BULK color = 0xDC143C if bulk_categories.keys.any? { |k| category_info.find { |c| c.db == k }.sfw } elsif !category_info.find { |c| c.db == category }.sfw color = 0xDC143C end Websites.config.yiffyapi_usage_webhook.execute(embeds: [ { title: "V2 API Request", description: <<~DESC.strip, Host: **#{request.host}** Path: **#{request.path}** Category: `#{category}` Auth: #{@apikey.nil? || @apikey.is_anon? ? '**No**' : "**Yes** (##{@apikey.id})"} Size Limit: **#{params[:sizeLimit] || 'None'}** User Agent: **#{request.user_agent}** IP: **#{request.remote_ip}** #{bulk} DESC color: color, timestamp: Time.now.iso8601, }, ]) end def allowed_readonly_actions super + %w[bulk] end end end