short 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. data/.gitignore +4 -0
  2. data/README.md +9 -0
  3. data/Rakefile +3 -0
  4. data/bin/shortener +115 -0
  5. data/config.ru +2 -0
  6. data/lib/shortener/client.rb +69 -0
  7. data/lib/shortener/configuration.rb +76 -0
  8. data/lib/shortener/server/Gemfile +6 -0
  9. data/lib/shortener/server/Gemfile.lock +23 -0
  10. data/lib/shortener/server/config.ru.template +2 -0
  11. data/lib/shortener/server/public/delete-icon.png +0 -0
  12. data/lib/shortener/server/public/flash/Jplayer.swf +0 -0
  13. data/lib/shortener/server/public/flash/clippy.swf +0 -0
  14. data/lib/shortener/server/public/flash/swfupload.swf +0 -0
  15. data/lib/shortener/server/public/images/XPButtonUploadText_61x22.png +0 -0
  16. data/lib/shortener/server/public/jquery-swfupload-min.js +1 -0
  17. data/lib/shortener/server/public/jquery-swfupload.js +64 -0
  18. data/lib/shortener/server/public/jquery.jplayer.min.js +97 -0
  19. data/lib/shortener/server/public/jquery.min.js +18 -0
  20. data/lib/shortener/server/public/patched.bootstrap.min.css +370 -0
  21. data/lib/shortener/server/public/site.js +36 -0
  22. data/lib/shortener/server/public/skin/blue.monday/jplayer.blue.monday.css +623 -0
  23. data/lib/shortener/server/public/skin/blue.monday/jplayer.blue.monday.jpg +0 -0
  24. data/lib/shortener/server/public/skin/blue.monday/jplayer.blue.monday.seeking.gif +0 -0
  25. data/lib/shortener/server/public/skin/blue.monday/jplayer.blue.monday.video.play.png +0 -0
  26. data/lib/shortener/server/public/swfupload.js +980 -0
  27. data/lib/shortener/server/views/add.haml +22 -0
  28. data/lib/shortener/server/views/display.haml +5 -0
  29. data/lib/shortener/server/views/index.haml +25 -0
  30. data/lib/shortener/server/views/layout.haml +21 -0
  31. data/lib/shortener/server/views/s3/audio.haml +75 -0
  32. data/lib/shortener/server/views/s3/image.haml +27 -0
  33. data/lib/shortener/server/views/s3/layout.haml +23 -0
  34. data/lib/shortener/server/views/s3/video.haml +71 -0
  35. data/lib/shortener/server/views/upload.haml +130 -0
  36. data/lib/shortener/server.rb +346 -0
  37. data/lib/shortener/version.rb +6 -0
  38. data/lib/shortener.rb +9 -0
  39. data/tasks/heroku.rake +46 -0
  40. data/test/test_client.rb +26 -0
  41. data/test/test_configuration.rb +23 -0
  42. data/test/test_server.rb +0 -0
  43. metadata +102 -0
@@ -0,0 +1,346 @@
1
+ require 'sinatra'
2
+ require 'redis-namespace'
3
+ require 'uri'
4
+ require 'json'
5
+ require 'haml'
6
+ require 'digest/sha1'
7
+ require 'base64'
8
+
9
+
10
+
11
+ class Shortener
12
+ class Server < Sinatra::Base
13
+ dir = File.expand_path(File.dirname(__FILE__))
14
+ set :root, File.join(dir, 'server')
15
+ set :public_folder, File.join(dir, 'server', 'public')
16
+
17
+ configure do
18
+ uri = URI.parse(ENV["REDISTOGO_URL"])
19
+ _redis = Redis.new(:host => uri.host, :port => uri.port, :password => uri.password)
20
+ $redis = Redis::Namespace.new(:shortener, redis: _redis)
21
+ $default_url = ENV['DEFAULT_URL'] || '/index'
22
+ $s3_config = {
23
+ bucket: ENV['S3_BUCKET'],
24
+ key_prefix: ENV['S3_KEY_PREFIX'],
25
+ default_acl: ENV['S3_DEFAULT_ACL'],
26
+ access_key_id: ENV['S3_ACCESS_KEY_ID'],
27
+ secret_access_key: ENV['S3_SECRET_ACCESS_KEY']
28
+ }
29
+ end
30
+
31
+ helpers do
32
+
33
+ def bad! message
34
+ halt 412, {}, message
35
+ end
36
+
37
+ def nope!
38
+ halt 404, {}, "No luck."
39
+ end
40
+
41
+ def base_url
42
+ @base_url ||= "#{request.env['rack.url_scheme']}://#{request.env['HTTP_HOST']}"
43
+ end
44
+
45
+ def clippy(text, bgcolor='#FFFFFF')
46
+ html = <<-EOF
47
+ <object classid="clsid:d27cdb6e-ae6d-11cf-96b8-444553540000"
48
+ width="110"
49
+ height="25"
50
+ id="clippy" >
51
+ <param name="movie" value="/flash/clippy.swf"/>
52
+ <param name="allowScriptAccess" value="always" />
53
+ <param name="quality" value="high" />
54
+ <param name="scale" value="noscale" />
55
+ <param NAME="FlashVars" value="text=#{text}">
56
+ <param name="bgcolor" value="#{bgcolor}">
57
+ <embed src="/flash/clippy.swf"
58
+ width="110"
59
+ height="14"
60
+ name="clippy"
61
+ quality="high"
62
+ allowScriptAccess="always"
63
+ type="application/x-shockwave-flash"
64
+ pluginspage="http://www.macromedia.com/go/getflashplayer"
65
+ FlashVars="text=#{text}"
66
+ bgcolor="#{bgcolor}"
67
+ />
68
+ </object>
69
+ EOF
70
+ end
71
+
72
+ def set_or_fetch_url(params)
73
+ bad! 'Missing url.' unless url = params['url']
74
+ url = "http://#{url}" unless /^http/i =~ url
75
+ bad! 'Malformed url.' unless (url = URI.parse(url)) && /^http/ =~ url.scheme
76
+
77
+ %w(max-clicks expire desired-short allow-override).each do |k|
78
+ params[k] = false if params[k].nil? || params[k].empty?
79
+ end
80
+
81
+ unless params['max-clicks'] || params['expire'] || params['desired-short']
82
+ id = check_cache(url)
83
+ end
84
+ id ||= shorten(url, params)
85
+
86
+ return "#{base_url}/#{id}", id
87
+ end
88
+
89
+ def set_upload_short(params)
90
+ bad! 'Missing content type.' unless type = params['type']
91
+ key = generate_short
92
+ fname = params['file_name'].gsub(' ', '+')
93
+ sha = Digest::SHA1.hexdigest(fname)
94
+ hash_key = "data:#{sha}:#{key}"
95
+ url = "https://s3.amazonaws.com/#{$s3_config[:bucket]}/#{$s3_config[:key_prefix]}/#{fname}"
96
+ ext = File.extname(fname)[1..-1]
97
+ extras = ['url', url, 's3', true, 'shortened', key, 'extension', ext, 'set-count', 1]
98
+ data = params.keys.map {|k| [k, params[k]] }.flatten.concat(extras)
99
+
100
+ $redis.set(key, sha)
101
+ $redis.hmset(hash_key, *data)
102
+
103
+ "#{base_url}/#{key}"
104
+ end
105
+
106
+ def shorten(url, options = {})
107
+
108
+ unless options['desired-short']
109
+ key = generate_short
110
+ else
111
+ check = $redis.get(options['desired-short'])
112
+ if check # it's already taken
113
+ check_key = "data:#{check}:#{options['desired-short']}"
114
+ prev_set = $redis.hgetall(check_key)
115
+
116
+ # if we don't expire or have max clicks and previously set key
117
+ # doesn't expire or have max clicks we can go ahead and use it
118
+ # without any further setup.
119
+ unless options['expire'] || options['max-clicks']
120
+ if (!prev_set['max-clicks'] && !prev_set['expire'] &&
121
+ (prev_set['url'] == url.to_s))
122
+ $redis.hincrby(check_key, 'set-count', 1)
123
+ return options['desired-short']
124
+ end
125
+ end
126
+
127
+ if prev_set['max-clicks'].to_i < prev_set['clicks'].to_i
128
+ # previous key is no longer valid, we can assign to it
129
+ key = options['desired-short']
130
+ else
131
+ bad! 'Name is already taken. Use Allow override' unless options['allow-override'] == 'true'
132
+ key = generate_short
133
+ end
134
+
135
+ else
136
+ key = options['desired-short']
137
+ end
138
+ end
139
+
140
+ sha = Digest::SHA1.hexdigest(url.to_s)
141
+ $redis.set(key, sha)
142
+
143
+ hsh_data = ['shortened', key, 'url', url, 'set-count', 1]
144
+ hsh_data.concat(['max-clicks', options['max-clicks'].to_i]) if options['max-clicks']
145
+
146
+ if options['expire'] # set expire time if specified
147
+ ttl = options['expire'].to_i
148
+ ttl_key = "expire:#{sha}:#{key}"
149
+ $redis.set(ttl_key, "#{sha}:#{key}")
150
+ $redis.expire(ttl_key, ttl)
151
+ hsh_data.concat(['expire', ttl_key])
152
+ end
153
+ $redis.hmset("data:#{sha}:#{key}", *hsh_data)
154
+
155
+ key
156
+ end
157
+
158
+ def check_cache(url)
159
+ sha = Digest::SHA1.hexdigest(url.to_s)
160
+
161
+ $redis.keys("data:#{sha}:*").each do |key|
162
+ short = $redis.hgetall(key)
163
+ unless short == {} || short['expire'] || short['max-clicks']
164
+ $redis.hincrby(key, 'set-count', 1)
165
+ return short['shortened']
166
+ end
167
+ end
168
+ nil
169
+ end
170
+
171
+ def delete_short(id)
172
+ puts "deleting #{id}"
173
+ puts sha = $redis.get(id)
174
+ $redis.multi do
175
+ $redis.del "data:#{sha}:#{id}"
176
+ $redis.del "expire:#{sha}:#{id}"
177
+ $redis.del id
178
+ end
179
+ end
180
+
181
+ def generate_short
182
+ begin
183
+ o = [('a'..'z'),('A'..'Z'),(0..9)].map{|i| i.to_a}.flatten;
184
+ key = (0..4).map{ o[rand(o.length)] }.join;
185
+ puts "testing #{key}"
186
+ end while !$redis.get(key).nil?
187
+ key
188
+ end
189
+
190
+ def ttl_display(ttl)
191
+ if ttl == -1
192
+ ret = 'expired'
193
+ elsif ttl == nil
194
+ ret = '&infin;'
195
+ else
196
+ ret = ttl
197
+ end
198
+ ret
199
+ end
200
+
201
+ def s3_policy
202
+ expiration_date = (Time.now + 36000).utc.strftime('%Y-%m-%dT%H:%M:%S.000Z') # 10.hours.from_now
203
+ max_filesize = 2147483648 # 2.gigabyte
204
+ policy = Base64.encode64(
205
+ "{'expiration': '#{expiration_date}',
206
+ 'conditions': [
207
+ {'bucket': '#{$s3_config[:bucket]}'},
208
+ ['starts-with', '$key', '#{$s3_config[:key_prefix]}'],
209
+ {'acl': '#{$s3_config[:default_acl]}'},
210
+ {'success_action_status': '201'},
211
+ ['starts-with', '$Filename', ''],
212
+ ['content-length-range', 0, #{max_filesize}]
213
+ ]
214
+ }"
215
+ ).gsub(/\n|\r/, '')
216
+ end
217
+
218
+ def s3_signature(policy)
219
+ signature = Base64.encode64(OpenSSL::HMAC.digest(
220
+ OpenSSL::Digest::Digest.new('sha1'),
221
+ $s3_config[:secret_access_key], policy)
222
+ ).gsub("\n","")
223
+ end
224
+
225
+ end
226
+
227
+ before do
228
+ params
229
+ end
230
+
231
+ get '/' do
232
+ redirect $default_url
233
+ end
234
+
235
+ get '/add' do
236
+ haml :add
237
+ end
238
+
239
+ get '/index.?:format?' do
240
+ @shortens = Array.new
241
+ $redis.keys('data:*').each do |key|
242
+ short = $redis.hgetall(key)
243
+ short['expire'] = $redis.ttl(short['expire']) if short.has_key?('expire')
244
+ @shortens << short
245
+ puts " url for #{key[-5..-1]} => #{short['url']}"
246
+ end
247
+ if params[:format] == 'json'
248
+ content_type :json
249
+ return @shortens.to_json
250
+ end
251
+ haml :index
252
+ end
253
+
254
+ get '/delete/:id.:format' do |id, format|
255
+ puts "#{id}, #{format}"
256
+ delete_short(id)
257
+ if format == 'json'
258
+ content_type :json
259
+ {success: true, short: id}.to_json
260
+ else
261
+ redirect :index
262
+ end
263
+ end
264
+
265
+ get '/upload' do
266
+ policy = s3_policy
267
+ signature = s3_signature(policy)
268
+
269
+ @post = {
270
+ "key" => "#{$s3_config[:key_prefix]}/${filename}",
271
+ "AWSAccessKeyId" => "#{$s3_config[:access_key_id]}",
272
+ "acl" => "#{$s3_config[:default_acl]}",
273
+ "policy" => "#{policy}",
274
+ "signature" => "#{signature}",
275
+ "success_action_status" => "201"
276
+ }
277
+
278
+ @upload_url = "http://#{$s3_config[:bucket]}.s3.amazonaws.com/"
279
+ haml :upload
280
+ end
281
+
282
+ #get '/:id.?:format?' do
283
+ get %r{\/([a-z0-9]{5})(\.[a-z]{3,}){0,1}}i do
284
+ id = params[:captures].first
285
+ sha = $redis.get(id)
286
+ unless sha.nil?
287
+ key = "data:#{sha}:#{id}"
288
+ short = $redis.hgetall(key)
289
+ not_expired = short.has_key?('expire') ? $redis.get(short['expire']) : true
290
+ not_maxed = !(short['click-count'].to_i >= short['max-clicks'].to_i)
291
+ short.has_key?('max-clicks') ? not_maxed : not_maxed = true
292
+ $redis.hincrby(key, 'click-count', 1) if not_expired && not_maxed
293
+ if params[:captures].last == '.json'
294
+ ret = short.merge({expired: not_expired.nil? , maxed: !not_maxed})
295
+ content_type :json
296
+ return ret.to_json
297
+ else
298
+ if not_expired
299
+ unless short['s3'] == 'true' && !(short['type'] == 'download')
300
+ if not_maxed
301
+ puts "redirecting #{id} to #{url}"
302
+ redirect short['url']
303
+ end # => max clicks check
304
+ else
305
+ @short = short
306
+ puts "rendering view for s3 content. #{id} => #{short['url']}"
307
+ return haml(:"s3/#{short['type']}", layout: :'s3/layout')
308
+ end # => it's S3 and needs displaying.
309
+ end # => expired check
310
+ end # => format
311
+ end
312
+ puts "redirecting to default url"
313
+ redirect $default_url
314
+ end
315
+
316
+ post '/upload.?:format?' do |format|
317
+ content_type :json
318
+ short = set_upload_short(params['shortener'])
319
+ puts "set #{short} to #{params['shortener']['file_name']}"
320
+ if format == 'json'
321
+ {url: short}.to_json
322
+ else
323
+ redirect :index
324
+ end
325
+ end
326
+
327
+ post '/add.?:format?' do |format|
328
+ begin
329
+ # TODO figure out why the fuck these are parsing from Net::HTTP
330
+ params['shortener'] = JSON.parse(params['shortener']) if params['shortener'].is_a?(String)
331
+ rescue Exception => boom
332
+ # essentally, params = params
333
+ end
334
+
335
+ @url, id = set_or_fetch_url(params["shortener"])
336
+ puts "set #{@url} to #{params['shortener']['url']}"
337
+ if format == 'json'
338
+ content_type :json
339
+ {success: true, short: id, url: @url, html: haml(:display, :layout => false)}.to_json
340
+ else
341
+ haml :display
342
+ end
343
+ end
344
+
345
+ end # => Server
346
+ end # => Shortener
@@ -0,0 +1,6 @@
1
+
2
+ class Shortener
3
+
4
+ VERSION = '0.2.1'
5
+
6
+ end
data/lib/shortener.rb ADDED
@@ -0,0 +1,9 @@
1
+ dir = File.expand_path(File.dirname(__FILE__))
2
+
3
+ require File.join(dir, 'shortener', 'version')
4
+ require File.join(dir, 'shortener', 'client')
5
+ require File.join(dir, 'shortener', 'configuration')
6
+
7
+ class Shortener
8
+
9
+ end
data/tasks/heroku.rake ADDED
@@ -0,0 +1,46 @@
1
+ $gem_dir = File.expand_path(File.dirname(File.dirname(__FILE__)))
2
+ def gem_file(*args)
3
+ ret = args.map {|f| File.join($gem_dir, f)}.join(" ")
4
+ end
5
+ namespace :heroku do
6
+
7
+ desc "Build a Heroku Ready Git repo"
8
+ task :build do
9
+ cmd = "mkdir heroku"
10
+ cmd += " && mkdir heroku/server"
11
+ cmd += " && cp -r #{gem_file("lib/shortener/server/*")} ./heroku/server/"
12
+ cmd += " && cp #{gem_file('lib/shortener/server.rb')} ./heroku/main.rb"
13
+ cmd += " && mv ./heroku/server/config.ru.template ./heroku/config.ru"
14
+ cmd += " && mv ./heroku/server/Gemfile ./heroku/server/Gemfile.lock ./heroku"
15
+ cmd += " && git init heroku && cd heroku && git add . && git commit -m initial"
16
+ sh cmd
17
+ end
18
+
19
+ desc "config a Heroku app the way we need it. Optionally set APPNAME to set heroku app name"
20
+ task :config do
21
+ require_relative '../lib/shortener'
22
+ $name = ENV['APPNAME'] || "shner-#{`whoami`.chomp}"
23
+ cmd = Dir.pwd =~ /heroku$/ ? "" : "cd heroku && "
24
+ cmd += "heroku create #{name}"
25
+ cmd += " && heroku addons:add redistogo:nano"
26
+ cmd += " && heroku config:add #{Shortener::Configuration.new.to_params}"
27
+ cmd += " && heroku addons:add custom_domains:basic"
28
+ sh cmd
29
+ end
30
+
31
+ desc "Push to Heroku"
32
+ task :push do
33
+ cmd = Dir.pwd =~ /heroku$/ ? "" : "cd heroku && "
34
+ cmd += "git push heroku master"
35
+ sh cmd
36
+ end
37
+
38
+ desc "Build, configure and push a shortener app to Heroku"
39
+ task :setup => [:build, :config, :push] do
40
+ puts "\nYour app has (hopefully) been created and pushed and available @" +
41
+ " http://#{$name}.heroku.com\n\n" +
42
+ "the Custom Domain Addon has been added, but still needs configuring, for" +
43
+ " steps see\n http://devcenter.heroku.com/articles/custom-domains"
44
+ end
45
+
46
+ end
@@ -0,0 +1,26 @@
1
+ require 'minitest/autorun'
2
+ require_relative '../lib/shortener/client'
3
+
4
+ class TestShortenerClient < MiniTest::Unit::TestCase
5
+
6
+ def setup
7
+ @client = Shortener::Client.new
8
+ end
9
+
10
+ def test_add
11
+ short = @client.shorten('www.google.com')
12
+ assert short['success']
13
+ end
14
+
15
+ def test_index
16
+ ind = @client.index
17
+ assert ind.is_a?(Array)
18
+ end
19
+
20
+ def test_delete
21
+ add = @client.shorten('www.google.com')
22
+ del = @client.delete(add['short'])
23
+ assert del['success']
24
+ end
25
+
26
+ end
@@ -0,0 +1,23 @@
1
+ require 'minitest/autorun'
2
+ require_relative '../lib/shortener/configuration'
3
+
4
+ class TestShortenerConfiguration < MiniTest::Unit::TestCase
5
+
6
+ def setup
7
+ @conf = Shortener::Configuration.new(:SHORTENER_URL => 'localhost:4567')
8
+ end
9
+
10
+ def test_pass_options
11
+ @conf = Shortener::Configuration.new(:SHORTENER_URL => 'fuckoff')
12
+ assert @conf.is_a?(Shortener::Configuration)
13
+ assert @conf.shortener_url == 'fuckoff'
14
+ p @conf.default_url
15
+ end
16
+
17
+ def test_uri_for
18
+ test = URI.parse("#{@conf.shortener_url}/add.json")
19
+ p @conf, @conf.shortener_url, @conf.uri_for(:add)
20
+ assert @conf.uri_for(:add) == test
21
+ end
22
+
23
+ end
File without changes
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: short
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - jake
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2011-12-23 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: turn
16
+ requirement: &70302203079100 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: *70302203079100
25
+ description: A (hopefully) easy and handy way to shorten links.
26
+ email:
27
+ - jake.wilkins@adfitech.com
28
+ executables:
29
+ - shortener
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - .gitignore
34
+ - README.md
35
+ - Rakefile
36
+ - bin/shortener
37
+ - config.ru
38
+ - lib/shortener.rb
39
+ - lib/shortener/client.rb
40
+ - lib/shortener/configuration.rb
41
+ - lib/shortener/server.rb
42
+ - lib/shortener/server/Gemfile
43
+ - lib/shortener/server/Gemfile.lock
44
+ - lib/shortener/server/config.ru.template
45
+ - lib/shortener/server/public/delete-icon.png
46
+ - lib/shortener/server/public/flash/Jplayer.swf
47
+ - lib/shortener/server/public/flash/clippy.swf
48
+ - lib/shortener/server/public/flash/swfupload.swf
49
+ - lib/shortener/server/public/images/XPButtonUploadText_61x22.png
50
+ - lib/shortener/server/public/jquery-swfupload-min.js
51
+ - lib/shortener/server/public/jquery-swfupload.js
52
+ - lib/shortener/server/public/jquery.jplayer.min.js
53
+ - lib/shortener/server/public/jquery.min.js
54
+ - lib/shortener/server/public/patched.bootstrap.min.css
55
+ - lib/shortener/server/public/site.js
56
+ - lib/shortener/server/public/skin/blue.monday/jplayer.blue.monday.css
57
+ - lib/shortener/server/public/skin/blue.monday/jplayer.blue.monday.jpg
58
+ - lib/shortener/server/public/skin/blue.monday/jplayer.blue.monday.seeking.gif
59
+ - lib/shortener/server/public/skin/blue.monday/jplayer.blue.monday.video.play.png
60
+ - lib/shortener/server/public/swfupload.js
61
+ - lib/shortener/server/views/add.haml
62
+ - lib/shortener/server/views/display.haml
63
+ - lib/shortener/server/views/index.haml
64
+ - lib/shortener/server/views/layout.haml
65
+ - lib/shortener/server/views/s3/audio.haml
66
+ - lib/shortener/server/views/s3/image.haml
67
+ - lib/shortener/server/views/s3/layout.haml
68
+ - lib/shortener/server/views/s3/video.haml
69
+ - lib/shortener/server/views/upload.haml
70
+ - lib/shortener/version.rb
71
+ - tasks/heroku.rake
72
+ - test/test_client.rb
73
+ - test/test_configuration.rb
74
+ - test/test_server.rb
75
+ homepage: ''
76
+ licenses: []
77
+ post_install_message:
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ none: false
83
+ requirements:
84
+ - - ! '>='
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ none: false
89
+ requirements:
90
+ - - ! '>='
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubyforge_project:
95
+ rubygems_version: 1.8.10
96
+ signing_key:
97
+ specification_version: 3
98
+ summary: Link Shortener
99
+ test_files:
100
+ - test/test_client.rb
101
+ - test/test_configuration.rb
102
+ - test/test_server.rb