straight-server 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,260 @@
1
+ module StraightServer
2
+
3
+ # This module contains common features of Gateway, later to be included
4
+ # in one of the classes below.
5
+ module GatewayModule
6
+
7
+ class InvalidSignature < Exception; end
8
+ class InvalidOrderId < Exception; end
9
+ class CallbackUrlBadResponse < Exception; end
10
+ class WebsocketExists < Exception; end
11
+ class WebsocketForCompletedOrder < Exception; end
12
+
13
+ CALLBACK_URL_ATTEMPT_TIMEFRAME = 3600 # seconds
14
+
15
+ def initialize(*attrs)
16
+
17
+ # When the status of an order changes, we send an http request to the callback_url
18
+ # and also notify a websocket client (if present, of course).
19
+ @order_callbacks = [
20
+ lambda do |order|
21
+ StraightServer::Thread.new do
22
+ send_callback_http_request order
23
+ send_order_to_websocket_client order
24
+ end
25
+ end
26
+ ]
27
+
28
+ @blockchain_adapters = [
29
+ Straight::Blockchain::BlockchainInfoAdapter.mainnet_adapter,
30
+ Straight::Blockchain::HelloblockIoAdapter.mainnet_adapter
31
+ ]
32
+
33
+ @exchange_rate_adapters = []
34
+ @status_check_schedule = Straight::GatewayModule::DEFAULT_STATUS_CHECK_SCHEDULE
35
+ @websockets = {}
36
+
37
+ super
38
+ initialize_exchange_rate_adapters # should always go after super
39
+ end
40
+
41
+ # Creates a new order and saves into the DB. Checks if the MD5 hash
42
+ # is correct first.
43
+ def create_order(attrs={})
44
+ StraightServer.logger.info "Creating new order with attrs: #{attrs}"
45
+ signature = attrs.delete(:signature)
46
+ raise InvalidOrderId if check_signature && (attrs[:id].nil? || attrs[:id].to_i <= 0)
47
+ if !check_signature || sign_with_secret(attrs[:id]) == signature
48
+ order = order_for_keychain_id(
49
+ amount: attrs[:amount],
50
+ keychain_id: increment_last_keychain_id!,
51
+ currency: attrs[:currency],
52
+ btc_denomination: attrs[:btc_denomination]
53
+ )
54
+ order.id = attrs[:id].to_i if attrs[:id]
55
+ order.data = attrs[:data] if attrs[:data]
56
+ order.gateway = self
57
+ order.save
58
+ self.save
59
+ StraightServer.logger.info "Order #{order.id} created: #{order.to_h}"
60
+ order
61
+ else
62
+ StraightServer.logger.warn "Invalid signature, cannot create an order for gateway (#{id})"
63
+ raise InvalidSignature
64
+ end
65
+ end
66
+
67
+ # Used to track the current keychain_id number, which is used by
68
+ # Straight::Gateway to generate addresses from the pubkey. The number is supposed
69
+ # to be incremented by 1. In the case of a Config file type of Gateway, the value
70
+ # is stored in a file in the .straight directory.
71
+ def increment_last_keychain_id!
72
+ self.last_keychain_id += 1
73
+ self.save
74
+ self.last_keychain_id
75
+ end
76
+
77
+ def add_websocket_for_order(ws, order)
78
+ raise WebsocketExists unless @websockets[order.id].nil?
79
+ raise WebsocketForCompletedOrder unless order.status < 2
80
+ StraightServer.logger.info "Opening ws connection for #{order.id}"
81
+ ws.on(:close) do |event|
82
+ @websockets.delete(order.id)
83
+ StraightServer.logger.info "Closing ws connection for #{order.id}"
84
+ end
85
+ @websockets[order.id] = ws
86
+ ws
87
+ end
88
+
89
+ def send_order_to_websocket_client(order)
90
+ if ws = @websockets[order.id]
91
+ ws.send(order.to_json)
92
+ ws.close
93
+ end
94
+ end
95
+
96
+ def initialize_exchange_rate_adapters
97
+ if self.exchange_rate_adapter_names
98
+ self.exchange_rate_adapter_names.each do |adapter|
99
+ begin
100
+ @exchange_rate_adapters << Kernel.const_get("Straight::ExchangeRate::#{adapter}Adapter").new
101
+ rescue NameError
102
+ raise NameError, "No such adapter exists: Straight::ExchangeRate::#{adapter}Adapter"
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def sign_with_secret(content, level: 1)
111
+ result = content.to_s
112
+ level.times do
113
+ h = HMAC::SHA1.new(secret)
114
+ h << result
115
+ result = h.hexdigest
116
+ end
117
+ result
118
+ end
119
+
120
+ # Tries to send a callback HTTP request to the resource specified
121
+ # in the #callback_url. If it fails for any reason, it keeps trying for an hour (3600 seconds)
122
+ # making 10 http requests, each delayed by twice the time the previous one was delayed.
123
+ # This method is supposed to be running in a separate thread.
124
+ def send_callback_http_request(order, delay: 5)
125
+ return if callback_url.nil?
126
+
127
+ StraightServer.logger.info "Attempting to send request to the callback url for order #{order.id} to #{callback_url}..."
128
+
129
+ # Composing the request uri here
130
+ signature = self.check_signature ? "&signature=#{sign_with_secret(order.id, level: 2)}" : ''
131
+ data = order.data ? "&data=#{order.data}" : ''
132
+ uri = URI.parse(callback_url + '?' + order.to_http_params + signature + data)
133
+
134
+ begin
135
+ response = Net::HTTP.get_response(uri)
136
+ order.callback_response = { code: response.code, body: response.body }
137
+ order.save
138
+ raise CallbackUrlBadResponse unless response.code.to_i == 200
139
+ rescue Exception => e
140
+ if delay < CALLBACK_URL_ATTEMPT_TIMEFRAME
141
+ sleep(delay)
142
+ send_callback_http_request(order, delay: delay*2)
143
+ else
144
+ StraightServer.logger.warn "Callback request for order #{order.id} faile, see order's #callback_response field for details"
145
+ end
146
+ end
147
+
148
+ StraightServer.logger.info "Callback request for order #{order.id} performed successfully"
149
+ end
150
+
151
+ end
152
+
153
+ # Uses database to load and save attributes
154
+ class GatewayOnDB < Sequel::Model(:gateways)
155
+
156
+ include Straight::GatewayModule
157
+ include GatewayModule
158
+ plugin :timestamps, create: :created_at, update: :updated_at
159
+ plugin :serialization, :marshal, :exchange_rate_adapter_names
160
+
161
+ end
162
+
163
+ # Uses a config file to load attributes and a special _last_keychain_id file
164
+ # to store last_keychain_id
165
+ class GatewayOnConfig
166
+
167
+ include Straight::GatewayModule
168
+ include GatewayModule
169
+
170
+ # This is the key that allows users (those, who use the gateway,
171
+ # online stores, for instance) to connect and create orders.
172
+ # It is not used directly, but is mixed with all the params being sent
173
+ # and a MD5 hash is calculted. Then the gateway checks whether the
174
+ # MD5 hash is correct.
175
+ attr_accessor :secret
176
+
177
+ # This is used to generate the next address to accept payments
178
+ attr_accessor :last_keychain_id
179
+
180
+ # If set to false, doesn't require an unique id of the order along with
181
+ # the signed md5 hash of that id + secret to be passed into the #create_order method.
182
+ attr_accessor :check_signature
183
+
184
+ # A url to which the gateway will send an HTTP request with the status of the order data
185
+ # (in JSON) when the status of the order is changed. The response should always be 200,
186
+ # otherwise the gateway will awesome something went wrong and will keep trying to send requests
187
+ # to this url according to a specific shedule.
188
+ attr_accessor :callback_url
189
+
190
+ # This will be assigned the number that is the order in which this gateway follows in
191
+ # the config file.
192
+ attr_accessor :id
193
+
194
+ attr_accessor :exchange_rate_adapter_names
195
+
196
+ # Because this is a config based gateway, we only save last_keychain_id
197
+ # and nothing more.
198
+ def save
199
+ File.open(@last_keychain_id_file, 'w') {|f| f.write(last_keychain_id) }
200
+ end
201
+
202
+ # Loads last_keychain_id from a file in the .straight dir.
203
+ # If the file doesn't exist, we create it. Later, whenever an attribute is updated,
204
+ # we save it to the file.
205
+ def load_last_keychain_id!
206
+ @last_keychain_id_file = StraightServer::Initializer::STRAIGHT_CONFIG_PATH +
207
+ "/#{name}_last_keychain_id"
208
+ if File.exists?(@last_keychain_id_file)
209
+ self.last_keychain_id = File.read(@last_keychain_id_file).to_i
210
+ else
211
+ self.last_keychain_id = 0
212
+ save
213
+ end
214
+ end
215
+
216
+ # This will later be used in the #find_by_id. Because we don't use a DB,
217
+ # the id will actually be the index of an element in this Array. Thus,
218
+ # the order in which gateways follow in the config file is important.
219
+ @@gateways = []
220
+
221
+ # Create instances of Gateway by reading attributes from Config
222
+ i = 0
223
+ StraightServer::Config.gateways.each do |name, attrs|
224
+ i += 1
225
+ gateway = self.new
226
+ gateway.pubkey = attrs['pubkey']
227
+ gateway.confirmations_required = attrs['confirmations_required'].to_i
228
+ gateway.order_class = attrs['order_class']
229
+ gateway.secret = attrs['secret']
230
+ gateway.check_signature = attrs['check_signature']
231
+ gateway.callback_url = attrs['callback_url']
232
+ gateway.default_currency = attrs['default_currency']
233
+ gateway.name = name
234
+ gateway.id = i
235
+ gateway.exchange_rate_adapter_names = attrs['exchange_rate_adapters']
236
+ gateway.initialize_exchange_rate_adapters
237
+ gateway.load_last_keychain_id!
238
+ @@gateways << gateway
239
+ end
240
+
241
+
242
+ # This method is a replacement for the Sequel's model one used in DB version of the gateway
243
+ # and it finds gateways using the index of @@gateways Array.
244
+ def self.find_by_id(id)
245
+ @@gateways[id.to_i-1]
246
+ end
247
+
248
+ end
249
+
250
+ # It may not be a perfect way to implement such a thing, but it gives enough flexibility to people
251
+ # so they can simply start using a single gateway on their machines, a gateway which attributes are defined
252
+ # in a config file instead of a DB. That way they don't need special tools to access the DB and create
253
+ # a gateway, but can simply edit the config file.
254
+ Gateway = if StraightServer::Config.gateways_source = 'config'
255
+ GatewayOnConfig
256
+ else
257
+ GatewayOnDB
258
+ end
259
+
260
+ end
@@ -0,0 +1,78 @@
1
+ module StraightServer
2
+
3
+ module Initializer
4
+
5
+ GEM_ROOT = File.expand_path('../..', File.dirname(__FILE__))
6
+ STRAIGHT_CONFIG_PATH = ENV['HOME'] + '/.straight'
7
+
8
+ def prepare
9
+ create_config_file unless File.exist?(STRAIGHT_CONFIG_PATH + '/config.yml')
10
+ read_config_file
11
+ create_logger
12
+ connect_to_db
13
+ run_migrations if migrations_pending?
14
+ end
15
+
16
+ private
17
+
18
+ def create_config_file
19
+ puts "\e[1;33mWARNING!\e[0m \e[33mNo file ~/.straight/config was found. Created a sample one for you.\e[0m"
20
+ puts "You should edit it and try starting the server again.\n"
21
+
22
+ FileUtils.mkdir_p(STRAIGHT_CONFIG_PATH) unless File.exist?(STRAIGHT_CONFIG_PATH)
23
+ FileUtils.cp(GEM_ROOT + '/templates/config.yml', ENV['HOME'] + '/.straight/')
24
+ puts "Shutting down now.\n\n"
25
+ exit
26
+ end
27
+
28
+ def read_config_file
29
+ YAML.load_file(STRAIGHT_CONFIG_PATH + '/config.yml').each do |k,v|
30
+ StraightServer::Config.send(k + '=', v)
31
+ end
32
+ end
33
+
34
+ def connect_to_db
35
+
36
+ # symbolize keys for convenience
37
+ db_config = StraightServer::Config.db.inject({}){|memo,(k,v)| memo[k.to_sym] = v; memo}
38
+
39
+ db_name = if db_config[:adapter] == 'sqlite'
40
+ STRAIGHT_CONFIG_PATH + "/" + db_config[:name]
41
+ else
42
+ db_config[:name]
43
+ end
44
+
45
+ StraightServer.db_connection = Sequel.connect(
46
+ "#{db_config[:adapter]}://" +
47
+ "#{db_config[:user]}#{(":" if db_config[:user])}" +
48
+ "#{db_config[:password]}#{("@" if db_config[:user] || db_config[:password])}" +
49
+ "#{db_config[:host]}#{(":" if db_config[:port])}" +
50
+ "#{db_config[:port]}#{("/" if db_config[:host] || db_config[:port])}" +
51
+ "#{db_name}"
52
+ )
53
+ end
54
+
55
+ def run_migrations
56
+ print "\nPending migrations for the database detected. Migrating..."
57
+ Sequel::Migrator.run(StraightServer.db_connection, GEM_ROOT + '/db/migrations/')
58
+ print "done\n\n"
59
+ end
60
+
61
+ def migrations_pending?
62
+ !Sequel::Migrator.is_current?(StraightServer.db_connection, GEM_ROOT + '/db/migrations/')
63
+ end
64
+
65
+ def create_logger
66
+ require_relative 'logger'
67
+ StraightServer.logger = StraightServer::Logger.new(
68
+ log_level: ::Logger.const_get(Config.logmaster['log_level'].upcase),
69
+ file: STRAIGHT_CONFIG_PATH + '/' + Config.logmaster['file'],
70
+ raise_exception: Config.logmaster['raise_exception'],
71
+ name: Config.logmaster['name'],
72
+ email_config: Config.logmaster['email_config']
73
+ )
74
+ end
75
+
76
+ end
77
+
78
+ end
@@ -0,0 +1,18 @@
1
+ module StraightServer
2
+ class Logger < Logmaster
3
+
4
+ # inserts a number of blank lines
5
+ def blank_lines(n=1)
6
+
7
+ n.times { puts "\n" }
8
+
9
+ File.open(StraightServer::Initializer::STRAIGHT_CONFIG_PATH + '/' + Config.logmaster['file'], 'a') do |f|
10
+ n.times do
11
+ f.puts "\n"
12
+ end
13
+ end if Config.logmaster['file']
14
+
15
+ end
16
+
17
+ end
18
+ end
@@ -0,0 +1,62 @@
1
+ module StraightServer
2
+
3
+ class Order < Sequel::Model
4
+
5
+ include Straight::OrderModule
6
+ plugin :validation_helpers
7
+ plugin :timestamps, create: :created_at, update: :updated_at
8
+
9
+ plugin :serialization
10
+ serialize_attributes :marshal, :callback_response
11
+
12
+ def gateway
13
+ @gateway ||= Gateway.find_by_id(gateway_id)
14
+ end
15
+
16
+ def gateway=(g)
17
+ self.gateway_id = g.id
18
+ @gateway = g
19
+ end
20
+
21
+ def save
22
+ super # calling Sequel::Model save
23
+ @status_changed = false
24
+ end
25
+
26
+ def status_changed?
27
+ @status_changed
28
+ end
29
+
30
+ def to_h
31
+ super.merge({ id: id })
32
+ end
33
+
34
+ def to_json
35
+ to_h.to_json
36
+ end
37
+
38
+ def validate
39
+ super # calling Sequel::Model validator
40
+ errors.add(:amount, "is invalid") if !amount.kind_of?(Numeric) || amount <= 0
41
+ errors.add(:gateway_id, "is invalid") if !gateway_id.kind_of?(Numeric) || gateway_id <= 0
42
+ validates_unique :id, :address, [:keychain_id, :gateway_id]
43
+ validates_presence [:address, :keychain_id, :gateway_id, :amount]
44
+ end
45
+
46
+ def to_http_params
47
+ "order_id=#{id}&amount=#{amount}&status=#{status}&address=#{address}&tid=#{tid}"
48
+ end
49
+
50
+ def start_periodic_status_check
51
+ StraightServer.logger.info "Starting periodic status checks of the order #{self.id}"
52
+ super
53
+ end
54
+
55
+ def check_status_on_schedule(period: 10, iteration_index: 0)
56
+ StraightServer.logger.info "Checking status of order #{self.id}"
57
+ super
58
+ end
59
+
60
+ end
61
+
62
+ end
@@ -0,0 +1,86 @@
1
+ module StraightServer
2
+
3
+ class OrdersController
4
+
5
+ attr_reader :response
6
+
7
+ def initialize(env)
8
+ @env = env
9
+ @params = env.params
10
+ @method = env['REQUEST_METHOD']
11
+ @request_path = env['REQUEST_PATH'].split('/').delete_if { |s| s.nil? || s.empty? }
12
+ dispatch
13
+ end
14
+
15
+ def create
16
+ begin
17
+ order = @gateway.create_order(
18
+ amount: @params['amount'],
19
+ currency: @params['currency'],
20
+ btc_denomination: @params['btc_denomination'],
21
+ id: @params['order_id'],
22
+ signature: @params['signature']
23
+ )
24
+ StraightServer::Thread.new do
25
+ order.start_periodic_status_check
26
+ end
27
+ [200, {}, order.to_json ]
28
+ rescue Sequel::ValidationFailed => e
29
+ StraightServer.logger.warn "validation errors in order, cannot create it."
30
+ [409, {}, "Invalid order: #{e.message}" ]
31
+ rescue StraightServer::GatewayModule::InvalidSignature
32
+ [409, {}, "Invalid signature for id: #{@params['order_id']}" ]
33
+ rescue StraightServer::GatewayModule::InvalidOrderId
34
+ StraightServer.logger.warn message = "An invalid id for order supplied: #{@params['order_id']}"
35
+ [409, {}, message ]
36
+ end
37
+ end
38
+
39
+ def show
40
+ order = Order[@params['id']]
41
+ if order
42
+ order.status(reload: true)
43
+ order.save if order.status_changed?
44
+ [200, {}, order.to_json]
45
+ end
46
+ end
47
+
48
+ def websocket
49
+ order = Order[@params['id']]
50
+ if order
51
+ begin
52
+ @gateway.add_websocket_for_order ws = Faye::WebSocket.new(@env), order
53
+ ws.rack_response
54
+ rescue Gateway::WebsocketExists
55
+ [403, {}, "Someone is already listening to that order"]
56
+ rescue Gateway::WebsocketForCompletedOrder
57
+ [403, {}, "You cannot listen to this order because it is completed (status > 1)"]
58
+ end
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def dispatch
65
+
66
+ StraightServer.logger.blank_lines
67
+ StraightServer.logger.info "#{@method} #{@env['REQUEST_PATH']}\n#{@params}"
68
+
69
+ @gateway = StraightServer::Gateway.find_by_id(@request_path[1])
70
+
71
+ @response = if @request_path[3] # if an order id is supplied
72
+ @params['id'] = @request_path[3].to_i
73
+ if @request_path[4] == 'websocket'
74
+ websocket
75
+ elsif @request_path[4].nil? && @method == 'GET'
76
+ show
77
+ end
78
+ elsif @request_path[3].nil?# && @method == 'POST'
79
+ create
80
+ end
81
+ @response = [404, {}, "#{@method} /#{@request_path.join('/')} Not found"] if @response.nil?
82
+ end
83
+
84
+ end
85
+
86
+ end
@@ -0,0 +1,52 @@
1
+ module StraightServer
2
+ class Server < Goliath::API
3
+
4
+ use Goliath::Rack::Params
5
+ include StraightServer::Initializer
6
+ Faye::WebSocket.load_adapter('goliath')
7
+
8
+ def initialize
9
+ prepare
10
+ StraightServer.logger.info "Starting Straight server v #{StraightServer::VERSION}"
11
+ require_relative 'order'
12
+ require_relative 'gateway'
13
+ require_relative 'orders_controller'
14
+ super
15
+ end
16
+
17
+ def response(env)
18
+ # POST /gateways/1/orders - create order
19
+ # GET /gateways/1/orders/1 - see order info
20
+ # /gateways/1/orders/1/websocket - subscribe to order status changes via a websocket
21
+
22
+ # This will be more complicated in the future. For now it
23
+ # just checks that the path starts with /gateways/:id/orders
24
+
25
+ StraightServer.logger.watch_exceptions do
26
+
27
+ # This is a client implementation example, an html page + a dart script
28
+ # supposed to only be loaded in development.
29
+ if Goliath.env == :development
30
+ if env['REQUEST_PATH'] == '/'
31
+ return [200, {}, IO.read(Initializer::GEM_ROOT + '/examples/client/client.html')]
32
+ elsif Goliath.env == :development && env['REQUEST_PATH'] == '/client.dart'
33
+ return [200, {}, IO.read(Initializer::GEM_ROOT + '/examples/client/client.dart')]
34
+ end
35
+ end
36
+
37
+ if env['REQUEST_PATH'] =~ /\A\/gateways\/.+?\/orders(\/.+)?\Z/
38
+ controller = OrdersController.new(env)
39
+ return controller.response
40
+ else
41
+ return [404, {}, "#{env['REQUEST_METHOD']} #{env['REQUEST_PATH']} Not found"]
42
+ end
43
+
44
+ end
45
+
46
+ # Assume things went wrong, if they didn't go right
47
+ [500, {}, "#{env['REQUEST_METHOD']} #{env['REQUEST_PATH']} Server Error"]
48
+
49
+ end
50
+
51
+ end
52
+ end
@@ -0,0 +1,9 @@
1
+ module StraightServer
2
+
3
+ class Thread
4
+ def self.new(&block)
5
+ ::Thread.new(&block)
6
+ end
7
+ end
8
+
9
+ end
@@ -0,0 +1,25 @@
1
+ require 'yaml'
2
+ require 'json'
3
+ require 'sequel'
4
+ require 'straight'
5
+ require 'logmaster'
6
+ require 'hmac'
7
+ require 'hmac-sha1'
8
+ require 'net/http'
9
+ require 'faye/websocket'
10
+ Sequel.extension :migration
11
+
12
+ require_relative 'straight-server/config'
13
+ require_relative 'straight-server/initializer'
14
+ require_relative 'straight-server/thread'
15
+ require_relative 'straight-server/orders_controller'
16
+
17
+ module StraightServer
18
+
19
+ VERSION = '0.1.0'
20
+
21
+ class << self
22
+ attr_accessor :db_connection, :logger
23
+ end
24
+
25
+ end
@@ -0,0 +1,34 @@
1
+ # If set to db, then use DB table to store gateways,
2
+ # useful when your run many gateways on the same server.
3
+ gateways_source: config
4
+
5
+ gateways:
6
+
7
+ default:
8
+ pubkey: 'xpub-000'
9
+ confirmations_required: 0
10
+ order_class: "StraightServer::Order"
11
+ secret: 'secret'
12
+ check_signature: true
13
+ callback_url: 'http://localhost:3000/payment-callback'
14
+ default_currency: 'BTC'
15
+ exchange_rate_adapters:
16
+ - Bitpay
17
+ - Coinbase
18
+ - Bitstamp
19
+ second_gateway:
20
+ pubkey: 'xpub-001'
21
+ confirmations_required: 0
22
+ order_class: "StraightServer::Order"
23
+ secret: 'secret'
24
+ check_signature: false
25
+ callback_url: 'http://localhost:3001/payment-callback'
26
+ default_currency: 'BTC'
27
+ exchange_rate_adapters:
28
+ - Bitpay
29
+ - Coinbase
30
+ - Bitstamp
31
+
32
+ db:
33
+ adapter: sqlite
34
+ name: straight.db
data/spec/factories.rb ADDED
@@ -0,0 +1,11 @@
1
+ FactoryGirl.define do
2
+
3
+ factory :order, class: StraightServer::Order do
4
+ sequence(:id) { |i| i }
5
+ sequence(:keychain_id) { |i| i }
6
+ sequence(:address) { |i| "address_#{i}" }
7
+ amount 10
8
+ gateway_id 1
9
+ end
10
+
11
+ end