394 lines
10 KiB
Ruby
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
|