impress/app/models/pet.rb
Emi Dunn-Rankin 568a3645de
Use secret NEOPETS_URL_ORIGIN to bypass HTTPS
Oops, neopets.com finally stopped accepting `http://` connections, so our AMFPHP requests stopped working! And our current dependencies make it hard to make modern HTTPS requests :(

Instead, we're doing this quick-fix: we have a connection who knows the internal address for the Neopets origin server behind their CDN, which *does* still accept `http://` requests!

So, when `NEOPETS_URL_ORIGIN` is specified in the secret `.env` file (not committed to the repository), we'll use it instead of `http://www.neopets.com`. However, we still have that in the code as a fallback, just to be a bit less surprising to some theoretical future dev so they can see the real error message, and to self-document a bit of what that value is semantically doing! (The documentation angle is more of why it's there, rather than an actual expectation that any actual person in the future will run the code and get the fallback.)
2022-08-02 20:46:47 -07:00

184 lines
5.5 KiB
Ruby

require 'rocketamf/remote_gateway'
require 'ostruct'
class Pet < ActiveRecord::Base
NEOPETS_URL_ORIGIN = ENV['NEOPETS_URL_ORIGIN'] || 'http://www.neopets.com'
GATEWAY_URL = NEOPETS_URL_ORIGIN + '/amfphp/gateway.php'
PET_VIEWER = RocketAMF::RemoteGateway.new(GATEWAY_URL).
service('CustomPetService').action('getViewerData')
PET_NOT_FOUND_REMOTE_ERROR = 'PHP: Unable to retrieve records from the database.'
WARDROBE_PATH = '/wardrobe'
belongs_to :pet_type
attr_reader :items, :pet_state
attr_accessor :contributor
scope :with_pet_type_color_ids, lambda { |color_ids|
joins(:pet_type).where(PetType.arel_table[:id].in(color_ids))
}
def load!(options={})
options[:locale] ||= I18n.default_locale
I18n.with_locale(options.delete(:locale)) do
use_viewer_data(fetch_viewer_data(options.delete(:timeout)), options)
end
true
end
def use_viewer_data(viewer_data, options={})
options[:item_scope] ||= Item.scoped
pet_data = viewer_data[:custom_pet]
self.pet_type = PetType.find_or_initialize_by_species_id_and_color_id(
pet_data[:species_id].to_i,
pet_data[:color_id].to_i
)
self.pet_type.body_id = pet_data[:body_id]
self.pet_type.origin_pet = self
biology = pet_data[:biology_by_zone]
biology[0] = nil # remove effects if present
@pet_state = self.pet_type.add_pet_state_from_biology! biology
@pet_state.label_by_pet(self, pet_data[:owner])
@items = Item.collection_from_pet_type_and_registries(self.pet_type,
viewer_data[:object_info_registry], viewer_data[:object_asset_registry],
options[:item_scope])
end
def fetch_viewer_data(timeout=4, locale=nil)
locale ||= I18n.default_locale
begin
neopets_language_code = I18n.compatible_neopets_language_code_for(locale)
envelope = PET_VIEWER.request([name, 0]).post(
:timeout => timeout,
:headers => {
'Cookie' => "lang=#{neopets_language_code}"
}
)
rescue RocketAMF::RemoteGateway::AMFError => e
if e.message == PET_NOT_FOUND_REMOTE_ERROR
raise PetNotFound, "Pet #{name.inspect} does not exist"
end
raise DownloadError, e.message
rescue RocketAMF::RemoteGateway::ConnectionError => e
raise DownloadError, e.message, e.backtrace
end
HashWithIndifferentAccess.new(envelope.messages[0].data.body)
end
def wardrobe_query
{
:name => self.name,
:color => self.pet_type.color.id,
:species => self.pet_type.species.id,
:state => self.pet_state.id,
:objects => self.items.map(&:id)
}.to_query
end
def contributables
contributables = [pet_type, @pet_state]
items.each do |item|
contributables << item
contributables += item.pending_swf_assets
end
contributables
end
def item_translation_candidates
{}.tap do |candidates|
if @items
@items.each do |item|
item.needed_translations.each do |locale|
candidates[locale] ||= []
candidates[locale] << item
end
end
end
end
end
def translate_items
candidates = self.item_translation_candidates
until candidates.empty?
# Organize known items by ID
items_by_id = {}
@items.each { |i| items_by_id[i.id] = i }
# Fetch registry data in parallel
registries = Parallel.map(candidates.keys, :in_threads => 8) do |locale|
viewer_data = fetch_viewer_data(4, locale) # TODO: ew, don't copy-paste the default timeout
[locale, viewer_data[:object_info_registry]]
end
# Look up any newly applied items on this pet, just in case
new_item_ids = []
registries.each do |locale, registry|
registry.each do |item_id, item_info|
item_id = item_id.to_i
new_item_ids << item_id unless items_by_id.has_key?(item_id)
end
end
Item.includes(:translations).find(new_item_ids).each do |item|
items_by_id[item.id] = item
end
# Apply translations, and figure out what items are currently being worn
current_items = Set.new
registries.each do |locale, registry|
registry.each do |item_id, item_info|
item = items_by_id[item_id.to_i]
item.add_origin_registry_info(item_info, locale)
current_items << item
end
end
@items = current_items
Item.transaction { @items.each { |i| i.save! if i.changed? } }
previous_candidates = candidates
candidates = item_translation_candidates
if previous_candidates == candidates
# This condition should never happen if Neopets responds with correct
# data, but, if Neopets somehow responds with incorrect data, this
# condition could throw us into an infinite loop if uncaught. Better
# safe than sorry when working with external services.
raise "No change when reloading #{name} for #{candidates}"
end
end
end
before_validation do
pet_type.save!
if @pet_state
@pet_state.save!
@pet_state.handle_assets!
end
if @items
@items.each do |item|
item.save! if item.changed?
item.handle_assets!
end
end
end
def self.load(name, options={})
pet = Pet.find_or_initialize_by_name(name)
pet.load!(options)
pet
end
def self.from_viewer_data(viewer_data, options={})
pet = Pet.find_or_initialize_by_name(viewer_data[:custom_pet][:name])
pet.use_viewer_data(viewer_data, options)
pet
end
class PetNotFound < Exception;end
class DownloadError < Exception;end
end