Websites/app/models/api_key.rb
2024-05-02 22:04:43 -05:00

394 lines
10 KiB
Ruby

# 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