rest-gw2 0.1.0 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitmodules +3 -0
- data/CHANGES.md +28 -0
- data/LICENSE +201 -0
- data/README.md +33 -6
- data/Rakefile +8 -2
- data/bin/rest-gw2 +4 -0
- data/config.ru +3 -0
- data/lib/rest-gw2/client.rb +133 -10
- data/lib/rest-gw2/server.rb +328 -32
- data/lib/rest-gw2/server/cache.rb +38 -0
- data/lib/rest-gw2/server/runner.rb +108 -0
- data/lib/rest-gw2/version.rb +4 -0
- data/lib/rest-gw2/view/error.erb +1 -0
- data/lib/rest-gw2/view/index.erb +16 -0
- data/lib/rest-gw2/view/info.erb +17 -0
- data/lib/rest-gw2/view/items.erb +18 -0
- data/lib/rest-gw2/view/layout.erb +57 -0
- data/lib/rest-gw2/view/menu.erb +15 -0
- data/lib/rest-gw2/view/pages.erb +7 -0
- data/lib/rest-gw2/view/transactions.erb +28 -0
- data/lib/rest-gw2/view/wallet.erb +16 -0
- data/lib/rest-gw2/view/wip.erb +1 -0
- data/rest-gw2.gemspec +39 -8
- metadata +92 -6
- data/lib/rest-gw2/view/bank.erb +0 -25
data/lib/rest-gw2/server.rb
CHANGED
@@ -1,8 +1,14 @@
|
|
1
1
|
|
2
|
-
require '
|
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
|
-
|
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]
|
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
|
-
|
58
|
-
|
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 =>
|
63
|
-
:log_method => env
|
64
|
-
:cache => RestGW2.
|
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
|
-
|
70
|
-
|
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
|