Created
August 6, 2012 08:06
-
-
Save gravis/3272178 to your computer and use it in GitHub Desktop.
A secure event tracking system for online betting in France
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Copyright © 2010-2011 Tech-Angels. All Rights Reserved. | |
# CollectorTransaction will be created each time the Collector needs | |
# to trace an activity. | |
# | |
# Attributes: | |
# * id [integer, primary, not null] - primary key | |
# * before_tr [binary] - associated model serialized before transaction | |
# * created_at [datetime] - creation time | |
# * model_id [integer] - belongs_to Model (polymorphic) | |
# * model_type [text] - belongs_to Model (polymorphic) | |
# * response_code [integer] - HTTP response code from the vault | |
# * status [text, not null] - state machine status (see below) | |
# * trace_type [text, not null] - type of trace that was sent to the vault | |
# (ie PAHIMISE) | |
# * updated_at [datetime] - last update time | |
# * xml_trace [xml] - full XML that was sent to the vault | |
# | |
class CollectorTransaction < ActiveRecord::Base | |
MAX_ATTEMPTS = 5 | |
acts_as_archive :indexes => [ [:model_id, :model_type], :response_code, :trace_type ], :quick => true | |
belongs_to :model, :polymorphic => true, :autosave => false | |
after_initialize :after_initialize_method | |
attr_accessible :model_id, :model_type, :trace_type | |
# +attempt+ is used to retry failed transactions | |
attr_accessor_with_default :attempt, 1 | |
attr_accessor :prev_model | |
# Temporary variables | |
# block : block code to execute during transaction | |
# request : The request used to vault | |
attr_accessor :block, :on_complete, :dont_enqueue, :extra_params, :request | |
attr_readonly :before_tr, :trace_type | |
validates :trace_type, :inclusion => {:in => | |
%(OUVINFOPERSO PREFCPTE OKCONDGENE OUVOKCONFIRME ACCESREFUSE | |
MODIFINFOPERSO AUTOINTERDICTION CLOTUREDEM | |
CPTEALIM CPTEABOND CPTERETRAIT CPTEALIMOPE | |
LOTNATURE | |
PAHIMISE PAHIGAIN PAHIANNUL)} | |
before_create :serialize_model | |
scope :stuck, lambda {{:conditions => ["created_at < ? and status = 'pending'", 2.minutes.ago]}} | |
state_machine :status, :initial => :pending do | |
event :send_to_vault do | |
transition :pending => :sent | |
end | |
event :cancel do | |
transition :pending => :cancelled | |
end | |
event :rollback do | |
transition :pending => :rollbacked | |
end | |
before_transition :on => :rollback, :do => :restore_model | |
after_transition :on => :rollback, :do => :cancel_dependent_transactions | |
before_transition :on => :send_to_vault, :do => :save_model_id | |
after_transition :on => :send_to_vault, :do => :fire_model_callbacks | |
state :sent do | |
validates :response_code, :inclusion => {:in => [200]} | |
end | |
state :rollbacked do | |
validates :response_code, :exclusion => {:in => [200]} # 0 = could not connect, 409 = no IDE header returned by vault | |
end | |
end | |
def max_attempts_tripped? | |
self.attempt >= MAX_ATTEMPTS | |
end | |
protected | |
# Save a serialized blob based on +model+ attributes. | |
# Only if the blob is present yet | |
# (prevent overriding original values when changing state) | |
# | |
def serialize_model | |
self.before_tr = model.class.find(model.id).attributes_before_type_cast.to_msgpack if (before_tr.blank? && model && !model.new_record?) | |
end | |
# Restore model attributes from the serialized blob (before_tr) | |
# | |
def restore_model | |
self.prev_model = self.model | |
if before_tr.blank? | |
# new record was created, destroy it | |
self.model.destroy if self.model | |
self.model = nil | |
else | |
self.transaction do | |
model.reload | |
MessagePack.unpack(before_tr).each do |attribute_name, value| | |
model.send(attribute_name.to_s + '=', value) | |
end | |
model.save | |
end | |
end | |
true | |
end | |
def after_initialize_method | |
self.on_complete ||= [] | |
end | |
def cancel_dependent_transactions | |
@stack = [] | |
dfs(self.on_complete) | |
@stack.each(&:cancel) | |
true | |
end | |
private | |
def fire_model_callbacks | |
if model && model.class.try(:after_vault_blocks) | |
blocks = model.class.after_vault_blocks | |
blocks += model.class.after_vault_blocks(before_tr.blank? ? :create : :update) | |
blocks.each do |blk| | |
blk.call(model) | |
end | |
end | |
end | |
def save_model_id | |
self.model_id = self.model.id if self.model | |
end | |
def dfs(ctransactions) | |
ctransactions.each do |ct| | |
@stack << ct | |
dfs(ct.on_complete) | |
end | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Copyright © 2010 Tech-Angels. All Rights Reserved. | |
require 'md5' | |
module Bettogo | |
module Collector | |
# French ARJEL (Autorité de Régulation des Jeux En Ligne) wants some | |
# data to be collected before they get processed by the | |
# application. | |
# The collector is in charge of collecting data, prepare xml documents, | |
# and send them to a safe, locked, place. This module | |
# *must* be in place in France to run the application. | |
class Handler | |
attr_reader :queue | |
# Create a new collector. This should be called once per http request, when a transaction is needed. | |
# | |
def initialize(rails_request) | |
@queue = [] | |
@rails_request = rails_request # save request params | |
@hydra = Typhoeus::Hydra.new(:max_concurrency => 20) # Hydra allows to run concurrent requests | |
end | |
# Enqueue a new transaction in the Collector queue | |
# | |
# Options: | |
# * +trace_type+ : A valid trace type (:OUVINFOPERSO, :PAHIMISE, etc.) | |
# * +options+ : A Hash of options, including: | |
# ** +:model+ : An existing model the transaction if refering to (used for rollback). | |
# ** +:after+ : Don't enqueue the request for parallel processing. The current transaction | |
# will be executed if the +:after: transaction is a success. | |
# ** +:extra_params+ : A Hash of extra parameters that suits the needs of some traces | |
# * +&block+ : The block to be executed when the collector will try to vault the whole transaction. | |
# If block fails, the transaction is aborted. | |
# | |
# Example: | |
# | |
# collector = Bettogo::Collector::Handler.new(request) | |
# collector.enqueue("OUVINFOPERSO", current_user.account) do | |
# account = current_user.account.update_attributes!(params[:account]) | |
# end | |
# | |
# _Remember_ : set the model in the transaction before changing the model (if model exists) | |
# | |
def enqueue(trace_type, options={}, &block) | |
ct = CollectorTransaction.new do |ctransaction| | |
ctransaction.trace_type = trace_type.to_s | |
ctransaction.model = options[:model] if options[:model] | |
ctransaction.extra_params = options[:extra_params].nil? ? {} : options[:extra_params] | |
end | |
ct.block = block | |
@queue << ct | |
if options[:after] | |
options[:after].on_complete << ct | |
ct.dont_enqueue = true | |
end | |
ct | |
end | |
# Fire the vaulting of all queued translations. | |
# As the bang (!) let think, the vault! action should | |
# be surrounded with a begin/rescue block. | |
# | |
# *Note:* All enqueued transactions are fired in a single | |
# DB transaction. Therefore, if one transaction fails, the whole | |
# queue is rollbacked. This is the intended behaviour, failures | |
# mean something has behaved in an unexpected way. | |
# On the other hand, each failure in the trace vaulting will | |
# be treated independently, and the corresponding transaction | |
# rollbacked. | |
# | |
# Example: | |
# | |
# collector = Bettogo::Collector::Handler.new(request) | |
# collector.enqueue("OKCONDGENE") do | |
# ... | |
# end | |
# begin | |
# collector.vault! | |
# rescue Exception | |
# puts "FAILURE" | |
# else | |
# puts "SUCCESS" | |
# end | |
# | |
def vault! | |
raise "Empty queue" if @queue.empty? | |
# Run all transactions at once, for performance purpose | |
ActiveRecord::Base.transaction do | |
@queue.each do |ctransaction| | |
ctransaction.save | |
ctransaction.block.call(ctransaction) | |
ctransaction.xml_trace = "::Bettogo::Collector::#{ctransaction.trace_type}Builder".constantize.new(@rails_request, ctransaction).build.to_xml(:indent => 0) | |
ctransaction.request = create_request(ctransaction) | |
ctransaction.request.on_complete do |response| | |
if response.success? and response.headers['IDE'] | |
ctransaction.response_code = response.code | |
ctransaction.send_to_vault | |
ctransaction.on_complete.each do |dependent_transaction| | |
@hydra.queue dependent_transaction.request | |
end | |
Rails.logger.info "Vaulting successfull for transaction ##{ctransaction.id} (#{ctransaction.trace_type})" | |
@queue.delete ctransaction if ctransaction.sent? # transactions left in the queue are failures | |
else | |
if ctransaction.max_attempts_tripped? # we have tried enough, it's time to give up | |
ctransaction.response_code = (response.code == 200 and !response.headers['IDE']) ? 409 : response.code # response can be success and thus don't have the required header(s). | |
Rails.logger.info "Vaulting failed for transaction ##{ctransaction.id} (#{ctransaction.trace_type}) with code=#{ctransaction.response_code}" | |
ctransaction.rollback # Rollback the transaction, and cancel dependent ones (cf. CollectorTransaction model) | |
else # let's retry! | |
ctransaction.attempt += 1 # one more try | |
Rails.logger.info "Retry to vault ##{ctransaction.id} (attempt=#{ctransaction.attempt})" | |
@hydra.queue ctransaction.request | |
end | |
end | |
end | |
@hydra.queue ctransaction.request unless ctransaction.dont_enqueue | |
end | |
end | |
Rails.logger.info "Sending traces to vault." | |
@hydra.run | |
Rails.logger.info "Vault performed." | |
@queue # return failures (left in the queue) | |
end | |
protected | |
# Construct and return a vault request based on a collector transaction | |
# | |
# Options: | |
# * +ctransaction+ : a CollectorTransaction | |
# | |
def create_request(ctransaction) | |
Typhoeus::Request.new( | |
VAULT_CONFIG['endpoint'], # VAULT_CONFIG is initialized in config/initializers/bettogo.rb | |
:method => :post, # Make sure the request is a POST | |
:user_agent => 'BettogoCollector', | |
:headers => { | |
'IDC' => "BettogoCollector-#{Rails.env}-#{Bettogo::Version}", # Collector ID | |
'Content-Type' => 'application/x-www-form-urlencoded;charset=UTF-8', | |
'OPE' => VAULT_CONFIG['idoper'] | |
}.reverse_merge(VAULT_CONFIG['headers']), | |
:timeout => VAULT_CONFIG['timeout'], | |
:params => { | |
:TRN => ctransaction.xml_trace, | |
:DTE => ctransaction.created_at.to_s(:number), | |
:IDE => ctransaction.id.to_s, | |
:EMP => MD5.new(ActiveSupport::Base64.encode64(ctransaction.xml_trace)).to_s, | |
:UTI => VAULT_CONFIG['login'], | |
:MDP => VAULT_CONFIG['password'] }, | |
# SSL config | |
:ssl_cacert => VAULT_CONFIG['ssl_cacert'], | |
:ssl_cert => VAULT_CONFIG['ssl_cert'], | |
:ssl_cert_type => VAULT_CONFIG['ssl_cert_type'], | |
:ssl_key => VAULT_CONFIG['ssl_key'], | |
:ssl_key_password => VAULT_CONFIG['ssl_key_password'], | |
:ssl_key_type => VAULT_CONFIG['ssl_key_type']) | |
end | |
end | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# POST /checkout | |
# POST /checkout.xml | |
def create | |
some_successful = false # At least one bet was successfuly created (and vaulted) | |
@order = current_user.orders.new | |
bet_types = Hash.new { |h,bet_type_name| h[bet_type_name] = BetType.find_by_name(bet_type_name)} | |
if current_user.can_bet? | |
begin | |
collector = Bettogo::Collector::Handler.new(request) | |
number_of_bets = 0 | |
@order.save! | |
params[:bets].each do |bet_params| | |
combination, stake, bet_type_name = bet_params.split(',') | |
bet_type = bet_types[bet_type_name] | |
ensure_bet_type_is_enabled(bet_type) | |
bet = Bet.new(:combination => combination, :bet_type => bet_type, :stake => stake.to_i, :order_id => @order.id, :race_id => @race.id) | |
collector.enqueue(:PAHIMISE, | |
:model => bet, | |
:extra_params => { | |
:race_name => [@race.to_s, @race.name, @race.place.name].join(' - '), | |
:balance_before_checkout => current_user.balance, | |
:balance_after_checkout => current_user.balance - bet.stake}) do | |
bet.save! | |
end | |
number_of_bets += 1 | |
end | |
failures = collector.vault! | |
rescue ActiveRecord::RecordInvalid => e | |
notify_hoptoad e | |
log_exception e | |
Rails.logger.error @order.errors.full_messages unless @order.valid? | |
if defined?(bet) | |
Rails.logger.error bet.errors.full_messages | |
if !bet.errors[:bet_type].empty? | |
flash[:alert] = t(:bet_type_max_stake_tripped, :scope => "activerecord.errors.models.bet.attributes.bet_type") | |
end | |
end | |
@order.destroy | |
rescue Exception => error | |
log_exception error | |
notify_hoptoad error | |
@order.destroy | |
else | |
some_successful = true unless number_of_bets == failures.size | |
Resque.enqueue(ExpireFragmentCache, "pending_bets/#{current_user.id}") | |
Resque.enqueue(UpdateAccountCanBet, 'account', current_user.account.id) | |
end | |
else | |
flash[:alert] = t(current_user.cant_bet_reason, :scope => 'account.cant_bet_reason') | |
redirect_to race_path(@race) | |
return | |
end | |
respond_to do |format| | |
format.html do | |
if some_successful | |
if current_user.prefers?(:new_order_notification) | |
Resque.enqueue(NotifyOrderConfirmation, @order.id) | |
end | |
if !failures.empty? | |
# Only some bets were placed correctly, let user retry bad ones, | |
# ond display successful part of order | |
@failed_bets = failures.map(&:prev_model) | |
flash.now[:alert] = t(:some_of_your_bets_were_not_placed, :scope => 'orders.flashes') | |
render :action => 'retry' | |
else | |
# Order successfuly placed, display it | |
flash[:notice] = t(:your_bets_have_been_placed, :scope => 'orders.flashes') | |
session[:current_user_last_order_id] = @order.id | |
redirect_to race_path(@race) | |
end | |
else | |
# No bet was placed. Display info and redirect user back to cart if passible | |
flash[:alert] ||= t(:vaulting_error, :scope => 'commons.flashes') | |
redirect_to(params[:after_login_user_url] || race_path(@race)) | |
end | |
end | |
format.xml do | |
if some_successful | |
render :xml => @order, :status => :created, :location => @order | |
else | |
render :xml => @order.errors, :status => :unprocessable_entity | |
end | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment