straight-server 0.2.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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