rest-gw2 0.1.0 → 0.2.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.
@@ -1,8 +1,14 @@
1
1
 
2
- require 'dalli'
2
+ require 'rest-gw2/server/cache'
3
+ require 'mime/types'
4
+ require 'rest-core'
3
5
  require 'jellyfish'
6
+ require 'rack'
4
7
 
8
+ require 'timeout'
9
+ require 'openssl'
5
10
  require 'erb'
11
+ require 'cgi'
6
12
 
7
13
  module RestGW2
8
14
  CONFIG = ENV['RESTGW2_CONFIG'] || File.expand_path("#{__dir__}/../../.env")
@@ -19,55 +25,345 @@ module RestGW2
19
25
  ENV[k] ||= v
20
26
  end
21
27
 
22
- module DalliExtension
23
- def [] *args
24
- get(*args)
25
- end
26
-
27
- def []= *args
28
- set(*args)
29
- end
30
-
31
- def store *args
32
- set(*args)
33
- end
34
- end
35
-
36
- def self.cache
37
- @cache ||= begin
38
- client = Dalli::Client.new
39
- client.extend(DalliExtension)
40
- client
41
- end
42
- end
43
-
44
28
  class ServerCore
45
29
  include Jellyfish
30
+ SECRET = ENV['RESTGW2_SECRET'] || 'RESTGW2_SECRET'*2
31
+ COINS = %w[gold silver copper].zip(%w[
32
+ https://wiki.guildwars2.com/images/d/d1/Gold_coin.png
33
+ https://wiki.guildwars2.com/images/3/3c/Silver_coin.png
34
+ https://wiki.guildwars2.com/images/e/eb/Copper_coin.png
35
+ ]).freeze
46
36
  controller_include Module.new{
37
+ # VIEW
47
38
  def render path
48
- ERB.new(views(path)).result(binding)
39
+ erb(:layout){ erb(path) }
40
+ end
41
+
42
+ def erb path, &block
43
+ ERB.new(views(path)).result(binding, &block)
44
+ end
45
+
46
+ def h str
47
+ CGI.escape_html(str) if str.kind_of?(String)
48
+ end
49
+
50
+ def u str
51
+ CGI.escape(str) if str.kind_of?(String)
52
+ end
53
+
54
+ def path str, q={}
55
+ RC::Middleware.request_uri(
56
+ RC::REQUEST_PATH => "#{ENV['RESTGW2_PREFIX']}#{str}",
57
+ RC::REQUEST_QUERY => q)
49
58
  end
50
59
 
51
60
  def views path
52
61
  @views ||= {}
53
- @views[path] ||= File.read("#{__dir__}/view/#{path}.erb")
62
+ @views[path] = File.read("#{__dir__}/view/#{path}.erb")
63
+ end
64
+
65
+ def refresh_path
66
+ path(request.path, :p => p, :r => '1', :t => t)
67
+ end
68
+
69
+ # TODO: clean me up
70
+ def menu item, title, query={}
71
+ href = path(item, query.merge(:t => t))
72
+ if path(request.path, :p => p, :t => t) == href
73
+ title
74
+ else
75
+ %Q{<a href="#{href}">#{title}</a>}
76
+ end
77
+ end
78
+
79
+ # TODO: clean me up
80
+ def menu_trans item, title
81
+ key = "/transactions#{item}"
82
+ if path(request.path) == path(key)
83
+ menu(key, title, :p => p)
84
+ else
85
+ menu(key, title)
86
+ end
87
+ end
88
+
89
+ def page num
90
+ menu(request.path, num.to_s, :p => zero_is_nil(num))
91
+ end
92
+
93
+ # HELPER
94
+ def blank_icon
95
+ %Q{<img class="icon" src="https://upload.wikimedia.org/wikipedia/commons/d/d2/Blank.png"/>}
96
+ end
97
+
98
+ def item_wiki item
99
+ if item['name']
100
+ page = item['name'].tr(' ', '_')
101
+ missing = if item['count'] == 0 then ' missing' else nil end
102
+ img = %Q{<img class="icon#{missing}" title="#{item_title(item)}"} +
103
+ %Q{ src="#{h item['icon']}"/>}
104
+ %Q{<a href="http://wiki.guildwars2.com/wiki/#{u page}">#{img}</a>}
105
+ else
106
+ blank_icon
107
+ end
54
108
  end
55
109
 
56
110
  def item_title item
57
- t = item['description']
58
- t && t.unpack('U*').map{ |c| "&##{c};" }.join
111
+ d = item['description']
112
+ d && d.unpack('U*').map{ |c| "&##{c};" }.join
113
+ end
114
+
115
+ def item_count item
116
+ c = item['count']
117
+ "(#{c})" if c > 1
118
+ end
119
+
120
+ def item_price item
121
+ b = item['buys']
122
+ s = item['sells']
123
+ bb = b && price(b['unit_price'])
124
+ ss = s && price(s['unit_price'])
125
+ %Q{#{bb} / #{ss}} if bb || ss
126
+ end
127
+
128
+ def price copper
129
+ g = copper / 100_00
130
+ s = copper % 100_00 / 100
131
+ c = copper % 100
132
+ l = [g, s, c]
133
+ n = l.index(&:nonzero?)
134
+ return '-' unless n
135
+ l.zip(COINS).drop(n).map do |(num, (title, src))|
136
+ %Q{#{num}<img class="price" title="#{h title}" src="#{h src}"/>}
137
+ end.join(' ')
138
+ end
139
+
140
+ def abbr_time_ago time, precision=1
141
+ return unless time
142
+ ago = time_ago(time)
143
+ short = ago.take(precision).join(' ')
144
+ %Q{(<abbr title="#{time}, #{ago.join(' ')} ago">#{short} ago</abbr>)}
145
+ end
146
+
147
+ def time_ago time, precision=1
148
+ delta = (Time.now - Time.parse(time)).to_i
149
+ result = []
150
+
151
+ [[ 60, :seconds],
152
+ [ 60, :minutes],
153
+ [ 24, :hours ],
154
+ [365, :days ],
155
+ [999, :years ]].
156
+ inject(delta) do |length, (divisor, name)|
157
+ quotient, remainder = length.divmod(divisor)
158
+ result.unshift("#{remainder} #{name}")
159
+ break if quotient == 0
160
+ quotient
161
+ end
162
+
163
+ result
164
+ end
165
+
166
+ def sum_trans trans
167
+ trans.inject(0) do |sum, t|
168
+ sum + t['price'] * t['quantity']
169
+ end
170
+ end
171
+
172
+ def sum_items items
173
+ items.inject([0, 0]) do |sum, i|
174
+ next sum unless i
175
+ b = i['buys']
176
+ s = i['sells']
177
+ sum[0] += b['unit_price'] * i['count'] if b
178
+ sum[1] += s['unit_price'] * i['count'] if s
179
+ sum
180
+ end
181
+ end
182
+
183
+ # CONTROLLER
184
+ def gw2_call msg, *args
185
+ refresh = !!request.GET['r']
186
+ opts = {'cache.update' => refresh, 'expires_in' => 600}
187
+ yield(gw2.public_send(msg, *args, opts).itself)
188
+ rescue RestGW2::Error => e
189
+ @error = e.error['text']
190
+ render :error
191
+ end
192
+
193
+ def trans_call msg, path, &block
194
+ gw2_call(msg, path, :page => p) do |trans|
195
+ @pages = calculate_pages("v2/commerce/transactions/#{path}")
196
+ @trans = trans
197
+ @total = sum_trans(trans)
198
+ render :transactions
199
+ end
200
+ end
201
+
202
+ def calculate_pages path
203
+ link = gw2.get(path, {:page_size => 200},
204
+ RC::RESPONSE_KEY => RC::RESPONSE_HEADERS)['LINK']
205
+ pages = RC::ParseLink.parse_link(link)
206
+ parse_page(pages['first']['uri'])..parse_page(pages['last']['uri'])
207
+ end
208
+
209
+ def parse_page uri
210
+ RC::ParseQuery.parse_query(URI.parse(uri).query)['page'].to_i
59
211
  end
60
212
 
61
213
  def gw2
62
- Client.new(:access_token => ENV['ACCESS_TOKEN'],
63
- :log_method => env['rack.errors'].method(:puts),
64
- :cache => RestGW2.cache)
214
+ Client.new(:access_token => access_token,
215
+ :log_method => logger(env).method(:info),
216
+ :cache => RestGW2::Cache.default(logger(env)))
217
+ end
218
+
219
+ # ACCESS TOKEN
220
+ def access_token
221
+ t && decrypt(t) || ENV['RESTGW2_ACCESS_TOKEN']
222
+ rescue ArgumentError, OpenSSL::Cipher::CipherError => e
223
+ raise RestGW2::Error.new({'text' => e.message}, 0)
224
+ end
225
+
226
+ def t
227
+ @t ||= begin
228
+ r = request.GET['t']
229
+ r if r && !r.strip.empty?
230
+ end
231
+ end
232
+
233
+ def p
234
+ @p ||= zero_is_nil(request.GET['p'])
235
+ end
236
+
237
+ def zero_is_nil n
238
+ r = n.to_i
239
+ r if r != 0
240
+ end
241
+
242
+ # UTILITIES
243
+ def encrypt data
244
+ cipher = OpenSSL::Cipher.new('aes-128-gcm')
245
+ cipher.encrypt
246
+ cipher.key = SECRET
247
+ iv = cipher.random_iv
248
+ encrypted = cipher.update(data) + cipher.final
249
+ tag = cipher.auth_tag
250
+ encode_base64(iv, encrypted, tag)
251
+ end
252
+
253
+ def decrypt data
254
+ iv, encrypted, tag = decode_base64(data)
255
+ decipher = OpenSSL::Cipher.new('aes-128-gcm')
256
+ decipher.decrypt
257
+ decipher.key = SECRET
258
+ decipher.iv = iv
259
+ decipher.auth_tag = tag
260
+ decipher.update(encrypted) + decipher.final
261
+ end
262
+
263
+ def encode_base64 *data
264
+ data.map{ |d| [d].pack('m0') }.join('.').tr('+/=', '-_~')
265
+ end
266
+
267
+ def decode_base64 str
268
+ str.split('.').map{ |d| d.tr('-_~', '+/=').unpack('m0').first }
269
+ end
270
+
271
+ # MISC
272
+ def logger env
273
+ env['rack.logger'] || begin
274
+ require 'logger'
275
+ Logger.new(env['rack.errors'])
276
+ end
65
277
  end
66
278
  }
67
279
 
280
+ handle Timeout::Error do
281
+ @error = 'Timeout. Please try again.'
282
+ render :error
283
+ end
284
+
285
+ post '/access_token' do
286
+ t = encrypt(request.POST['access_token'])
287
+ r = request.POST['referrer']
288
+ u = if r == path('/') then path('/account') else r end
289
+ found "#{u}?t=#{t}"
290
+ end
291
+
292
+ get '/' do
293
+ render :index
294
+ end
295
+
296
+ get '/account' do
297
+ gw2_call(:account_with_detail) do |account|
298
+ @info = account
299
+ render :info
300
+ end
301
+ end
302
+
303
+ get '/characters' do
304
+ render :wip
305
+ end
306
+
307
+ get '/dyes' do
308
+ render :wip
309
+ end
310
+
311
+ get '/skins' do
312
+ render :wip
313
+ end
314
+
315
+ get '/minis' do
316
+ render :wip
317
+ end
318
+
319
+ get '/achievements' do
320
+ render :wip
321
+ end
322
+
68
323
  get '/bank' do
69
- @items = gw2.with_item_detail('account/bank')
70
- render 'bank'
324
+ gw2_call(:with_item_detail, 'v2/account/bank') do |items|
325
+ @items = items
326
+ @buy, @sell = sum_items(items)
327
+ render :items
328
+ end
329
+ end
330
+
331
+ get '/materials' do
332
+ gw2_call(:with_item_detail, 'v2/account/materials') do |items|
333
+ @items = items
334
+ @buy, @sell = sum_items(items)
335
+ render :items
336
+ end
337
+ end
338
+
339
+ get '/wallet' do
340
+ gw2_call(:wallet_with_detail) do |wallet|
341
+ @wallet = wallet
342
+ render :wallet
343
+ end
344
+ end
345
+
346
+ get '/transactions/buying' do
347
+ trans_call(:transactions_with_detail, 'current/buys')
348
+ end
349
+
350
+ get '/transactions/selling' do
351
+ trans_call(:transactions_with_detail, 'current/sells')
352
+ end
353
+
354
+ get '/transactions/bought' do
355
+ trans_call(:transactions_with_detail_compact, 'history/buys')
356
+ end
357
+
358
+ get '/transactions/sold' do
359
+ trans_call(:transactions_with_detail_compact, 'history/sells')
360
+ end
361
+
362
+ get '/tokeninfo' do
363
+ gw2_call(:get, 'v2/tokeninfo') do |info|
364
+ @info = info
365
+ render :info
366
+ end
71
367
  end
72
368
  end
73
369
 
@@ -0,0 +1,38 @@
1
+
2
+ module RestGW2
3
+ module Cache
4
+ module_function
5
+ def default logger
6
+ @cache ||= Cache.pick(logger)
7
+ end
8
+
9
+ def pick logger
10
+ memcache(logger) || lru_cache(logger)
11
+ end
12
+
13
+ def memcache logger
14
+ require 'dalli'
15
+ client = Dalli::Client.new
16
+ File.open(IO::NULL) do |null|
17
+ Dalli.logger = Logger.new(null)
18
+ client.alive!
19
+ Dalli.logger = logger
20
+ end
21
+ logger.info("Memcached connected to #{client.version.keys.join(', ')}")
22
+ client.extend(RestCore::DalliExtension)
23
+ client
24
+ rescue LoadError, Dalli::RingError => e
25
+ logger.debug("Skip memcached because: #{e}")
26
+ nil
27
+ end
28
+
29
+ def lru_cache logger
30
+ require 'lru_redux'
31
+ logger.info("LRU cache size: 100")
32
+ LruRedux::ThreadSafeCache.new(100)
33
+ rescue LoadError => e
34
+ logger.debug("Skip LRU cache because: #{e}")
35
+ nil
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,108 @@
1
+
2
+ require 'rest-gw2'
3
+
4
+ module RestGW2::Runner
5
+ module_function
6
+ def options
7
+ @options ||=
8
+ [['Options:' , '' ],
9
+ ['-o, --host HOST' , 'Use HOST (default: 0.0.0.0)' ],
10
+ ['-p, --port PORT' , 'Use PORT (default: 8080)' ],
11
+ ['-s, --server SERVER', 'Use SERVER (default: webrick)'],
12
+ ['-c, --configru' , 'Print where the config.ru is' ],
13
+ ['-h, --help' , 'Print this message' ],
14
+ ['-v, --version' , 'Print the version' ]]
15
+ end
16
+
17
+ def root
18
+ File.expand_path("#{__dir__}/../../../")
19
+ end
20
+
21
+ def config_ru_path
22
+ "#{root}/config.ru"
23
+ end
24
+
25
+ def run argv=ARGV
26
+ unused, host, port, server = parse(argv)
27
+ warn("Unused arguments: #{unused.inspect}") unless unused.empty?
28
+ load_rack
29
+ load_rack_handlers
30
+ handler = server && Rack::Handler.get(server) || Rack::Handler.default
31
+ handler.run(Rack::Builder.new{
32
+ eval(File.read(RestGW2::Runner.config_ru_path))
33
+ }.to_app, :Host => host, :Port => port, :config => root)
34
+ end
35
+
36
+ def parse argv
37
+ unused, host, port, server = [], '0.0.0.0', 8080, nil
38
+ until argv.empty?
39
+ case arg = argv.shift
40
+ when /^-o=?(.+)?/, /^--host=?(.+)?/
41
+ host = $1 || argv.shift
42
+ missing_arg('host') unless host
43
+
44
+ when /^-p=?(.+)?/, /^--port=?(.+)?/
45
+ port = $1 || argv.shift
46
+ missing_arg('port') unless port
47
+
48
+ when /^-s=?(.+)?/, /^--server=?(.+)?/
49
+ server = $1 || argv.shift
50
+ missing_arg('server') unless server
51
+
52
+ when /^-c/, '--configru'
53
+ puts(config_ru_path)
54
+ exit
55
+
56
+ when /^-h/, '--help'
57
+ puts(help)
58
+ exit
59
+
60
+ when /^-v/, '--version'
61
+ require 'rest-gw2/version'
62
+ puts(RestGW2::VERSION)
63
+ exit
64
+
65
+ else
66
+ unused << arg
67
+ end
68
+ end
69
+
70
+ [unused, host, port, server]
71
+ end
72
+
73
+ def parse_next argv, arg
74
+ argv.unshift("-#{arg[2..-1]}") if arg.size > 2
75
+ end
76
+
77
+ def missing_arg arg
78
+ warn("Missing argument: #{arg}")
79
+ exit(1)
80
+ end
81
+
82
+ def load_rack
83
+ require 'rack'
84
+ rescue LoadError => e
85
+ warn(e)
86
+ puts "Maybe you should install rack by running: gem install rack"
87
+ exit(1)
88
+ end
89
+
90
+ def load_rack_handlers
91
+ require 'rack-handlers'
92
+ rescue LoadError
93
+ end
94
+
95
+ def help
96
+ optt = options.transpose
97
+ maxn = optt.first.map(&:size).max
98
+ maxd = optt.last .map(&:size).max
99
+ "Usage: rest-gw2 [OPTIONS]\n" +
100
+ options.map{ |(name, desc)|
101
+ if name.end_with?(':')
102
+ name
103
+ else
104
+ sprintf(" %-*s %-*s", maxn, name, maxd, desc)
105
+ end
106
+ }.join("\n")
107
+ end
108
+ end