short 0.2.1

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.
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