# frozen_string_literal: true class APIKey < ApplicationRecord WINDOW_LONG_ANON = 10_000 LIMIT_LONG_ANON = 7 WINDOW_SHORT_ANON = 2000 LIMIT_SHORT_ANON = 2 MAX_PER_USER = 3 belongs_to :owner, class_name: "APIUser" validate :validate_user_not_limited, unless: :skip_validations, on: :create validates :application_name, length: { minimum: 3, maximum: 50 }, unless: :skip_validations, uniqueness: { scope: %i[owner_id] } validates :usage, length: { maximum: 150 }, unless: :skip_validations validates :limit_short, numericality: { greater_than_or_equal_to: 0 }, unless: :skip_validations validates :window_short, numericality: { greater_than_or_equal_to: 0 }, unless: :skip_validations validates :limit_long, numericality: { greater_than_or_equal_to: 0 }, unless: :skip_validations validates :window_long, numericality: { greater_than_or_equal_to: 0 }, unless: :skip_validations before_create do self.key = SecureRandom.hex(20) if key.blank? self.flags = Flags.default if flags.blank? end attr_accessor :no_webhook_messages, :skip_validations after_create :send_created, unless: :no_webhook_messages after_update :send_updated, unless: :no_webhook_messages after_destroy :send_deleted, unless: :no_webhook_messages before_save do self.disabled_reason = "None Provided" if disabled? && disabled_reason.blank? self.usage = "None provided" if usage.blank? self.application_name = "None provided" if application_name.blank? end module Flags module_function IMAGES = 1 << 0 THUMBS = 1 << 1 SHORTENER = 1 << 2 IMAGES_BULK = 1 << 3 def default IMAGES | THUMBS | SHORTENER end def anon IMAGES end end module AccessMethods def any active? && !disabled? end def images_access? any && (flags & Flags::IMAGES == Flags::IMAGES) end def thumbs_access? any && (flags & Flags::THUMBS == Flags::THUMBS) end def shortener_access? any && (flags & Flags::SHORTENER == Flags::SHORTENER) end def images_bulk_access? any && (flags & Flags::IMAGES_BULK == Flags::IMAGES_BULK) end def access_string services = [] services << "Images" if images_access? services << "Thumbs" if thumbs_access? services << "Shortener" if shortener_access? services << "Bulk Images (#{bulk_limit})" if images_bulk_access? return "None" if services.empty? services.join(", ") end def can_view?(user) return true if user.id == owner_id user.is_manager? end def can_edit?(user) return true if user.is_admin? return false if disabled user.id == owner_id end def can_delete?(user) return true if user.id == owner_id user.is_admin? end def can_disable?(user) user.is_manager? end def can_deactivate?(user) return false if disabled user.id == owner_id end def can_regenerate?(user) return true if user.is_admin? user.id == owner_id end end module SearchMethods def from_request(request, with_anon: true) return anon if request.headers["Authorization"].blank? && with_anon find_by(key: request.headers["Authorization"]) end def for_owner(id) where(owner_id: id) end def search(params) q = super q = q.attribute_matches(:owner_id, params[:owner_id]) q = q.attribute_matches(:application_name, params[:application_name]) q = q.attribute_matches(:usage, params[:usage]) q = q.attribute_matches(:active, params[:active]) q = q.attribute_matches(:disabled, params[:disabled]) q = q.attribute_matches(:disabled_reason, params[:disabled_reason]) q.order(id: :asc) end end include AccessMethods extend SearchMethods def is_anon? self == APIKey.anon end def encoded # key doesn't actually need to be private, or really even have meaning here - just needs to be consistent # using sha1 because sha256 is 64 characters, pushing the custom id to 101 or 102 characters OpenSSL::HMAC.hexdigest("sha1", Websites.config.yiffyapi_public_key, key) end def self.anon @anon ||= new( flags: Flags.anon, limit_long: LIMIT_LONG_ANON, limit_short: LIMIT_SHORT_ANON, window_long: WINDOW_LONG_ANON, window_short: WINDOW_SHORT_ANON, application_name: "Anonymous", ) end def flags_images images_access? end def flags_thumbs thumbs_access? end def flags_shortener shortener_access? end def flags_images_bulk images_bulk_access? end def set_flag(flag, value) if value.truthy? update(flags: flags | flag) else update(flags: flags & ~flag) end end def flags_images=(value) set_flag(Flags::IMAGES, value) end def flags_thumbs=(value) set_flag(Flags::THUMBS, value) end def flags_shortener=(value) set_flag(Flags::SHORTENER, value) end def flags_images_bulk=(value) set_flag(Flags::IMAGES_BULK, value) end def css_class classes = [] classes << "apikey-super" if super? classes << "apikey-disabled" if disabled? classes << "apikey-inactive" unless active? classes.join(" ") end def regenerate! update(key: SecureRandom.hex(20)) end def validate_user_not_limited return if CurrentUser.is_admin? unless owner.can_create_apikey? errors.add(:owner, "cannot create apikeys.") throw(:abort) end end module WebhookMethods GREEN = 0x008000 YELLOW = 0xFFA500 RED = 0xFF0000 GREEN_TICK = "<:GreenTick:1235058920762904576>" RED_TICK = "<:RedTick:1235058898549870724>" def execute(content) Websites.config.yiffyapi_apikey_logs_webhook.execute({ embeds: [content], }) end def send_created execute({ title: "API Key ##{id} Created", description: <<~DESC, Key: `#{key}` Application: `#{application_name}` Usage: `#{usage}` Active: #{active? ? GREEN_TICK : RED_TICK} Disabled: #{disabled? ? "#{GREEN_TICK} (Reason: #{disabled_reason || 'None Provided'})" : RED_TICK} Unlimited: #{unlimited? ? GREEN_TICK : RED_TICK} Super: #{super? ? GREEN_TICK : RED_TICK} Services: #{access_string} Blame: #{blame} DESC color: GREEN, timestamp: Time.now.iso8601, }) end def send_deleted execute({ title: "API Key ##{id} Deleted", description: <<~DESC, Key: `#{key}` Application: `#{application_name}` Usage: `#{usage}` Active: #{active? ? GREEN_TICK : RED_TICK} Disabled: #{disabled? ? "#{GREEN_TICK} (Reason: #{disabled_reason || 'None Provided'})" : RED_TICK} Unlimited: #{unlimited? ? GREEN_TICK : RED_TICK} Super: #{super? ? GREEN_TICK : RED_TICK} Services: #{access_string} Blame: #{blame} 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 = [] changes << "Disabled (**#{disabled_reason}**)" if disabled? && saved_change_to_disabled? changes << "Enabled" if !disabled? && saved_change_to_disabled? changes << "Deactivated" if !active? && saved_change_to_active? changes << "Activated" if active? && saved_change_to_active? changes << "Super Removed" if !super? && saved_change_to_super? changes << "Super Added" if super? && saved_change_to_super? if saved_change_to_flags? ref = APIKey.new(flags: flags_before_last_save, bulk_limit: bulk_limit_before_last_save) changes << "Old Services: #{ref.access_string}" changes << "New Services: #{access_string}" end check_change(:application_name, changes) check_change(:usage, changes) check_change(:limit_short, changes) check_change(:window_short, changes) check_change(:limit_long, changes) check_change(:window_long, changes) check_change(:key, changes) check_change(:unlimited, changes) check_change(:bulk_limit, changes) return if changes.empty? changes << "Blame: #{blame}" execute({ title: "API Key ##{id} Updated", description: changes.join("\n"), 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 def self.stats(ip: nil, key: nil) categories = APIImage.categories.pluck(:db) keys = [ "yiffy2:stats:images:total", *categories.map { |c| "yiffy2:stats:images:total:#{c}" }, ] ipstats = ip.present? if ipstats keys.push( "yiffy2:stats:images:ip:#{ip}", *categories.map { |c| "yiffy2:stats:images:ip:#{ip}:#{c}" }, ) end keystats = key.present? && !key.is_anon? if keystats keys.push( "yiffy2:stats:images:key:#{key.id}", *categories.map { |c| "yiffy2:stats:images:key:#{key.id}:#{c}" }, ) end values = Cache.redis.mget(*keys).map(&:to_i) total = values.shift total_specific = values.shift(categories.length) ip_total = values.shift if ipstats ip_specific = values.shift(categories.length) if ipstats key_total = values.shift if keystats key_specific = values.shift(categories.length) if keystats fmt = ->(arr) { categories.each_with_index .map { |c, i| hasherizer(c.split("."), arr[i]) } .reduce({}) { |a, e| a.deep_merge(e) } } { global: { total: total, **fmt.call(total_specific), }, ip: if ipstats { total: ip_total, **fmt.call(ip_specific), } end, key: if keystats { total: key_total, **fmt.call(key_specific), } end, } end def self.hasherizer(arr, value) if arr.empty? value else {}.tap do |hash| hash[arr.shift] = hasherizer(arr, value) end end end end