straight-server 0.2.3 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +2 -2
  3. data/Gemfile +21 -16
  4. data/Gemfile.lock +44 -30
  5. data/Gemfile.travis +15 -16
  6. data/README.md +66 -47
  7. data/VERSION +1 -1
  8. data/db/migrations/011_add_callback_data_to_orders.rb +1 -1
  9. data/db/migrations/012_add_address_provider.rb +11 -0
  10. data/db/migrations/013_add_address_derivation_scheme.rb +11 -0
  11. data/db/migrations/014_pubkey_null_address_provider_not_null.rb +8 -0
  12. data/db/migrations/015_add_amount_paid_to_orders.rb +11 -0
  13. data/db/migrations/016_add_new_params_to_orders.rb +13 -0
  14. data/db/migrations/017_add_test_mode_to_gateways.rb +11 -0
  15. data/db/migrations/018_add_test_keychain_id_to_gateways.rb +11 -0
  16. data/db/migrations/019_add_test_pubkey_to_gateways.rb +11 -0
  17. data/db/migrations/020_add_test_mode_to_orders.rb +11 -0
  18. data/db/schema.rb +11 -1
  19. data/lib/straight-server.rb +11 -9
  20. data/lib/straight-server/config.rb +28 -18
  21. data/lib/straight-server/gateway.rb +167 -87
  22. data/lib/straight-server/initializer.rb +13 -7
  23. data/lib/straight-server/order.rb +39 -17
  24. data/lib/straight-server/orders_controller.rb +71 -21
  25. data/lib/straight-server/random_string.rb +3 -13
  26. data/lib/straight-server/server.rb +3 -4
  27. data/lib/straight-server/signature_validator.rb +69 -0
  28. data/lib/straight-server/thread.rb +19 -4
  29. data/lib/straight-server/throttler.rb +7 -13
  30. data/lib/tasks/db.rake +1 -1
  31. data/spec/.straight/config.yml +8 -3
  32. data/spec/.straight/default_test_last_keychain_id +1 -0
  33. data/spec/factories.rb +2 -1
  34. data/spec/lib/gateway_spec.rb +222 -94
  35. data/spec/lib/initializer_spec.rb +1 -1
  36. data/spec/lib/order_spec.rb +26 -7
  37. data/spec/lib/orders_controller_spec.rb +65 -6
  38. data/spec/lib/signature_validator_spec.rb +72 -0
  39. data/spec/lib/thread_spec.rb +16 -0
  40. data/spec/lib/throttle_spec.rb +2 -2
  41. data/spec/spec_helper.rb +17 -22
  42. data/straight-server.gemspec +31 -12
  43. data/templates/config.yml +19 -10
  44. metadata +52 -11
@@ -41,12 +41,12 @@ module StraightServer
41
41
  create_logger
42
42
  connect_to_db
43
43
  run_migrations if migrations_pending?
44
- setup_redis_connection if StraightServer::Config.count_orders
44
+ setup_redis_connection
45
45
  initialize_routes
46
46
  end
47
47
 
48
48
  def add_route(path, &block)
49
- @routes[path] = block
49
+ @routes[path] = block
50
50
  end
51
51
 
52
52
  def create_config_files
@@ -116,6 +116,7 @@ module StraightServer
116
116
  end
117
117
 
118
118
  def create_logger
119
+ return unless Config.logmaster
119
120
  require_relative 'logger'
120
121
  StraightServer.logger = StraightServer::Logger.new(
121
122
  log_level: ::Logger.const_get(Config.logmaster['log_level'].upcase),
@@ -128,7 +129,11 @@ module StraightServer
128
129
 
129
130
  def initialize_routes
130
131
  @routes = {}
131
- add_route /\A\/gateways\/.+?\/orders(\/.+)?\Z/ do |env|
132
+ add_route %r{\A/gateways/.+?/orders(/.+)?\Z} do |env|
133
+ controller = OrdersController.new(env)
134
+ controller.response
135
+ end
136
+ add_route %r{\A/gateways/.+?/last_keychain_id\Z} do |env|
132
137
  controller = OrdersController.new(env)
133
138
  controller.response
134
139
  end
@@ -168,6 +173,7 @@ module StraightServer
168
173
  # an unclean shutdown of the server. Let's check and update the status manually once.
169
174
  if order.time_left_before_expiration < 1
170
175
  StraightServer.logger.info "Order #{order.id} seems to be expired, but status remains #{order.status}. Will check for status update manually."
176
+ order.gateway.test_mode = true if order.test_mode
171
177
  order.status(reload: true)
172
178
 
173
179
  # if we still see no transactions to that address,
@@ -187,17 +193,17 @@ module StraightServer
187
193
  end
188
194
 
189
195
  # Loads redis gem and sets up key prefixes for order counters
190
- # for the current straight environment.
196
+ # for the current straight environment.
191
197
  def setup_redis_connection
192
- require 'redis'
198
+ raise "Redis not configured" unless Config.redis
193
199
  Config.redis = Config.redis.keys_to_sym
194
- Config.redis[:connection] = Redis.new(
200
+ Config.redis[:prefix] ||= "StraightServer:#{Config.environment}"
201
+ StraightServer.redis_connection = Redis.new(
195
202
  host: Config.redis[:host],
196
203
  port: Config.redis[:port],
197
204
  db: Config.redis[:db],
198
205
  password: Config.redis[:password]
199
206
  )
200
- Config.redis[:prefix] ||= "StraightServer:#{Config.environment}"
201
207
  end
202
208
 
203
209
  end
@@ -1,5 +1,5 @@
1
1
  module StraightServer
2
-
2
+
3
3
  class Order < Sequel::Model
4
4
 
5
5
  include Straight::OrderModule
@@ -10,12 +10,12 @@ module StraightServer
10
10
 
11
11
  # Additional data that can be passed and stored with each order. Not returned with the callback.
12
12
  serialize_attributes :marshal, :data
13
-
13
+
14
14
  # data that was provided by the merchan upon order creation and is sent back with the callback
15
- serialize_attributes :marshal, :callback_data
15
+ serialize_attributes :marshal, :callback_data
16
16
 
17
17
  # stores the response of the server to which the callback is issued
18
- serialize_attributes :marshal, :callback_response
18
+ serialize_attributes :marshal, :callback_response
19
19
 
20
20
  plugin :after_initialize
21
21
  def after_initialize
@@ -25,7 +25,7 @@ module StraightServer
25
25
  def gateway
26
26
  @gateway ||= Gateway.find_by_id(gateway_id)
27
27
  end
28
-
28
+
29
29
  def gateway=(g)
30
30
  self.gateway_id = g.id
31
31
  @gateway = g
@@ -60,13 +60,30 @@ module StraightServer
60
60
  self[:status] = @status
61
61
  end
62
62
 
63
+ def cancelable?
64
+ status == Straight::Order::STATUSES.fetch(:new)
65
+ end
66
+
67
+ def cancel
68
+ self.status = Straight::Order::STATUSES.fetch(:canceled)
69
+ save
70
+ StraightServer::Thread.interrupt(label: payment_id)
71
+ end
72
+
63
73
  def save
64
74
  super # calling Sequel::Model save
65
75
  @status_changed = false
66
76
  end
67
77
 
68
78
  def to_h
69
- super.merge({ id: id, payment_id: payment_id, amount_in_btc: amount_in_btc(as: :string), keychain_id: keychain_id, last_keychain_id: self.gateway.last_keychain_id })
79
+ super.merge({
80
+ id: id,
81
+ payment_id: payment_id,
82
+ amount_in_btc: amount_in_btc(as: :string),
83
+ amount_paid_in_btc: amount_in_btc(field: amount_paid,as: :string),
84
+ keychain_id: keychain_id,
85
+ last_keychain_id: (self.gateway.test_mode ? self.gateway.test_last_keychain_id : self.gateway.last_keychain_id)
86
+ })
70
87
  end
71
88
 
72
89
  def to_json
@@ -75,17 +92,18 @@ module StraightServer
75
92
 
76
93
  def validate
77
94
  super # calling Sequel::Model validator
78
- errors.add(:amount, "is not numeric") if !amount.kind_of?(Numeric)
79
- errors.add(:amount, "should be more than 0") if amount && amount <= 0
80
- errors.add(:gateway_id, "is invalid") if !gateway_id.kind_of?(Numeric) || gateway_id <= 0
81
- errors.add(:description, "should be shorter than 255 charachters") if description.kind_of?(String) && description.length > 255
82
- errors.add(:gateway, "is inactive, cannot create order for inactive gateway") unless gateway.active
83
- validates_unique :id
95
+ errors.add(:amount, "is not numeric") if !amount.kind_of?(Numeric)
96
+ errors.add(:amount, "should be more than 0") if amount && amount <= 0
97
+ errors.add(:amount_paid, "is not numeric") if !amount.kind_of?(Numeric)
98
+ errors.add(:gateway_id, "is invalid") if !gateway_id.kind_of?(Numeric) || gateway_id <= 0
99
+ errors.add(:description, "should be shorter than 256 characters") if description.kind_of?(String) && description.length > 255
100
+ errors.add(:gateway, "is inactive, cannot create order for inactive gateway") if !gateway.active && self.new?
101
+ validates_unique :id
84
102
  validates_presence [:address, :keychain_id, :gateway_id, :amount]
85
103
  end
86
104
 
87
105
  def to_http_params
88
- "order_id=#{id}&amount=#{amount}&amount_in_btc=#{amount_in_btc(as: :string)}&status=#{status}&address=#{address}&tid=#{tid}&keychain_id=#{keychain_id}&last_keychain_id=#{@gateway.last_keychain_id}"
106
+ "order_id=#{id}&amount=#{amount}&amount_in_btc=#{amount_in_btc(as: :string)}&amount_paid_in_btc=#{amount_in_btc(field: amount_paid, as: :string)}&status=#{status}&address=#{address}&tid=#{tid}&keychain_id=#{keychain_id}&last_keychain_id=#{@gateway.last_keychain_id}"
89
107
  end
90
108
 
91
109
  def before_create
@@ -96,13 +114,13 @@ module StraightServer
96
114
  self.data = {} unless self.data
97
115
  self.data[:exchange_rate] = { price: gateway.current_exchange_rate, currency: gateway.default_currency }
98
116
  end
99
-
117
+
100
118
  super
101
119
  end
102
120
 
103
121
  # Update Gateway's order_counters, incrementing the :new counter.
104
122
  # All other increments/decrements happen in the the Gateway#order_status_changed callback,
105
- # but the initial :new increment needs this code because the Gateway#order_status_changed
123
+ # but the initial :new increment needs this code because the Gateway#order_status_changed
106
124
  # isn't called in this case.
107
125
  def after_create
108
126
  self.gateway.increment_order_counter!(:new) if StraightServer::Config.count_orders
@@ -112,15 +130,19 @@ module StraightServer
112
130
  # Order#created_at into account now, so that we don't start checking on
113
131
  # an order that is already expired. Or, if it's not expired yet,
114
132
  # we make sure to stop all checks as soon as it expires, but not later.
115
- def start_periodic_status_check(duration: nil)
116
- StraightServer.logger.info "Starting periodic status checks of order #{self.id} (expires in #{duration} seconds)"
133
+ def start_periodic_status_check
117
134
  if (t = time_left_before_expiration) > 0
135
+ StraightServer.logger.info "Starting periodic status checks of order #{id} (expires in #{t} seconds)"
118
136
  check_status_on_schedule(duration: t)
119
137
  end
120
138
  self.save if self.status_changed?
121
139
  end
122
140
 
123
141
  def check_status_on_schedule(period: 10, iteration_index: 0, duration: 600, time_passed: 0)
142
+ if StraightServer::Thread.interrupted?(thread: ::Thread.current)
143
+ StraightServer.logger.info "Checking status of order #{self.id} interrupted"
144
+ return
145
+ end
124
146
  StraightServer.logger.info "Checking status of order #{self.id}"
125
147
  super
126
148
  end
@@ -1,8 +1,10 @@
1
- require_relative './throttler'
1
+ require_relative 'throttler'
2
+ require_relative 'signature_validator'
2
3
 
3
4
  module StraightServer
4
5
 
5
6
  class OrdersController
7
+ include Goliath::Constants
6
8
 
7
9
  attr_reader :response
8
10
 
@@ -21,7 +23,9 @@ module StraightServer
21
23
  return [404, {}, "Gateway not found" ]
22
24
  end
23
25
 
24
- unless @gateway.check_signature
26
+ if @gateway.check_signature
27
+ StraightServer::SignatureValidator.new(@gateway, @env).validate!
28
+ else
25
29
  ip = @env['HTTP_X_FORWARDED_FOR'].to_s
26
30
  ip = @env['REMOTE_ADDR'] if ip.empty?
27
31
  if StraightServer::Throttler.new(@gateway.id).deny?(ip)
@@ -43,20 +47,20 @@ module StraightServer
43
47
  currency: @params['currency'],
44
48
  btc_denomination: @params['btc_denomination'],
45
49
  keychain_id: @params['keychain_id'],
46
- signature: @params['signature'],
47
50
  callback_data: @params['callback_data'],
48
- data: @params['data']
51
+ data: @params['data'],
52
+ description: @params['description']
49
53
  }
54
+
50
55
  order = @gateway.create_order(order_data)
51
- StraightServer::Thread.new do
56
+ StraightServer::Thread.new(label: order.payment_id) do
52
57
  # Because this is a new thread, we have to wrap the code inside in #watch_exceptions
53
58
  # once again. Otherwise, no watching is done. Oh, threads!
54
59
  StraightServer.logger.watch_exceptions do
55
60
  order.start_periodic_status_check
56
61
  end
57
62
  end
58
- order = add_callback_data_warning(order)
59
- [200, {}, order.to_json ]
63
+ [200, {}, add_callback_data_warning(order).to_json]
60
64
  rescue Sequel::ValidationFailed => e
61
65
  StraightServer.logger.warn(
62
66
  "VALIDATION ERRORS in order, cannot create it:\n" +
@@ -64,11 +68,8 @@ module StraightServer
64
68
  "Order data: #{order_data.inspect}\n"
65
69
  )
66
70
  [409, {}, "Invalid order: #{e.message}" ]
67
- rescue StraightServer::GatewayModule::InvalidSignature
68
- [409, {}, "Invalid signature for id: #{@params['order_id']}" ]
69
- rescue StraightServer::GatewayModule::InvalidOrderId
70
- StraightServer.logger.warn message = "An invalid id for order supplied: #{@params['order_id']}"
71
- [409, {}, message ]
71
+ rescue Straight::Gateway::OrderAmountInvalid => e
72
+ [409, {}, "Invalid order: #{e.message}" ]
72
73
  rescue StraightServer::GatewayModule::GatewayInactive
73
74
  StraightServer.logger.warn message = "The gateway is inactive, you cannot create order with it"
74
75
  [503, {}, message ]
@@ -82,6 +83,10 @@ module StraightServer
82
83
  return [404, {}, "Gateway not found" ]
83
84
  end
84
85
 
86
+ if @gateway.check_signature
87
+ StraightServer::SignatureValidator.new(@gateway, @env).validate!
88
+ end
89
+
85
90
  order = find_order
86
91
 
87
92
  if order
@@ -106,8 +111,40 @@ module StraightServer
106
111
  end
107
112
  end
108
113
 
114
+ def cancel
115
+ unless @gateway
116
+ StraightServer.logger.warn "Gateway not found"
117
+ return [404, {}, "Gateway not found"]
118
+ end
119
+
120
+ if @gateway.check_signature
121
+ StraightServer::SignatureValidator.new(@gateway, @env).validate!
122
+ end
123
+
124
+ if (order = find_order)
125
+ order.status(reload: true)
126
+ order.save if order.status_changed?
127
+ if order.cancelable?
128
+ order.cancel
129
+ [200, {}, '']
130
+ else
131
+ [409, {}, "Order is not cancelable"]
132
+ end
133
+ end
134
+ end
135
+
136
+ def last_keychain_id
137
+ unless @gateway
138
+ StraightServer.logger.warn "Gateway not foun"
139
+ return [404, {}, "Gateway not found"]
140
+ end
141
+
142
+ [200, {}, {gateway_id: @gateway.id, last_keychain_id: @gateway.last_keychain_id}.to_json]
143
+ end
144
+
109
145
  private
110
146
 
147
+ # Refactoring proposed: https://github.com/AlexanderPavlenko/straight-server/commit/49ea6e3732a9564c04d8dfecaee6d0ebaa462042
111
148
  def dispatch
112
149
 
113
150
  StraightServer.logger.blank_lines
@@ -115,17 +152,30 @@ module StraightServer
115
152
 
116
153
  @gateway = StraightServer::Gateway.find_by_hashed_id(@request_path[1])
117
154
 
118
- @response = if @request_path[3] # if an order id is supplied
119
- @params['id'] = @request_path[3]
120
- @params['id'] = @params['id'].to_i if @params['id'] =~ /\A\d+\Z/
121
- if @request_path[4] == 'websocket'
122
- websocket
123
- elsif @request_path[4].nil? && @method == 'GET'
124
- show
155
+ @response = begin
156
+ if @request_path[3] # if an order id is supplied
157
+ @params['id'] = @request_path[3]
158
+ @params['id'] = @params['id'].to_i if @params['id'] =~ /\A\d+\Z/
159
+ if @request_path[4] == 'websocket'
160
+ websocket
161
+ elsif @request_path[4] == 'cancel'&& @method == 'POST'
162
+ cancel
163
+ elsif @request_path[4].nil? && @method == 'GET'
164
+ show
165
+ end
166
+ elsif @request_path[2] == 'last_keychain_id'
167
+ last_keychain_id
168
+ elsif @request_path[3].nil?# && @method == 'POST'
169
+ create
125
170
  end
126
- elsif @request_path[3].nil?# && @method == 'POST'
127
- create
171
+ rescue StraightServer::SignatureValidator::InvalidNonce
172
+ StraightServer.logger.warn message = "X-Nonce is invalid: #{@env["#{HTTP_PREFIX}X_NONCE"].inspect}"
173
+ [409, {}, message]
174
+ rescue StraightServer::SignatureValidator::InvalidSignature
175
+ StraightServer.logger.warn message = "X-Signature is invalid: #{@env["#{HTTP_PREFIX}X_SIGNATURE"].inspect}"
176
+ [409, {}, message]
128
177
  end
178
+
129
179
  @response = [404, {}, "#{@method} /#{@request_path.join('/')} Not found"] if @response.nil?
130
180
  end
131
181
 
@@ -1,18 +1,8 @@
1
+ require 'securerandom'
2
+
1
3
  class String
2
4
 
3
5
  def self.random(len)
4
- s = ""
5
- while s.length != len do
6
- s = rand(36**len).to_s(36)
7
- end
8
- s
9
- end
10
-
11
- def repeat(times)
12
- result = ""
13
- times.times { result << self }
14
- result
6
+ BTC::Base58.base58_from_data(SecureRandom.random_bytes(len))[0, len]
15
7
  end
16
-
17
8
  end
18
-
@@ -11,7 +11,6 @@ module StraightServer
11
11
  StraightServer.logger.info "starting Straight Server v #{StraightServer::VERSION}"
12
12
  require_relative 'order'
13
13
  require_relative 'gateway'
14
- require_relative 'orders_controller'
15
14
  load_addons
16
15
  resume_tracking_active_orders!
17
16
  end
@@ -20,7 +19,7 @@ module StraightServer
20
19
  # Even though we define that option here, it is purely for the purposes of compliance with
21
20
  # Goliath server. If don't do that, there will be an exception saying "unrecognized argument".
22
21
  # In reality, we make use of --config-dir value in the in StraightServer::Initializer and stored
23
- # it in StraightServer::Initializer.config_dir property.
22
+ # it in StraightServer::Initializer.config_dir property.
24
23
  opts.on('-c', '--config-dir STRING', "Directory where config files and addons are placed") do |val|
25
24
  options[:config_dir] = File.expand_path(val || ENV['HOME'] + '/.straight' )
26
25
  end
@@ -42,7 +41,7 @@ module StraightServer
42
41
  # AFTER the process is daemonized, this shall remain as it is now.
43
42
  begin
44
43
  return process_request(env)
45
- rescue Sequel::DatabaseDisconnectError
44
+ rescue Sequel::DatabaseDisconnectError
46
45
  connect_to_db
47
46
  return process_request(env)
48
47
  end
@@ -74,6 +73,6 @@ module StraightServer
74
73
  # no block was called, means no route matched. Let's render 404
75
74
  return [404, {}, "#{env['REQUEST_METHOD']} #{env['REQUEST_PATH']} Not found"]
76
75
  end
77
-
76
+
78
77
  end
79
78
  end
@@ -0,0 +1,69 @@
1
+ require 'goliath/constants'
2
+ require 'base64'
3
+
4
+ module StraightServer
5
+ class SignatureValidator
6
+ include Goliath::Constants
7
+
8
+ SignatureValidatorError = Class.new(StandardError)
9
+ InvalidNonce = Class.new(SignatureValidatorError)
10
+ InvalidSignature = Class.new(SignatureValidatorError)
11
+
12
+ attr_reader :gateway, :env
13
+
14
+ def initialize(gateway, env)
15
+ @gateway = gateway
16
+ @env = env
17
+ end
18
+
19
+ def validate!
20
+ raise InvalidNonce unless valid_nonce?
21
+ raise InvalidSignature unless valid_signature?
22
+ true
23
+ end
24
+
25
+ def valid_nonce?
26
+ nonce = env["#{HTTP_PREFIX}X_NONCE"].to_i
27
+ redis = StraightServer.redis_connection
28
+ loop do
29
+ redis.watch last_nonce_key do
30
+ last_nonce = redis.get(last_nonce_key).to_i
31
+ if last_nonce < nonce
32
+ result = redis.multi do |multi|
33
+ multi.set last_nonce_key, nonce
34
+ end
35
+ return true if result[0] == 'OK'
36
+ else
37
+ redis.unwatch
38
+ return false
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ def valid_signature?
45
+ signature == env["#{HTTP_PREFIX}X_SIGNATURE"]
46
+ end
47
+
48
+ def last_nonce_key
49
+ "#{Config[:'redis.prefix']}:LastNonce:#{gateway.id}"
50
+ end
51
+
52
+ def signature
53
+ self.class.signature(
54
+ nonce: env["#{HTTP_PREFIX}X_NONCE"],
55
+ body: env[RACK_INPUT].kind_of?(StringIO) ? env[RACK_INPUT].string : env[RACK_INPUT].to_s,
56
+ method: env[REQUEST_METHOD],
57
+ request_uri: env[REQUEST_URI],
58
+ secret: gateway.secret,
59
+ )
60
+ end
61
+
62
+ # Should mirror StraightServerKit.signature
63
+ def self.signature(nonce:, body:, method:, request_uri:, secret:)
64
+ sha512 = OpenSSL::Digest::SHA512.new
65
+ request = "#{method.to_s.upcase}#{request_uri}#{sha512.digest("#{nonce}#{body}")}"
66
+ Base64.strict_encode64 OpenSSL::HMAC.digest(sha512, secret.to_s, request)
67
+ end
68
+ end
69
+ end