straight-server 0.1.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.
@@ -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