short 0.5.4 → 0.6.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.
Files changed (35) hide show
  1. data/README.md +14 -0
  2. data/bin/short +3 -0
  3. data/lib/shortener/configuration.rb +38 -6
  4. data/lib/shortener/server/Gemfile +2 -0
  5. data/lib/shortener/server/Gemfile.lock +5 -0
  6. data/lib/shortener/server/api/v1.rb +76 -0
  7. data/lib/shortener/server/auth.rb +117 -0
  8. data/lib/shortener/server/brief.rb +189 -0
  9. data/lib/shortener/server/config.ru.template +44 -2
  10. data/lib/shortener/server/helpers.rb +84 -0
  11. data/lib/shortener/server/pbkdf2.rb +192 -0
  12. data/lib/shortener/server/public/css/bootstrap-responsive.min.css +9 -3
  13. data/lib/shortener/server/public/css/bootstrap.min.css +9 -610
  14. data/lib/shortener/server/public/css/controls.png +0 -0
  15. data/lib/shortener/server/public/css/loading.gif +0 -0
  16. data/lib/shortener/server/public/css/shortener.css +23 -2
  17. data/lib/shortener/server/public/js/bootstrap.min.js +6 -1
  18. data/lib/shortener/server/public/js/site.js +2 -2
  19. data/lib/shortener/server/user.rb +131 -0
  20. data/lib/shortener/server/views.rb +55 -0
  21. data/lib/shortener/server/views/add.haml +8 -8
  22. data/lib/shortener/server/views/display.haml +1 -1
  23. data/lib/shortener/server/views/index.haml +28 -12
  24. data/lib/shortener/server/views/layout.haml +38 -12
  25. data/lib/shortener/server/views/s3/layout.haml +1 -1
  26. data/lib/shortener/server/views/u/edit.haml +40 -0
  27. data/lib/shortener/server/views/u/login.haml +13 -0
  28. data/lib/shortener/server/views/upload.haml +9 -9
  29. data/lib/shortener/server/warden.rb +29 -0
  30. data/lib/shortener/short.rb +2 -2
  31. data/lib/shortener/tasks/heroku.rb +16 -10
  32. data/lib/shortener/version.rb +1 -1
  33. metadata +22 -12
  34. data/lib/shortener/server.rb +0 -350
  35. data/lib/shortener/server/public/flash/clippy.swf +0 -0
data/README.md CHANGED
@@ -9,6 +9,20 @@ for obvious reasons, the demo is configured with S3 disabled. However, if you ar
9
9
  to play around with `short` follow the instructions below and use `http://shortener1.heroku.com`
10
10
  as your the url you want to use.
11
11
 
12
+ ## Version 0.6.0
13
+
14
+ v0.6.0 is pretty much a complete rewrite of most things short. The server has
15
+ been divided up in to smaller chunks, seperating the shortening service from the
16
+ view front-end, and adding an optional authentication layer, and then the short
17
+ client was patched to use these changes. v0.6.0 will hopefully wind up being
18
+ pretty close to version 1.0, but before then I want to put together some test
19
+ cases and rewrite the docs to accuarately reflect the new server structure.
20
+
21
+ There shouldn't be any configuration changes most people need to make, unless
22
+ you want to take advantage of the new features, if you'd like to check these out
23
+ before I can improve the docs, once again the `Configuration` class will be your
24
+ friend.
25
+
12
26
  ## Upgrading to 0.5.0
13
27
 
14
28
  v0.5.0 updates how shortener stores some data. To assist in keeping your data,
data/bin/short CHANGED
@@ -75,6 +75,7 @@ def show_index
75
75
  index = Shortener.index
76
76
  rescue Shortener::NetworkException => boom
77
77
  puts boom.message
78
+ exit
78
79
  end
79
80
  short_summary = index.map do |v|
80
81
  url = v['url'].length > 38 ? "#{v['url'][0..25]}...#{v['url'][-10..-1]}" : "#{v['url']}"
@@ -174,6 +175,8 @@ when 'delete'
174
175
  do_action(:delete, ARGV[1])
175
176
  when '-v', '--version'
176
177
  puts "short version #{Shortener::VERSION}"
178
+ when '-h', '--help'
179
+ usage
177
180
  else
178
181
  do_action(:shorten, ARGV[0])
179
182
  end
@@ -16,9 +16,10 @@ class Shortener
16
16
 
17
17
  OPTIONS = [:SHORTENER_URL, :DEFAULT_URL, :REDISTOGO_URL, :S3_KEY_PREFIX,
18
18
  :S3_ACCESS_KEY_ID, :S3_SECRET_ACCESS_KEY, :S3_DEFAULT_ACL, :S3_BUCKET,
19
- :DOTFILE_PATH, :S3_ENABLED, :SHORTENER_NS]
19
+ :DOTFILE_PATH, :S3_ENABLED, :SHORTENER_NS, :REQUIRE_AUTH, :ALLOW_SIGNUP,
20
+ :VIEWS]
20
21
 
21
- HEROKU_IGNORE = [:DOTFILE_PATH, :SHORTENER_URL, :REDISTOGO_URL]
22
+ HEROKU_IGNORE = [:DOTFILE_PATH, :SHORTENER_URL, :REDISTOGO_URL, :REQUIRE_AUTH]
22
23
 
23
24
  END_POINTS = [:add, :fetch, :upload, :index, :delete]
24
25
 
@@ -33,7 +34,18 @@ class Shortener
33
34
  check_env
34
35
  @options = @options.merge!(opts)
35
36
  @options[:DEFAULT_URL] ||= '/index'
37
+ if @options[:SHORTENER_URL] && @options[:SHORTENER_URL][-1] == '/'
38
+ @options[:SHORTENER_URL].chop!
39
+ end
36
40
  @options[:SHORTENER_NS] ||= :shortener
41
+ @options[:VIEWS] = !(@options[:VIEWS] == false || @options[:VIEWS] == 'false')
42
+ @options[:ALLOW_SIGNUP] = @options.has_key?(:ALLOW_SIGNUP)
43
+ if @options[:REQUIRE_AUTH].is_a?(String) || @options[:REQUIRE_AUTH].is_a?(Symbol)
44
+ @options[:REQUIRE_AUTH] = @options[:REQUIRE_AUTH].to_s.split(',').map do |auth|
45
+ auth.upcase.to_sym
46
+ end
47
+ end
48
+ @options[:REQUIRE_AUTH] ||= []
37
49
  else
38
50
  @options = Configuration.current.options
39
51
  end
@@ -53,11 +65,20 @@ class Shortener
53
65
  def to_params
54
66
  ret = Array.new
55
67
  @options.each {|k,v| ret << "#{k}=#{v}" unless HEROKU_IGNORE.include?(k)}
68
+ _ra = @options[:REQUIRE_AUTH].join(',')
69
+ ret << "REQUIRE_AUTH=#{_ra}"
56
70
  ret.join(" ")
57
71
  end
58
72
 
73
+ def to_json
74
+ safe_options = @options.delete_if do |k|
75
+ [:S3_SECRET_ACCESS_KEY, :S3_ACCESS_KEY_ID, :REDISTOGO_URL].include?(k)
76
+ end
77
+ safe_options.to_json
78
+ end
79
+
59
80
  OPTIONS.each do |opt|
60
- next if [:REDISTOGO_URL, :S3_ENABLED].include?(opt)
81
+ next if [:REDISTOGO_URL, :S3_ENABLED, :REQUIRES_AUTH].include?(opt)
61
82
  method_name = opt.to_s.downcase.to_sym
62
83
  define_method "#{method_name}" do
63
84
  @options[opt]
@@ -128,6 +149,19 @@ class Shortener
128
149
  OpenSSL::Digest::Digest.new('sha1'), s3_secret_access_key, policy)).gsub("\n","")
129
150
  end
130
151
 
152
+ # a boolean indicating whether we should use authentication
153
+ def authenticate?
154
+ !(@options[:REQUIRE_AUTH] == [])
155
+ end
156
+
157
+ # check if this endpoint needs auth
158
+ def auth_route?(url)
159
+ ep = url.split('/').last
160
+ return false if ep.nil?
161
+ ep.gsub!('.json', '')
162
+ @options[:REQUIRE_AUTH].include?(ep.upcase.to_sym)
163
+ end
164
+
131
165
  private
132
166
 
133
167
  # Parse the YAML dotfile if one exists
@@ -150,10 +184,8 @@ class Shortener
150
184
  case end_point
151
185
  when :fetch
152
186
  opts
153
- when :delete
154
- "#{end_point}/#{opts}"
155
187
  else
156
- end_point.to_s
188
+ "api/v1/#{end_point}"
157
189
  end
158
190
  end
159
191
 
@@ -4,3 +4,5 @@ source "http://rubygems.org"
4
4
  gem 'sinatra'
5
5
  gem 'redis-namespace'
6
6
  gem 'haml'
7
+ gem 'warden'
8
+ gem 'ruby-hmac'
@@ -8,11 +8,14 @@ GEM
8
8
  redis (2.2.2)
9
9
  redis-namespace (1.1.0)
10
10
  redis (< 3.0.0)
11
+ ruby-hmac (0.4.0)
11
12
  sinatra (1.3.1)
12
13
  rack (~> 1.3, >= 1.3.4)
13
14
  rack-protection (~> 1.1, >= 1.1.2)
14
15
  tilt (~> 1.3, >= 1.3.3)
15
16
  tilt (1.3.3)
17
+ warden (1.1.1)
18
+ rack (>= 1.0)
16
19
 
17
20
  PLATFORMS
18
21
  ruby
@@ -20,4 +23,6 @@ PLATFORMS
20
23
  DEPENDENCIES
21
24
  haml
22
25
  redis-namespace
26
+ ruby-hmac
23
27
  sinatra
28
+ warden
@@ -0,0 +1,76 @@
1
+ require 'uri'
2
+ require 'json'
3
+ require 'digest/sha1'
4
+ require 'base64'
5
+ require 'haml'
6
+
7
+ class Shortener
8
+ module Server
9
+ module Api
10
+ class V1 < Sinatra::Base
11
+
12
+ set(:s3_available) { |v| condition {$conf.s3_available == v} }
13
+ set(:allow_signup) { |v| condition {$conf.allow_signup} }
14
+
15
+ set :root, File.dirname(File.dirname(__FILE__))
16
+
17
+ helpers ShortServerHelpers
18
+
19
+ before do
20
+ if $conf.auth_route?(env['PATH_INFO']) &&
21
+ !env['warden'].authenticated?(:token) &&
22
+ !env['warden'].authenticate!(:token)
23
+ halt 401, {}, "Not Authorized, specify your auth token."
24
+ end
25
+ end if $conf.authenticate?
26
+
27
+ get '/' do
28
+ redirect $conf.default_url
29
+ end
30
+
31
+ get '/api/v1/index.?:format?' do
32
+ Brief.all.to_json
33
+ end
34
+
35
+ post '/api/v1/upload.?:format?' do
36
+ @data = Brief.upload(params['shortener'])
37
+ puts "set #{@data['shortened']} to #{params['shortener']['file_name']}"
38
+ @url = "#{base_url}/#{@data['shortened']}"
39
+ content_type :json
40
+ @data.merge({html: haml(:display, layout: false)}).to_json
41
+ end
42
+
43
+ post '/api/v1/add.?:format?' do
44
+ @data = Brief.shorten(params["shortener"])
45
+ @url = "#{base_url}/#{@data['shortened']}"
46
+ puts "set #{@url} to #{params['shortener']['url']}"
47
+ content_type :json
48
+ @data.merge({success: true, html: haml(:display, :layout => false)}).to_json
49
+ end
50
+
51
+ post '/api/v1/delete.?:format?' do
52
+ status = Brief.delete(params['id'])
53
+ nope! "Short not found: #{params['id']}" if status == false
54
+ puts " - deleted short id: #{params['id']}"
55
+ content_type :json
56
+ {success: status, shortened: params['id']}.to_json
57
+ end
58
+
59
+ get '/api/v1/config.?:format?' do
60
+ $conf.to_json
61
+ end
62
+
63
+ #get '/:id.?:format?' do
64
+ get %r{\/([a-z0-9]{3,})(\.[a-z]{3,}){0,1}}i do
65
+ id = params[:captures].first
66
+ @short, type = Brief.find(id, params)
67
+ redirect @short if type == :url
68
+ return haml(:"s3/#{@short['type']}", layout: :'s3/layout') if type == :s3
69
+ content_type :json
70
+ @short
71
+ end
72
+
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,117 @@
1
+ require File.join(File.dirname(__FILE__), 'user')
2
+
3
+ class Shortener
4
+ module Server
5
+ class Auth < Sinatra::Base
6
+
7
+ dir = File.expand_path(File.dirname(__FILE__))
8
+ set :root, dir
9
+ set :public_folder, File.join(dir, 'public')
10
+
11
+ set(:signup) {|v| condition {$conf.allow_signup == v}}
12
+ set(:has_views) {|v| condition {$conf.views == v}}
13
+
14
+ helpers ShortServerHelpers
15
+
16
+ helpers do
17
+ def _response(views, api)
18
+ if $conf.views && !(params[:format] == '.json' ||
19
+ request.env['REQUEST_PATH'].include?('.json'))
20
+ return views.call if views.is_a?(Proc)
21
+ redirect views
22
+ else
23
+ content_type :json
24
+ api.is_a?(Proc) ? api.call.to_json : api.to_json
25
+ end
26
+ end
27
+ end
28
+
29
+ # this is our failure app, and this is where we handle that.
30
+ post '/unauthenticated/?' do
31
+ status 401
32
+ _response(->{haml :'u/login'},
33
+ {message: "You are not authenticated. Pass your token!"})
34
+ end
35
+
36
+ #
37
+ # these handle view based authentication.
38
+ #
39
+
40
+ get('/u/login', has_views: true) { haml :'u/login'}
41
+
42
+ get('/u/edit', has_views: true) do
43
+ authorize!
44
+ @action = '/api/v1/u/update'
45
+ @user = env['warden'].user
46
+ haml :'u/edit'
47
+ end
48
+
49
+ get('/u/signup', signup: true, has_views: true) do
50
+ @action = '/api/v1/u/create'
51
+ @user = User.new
52
+ haml :'u/edit'
53
+ end
54
+
55
+ #
56
+ # These are the api calls associated with authentiction
57
+ #
58
+
59
+ get '/api/v1/u/username_available.json' do
60
+ {available: User.available?(params['user']['username'])}.to_json
61
+ end
62
+
63
+ post '/api/v1/u/create', signup: true do
64
+ ret = if User.available?(params['user']['username'])
65
+ u = User.new(params['user'])
66
+ u.save
67
+ env['warden'].set_user(u)
68
+ ["#{base_url}/v/index", u]
69
+ else
70
+ ["#{base_url}/u/signup", {status: :fail, message: 'Username not available'}]
71
+ end
72
+ _response(*ret)
73
+ end
74
+
75
+ post '/api/v1/u/login.?:format?' do
76
+ env['warden'].authenticate!#(:token)
77
+ _url = session.delete(:REDIRECT_TO) || "#{$conf.shortener_url}/v/index"
78
+ _response(_url, env['warden'].user)
79
+ end
80
+
81
+ get '/api/v1/u/logout.?:format?' do
82
+ env['warden'].logout if env['warden'].authenticated?
83
+ _response($conf.default_url, {message: 'l8er'})
84
+ end
85
+
86
+ post '/api/v1/u/update' do
87
+ authorize!
88
+ @user = env['warden'].user
89
+ [:username, :email, :name].each do |attr|
90
+ @user.send(:"#{attr}=", params['user'][attr.to_s])
91
+ end
92
+ unless params['user']['password'].nil? || params['user']['password'].empty?
93
+ @user.password = params['user']['password']
94
+ end
95
+ @user.save
96
+ _response("#{base_url}/v/index", @user)
97
+ end
98
+
99
+ post '/api/v1/u/reset_token.json' do
100
+ authorize!
101
+ user = env['warden'].user
102
+ user.reset_token
103
+ user.save
104
+ env['warden'].set_user(user)
105
+ content_type :json
106
+ env['warden'].user.to_json
107
+ end
108
+
109
+ post '/api/v1/u/delete' do
110
+ env['warden'].user.delete
111
+ env['warden'].logout(env['warden'].config.default_scope)
112
+ _response($conf.default_url, {message: "Miss you already"})
113
+ end
114
+
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,189 @@
1
+
2
+ class Shortener
3
+ module Server
4
+ class Brief
5
+
6
+ class << self
7
+
8
+ def all
9
+ $redis.keys('data:*').map do |key|
10
+ short = $redis.hgetall(key)
11
+ puts " url for #{key[-5..-1]} => #{short['url']}"
12
+ short['expire'] = $redis.ttl(short['expire']) if short.has_key?('expire')
13
+ short
14
+ end
15
+ end
16
+
17
+ def find(id, params)
18
+ sha = $redis.get(id)
19
+ if sha.nil? # => Short Not Found
20
+ if (params[:captures].last == '.json')
21
+ nope! "Short not found: #{id}"
22
+ else
23
+ puts "redirecting #{params[:captures].inspect} to default url"
24
+ return $conf.default_url, :url
25
+ end
26
+ else # => Short Found
27
+ key = "data:#{sha}:#{id}"
28
+ short = $redis.hgetall(key)
29
+ not_expired = short.has_key?('expire') ? $redis.get(short['expire']) : true
30
+ not_maxed = !(short['click_count'].to_i >= short['max_clicks'].to_i)
31
+ short.has_key?('max_clicks') ? not_maxed : not_maxed = true
32
+ if params[:captures].last == '.json' # => We just want JSON
33
+ ret = short.merge({expired: not_expired.nil? , maxed: !not_maxed})
34
+ return ret.to_json, :json
35
+ else # => Redirect Me!
36
+ $redis.hincrby(key, 'click_count', 1) if not_expired && not_maxed
37
+ if not_expired
38
+ unless short['s3'] == 'true' && !(short['type'] == 'download')
39
+ if not_maxed
40
+ puts "redirecting found short #{id} to #{short['url']}"
41
+ return short['url'], :url
42
+ end # => max clicks check
43
+ else # => This is S3 content
44
+ puts "rendering view for s3 content. #{id} => #{short['url']}"
45
+ return short, :s3
46
+ end # => it's S3 and needs displaying.
47
+ end # => expired check
48
+ end # => format
49
+ end
50
+ # => short was maxed or expired & not a JSON request
51
+ return $conf.default_url, :url
52
+ end
53
+
54
+ def shorten(params)
55
+ bad! 'Missing url.' unless url = params['url']
56
+ bad! 'Bad URL' unless params['url'] =~ /(^http|^www)/
57
+ url = "http://#{url}" unless /^http/i =~ url
58
+ bad! 'Bad URL' unless (url = URI.parse(url)) && /^http/ =~ url.scheme
59
+
60
+ %w(max_clicks expire desired_short allow_override).each do |k|
61
+ params[k] = false if params[k].nil? || params[k].empty?
62
+ end
63
+
64
+ unless params['max_clicks'] || params['expire'] || params['desired_short']
65
+ data = check_cache(url)
66
+ end
67
+ data ||= get_short_key(url, params)
68
+
69
+ data
70
+ end
71
+
72
+ def delete(id)
73
+ sha = $redis.get(id)
74
+ unless sha.nil?
75
+ $redis.multi do
76
+ $redis.del "data:#{sha}:#{id}"
77
+ $redis.del "expire:#{sha}:#{id}"
78
+ $redis.del id
79
+ end
80
+ true
81
+ else
82
+ false
83
+ end
84
+ end
85
+
86
+ def upload(params)
87
+ bad! 'Missing content type.' unless type = params['type']
88
+ fname = params['file_name'].gsub(' ', '+')
89
+ url = "https://s3.amazonaws.com/#{$conf.s3_bucket}/#{$conf.s3_key_prefix}/#{fname}"
90
+ data = {'s3' => true, 'extension' => File.extname(fname)[1..-1],
91
+ 'description' => params.delete('description'),
92
+ 'name' => params.delete('name'), 'type' => params.delete('type')}
93
+ get_short_key(url, params, data)
94
+ end
95
+
96
+ private
97
+
98
+ def get_short_key(url, options = {}, data = {})
99
+ hsh_data = catch :stop_setting_up do
100
+ unless options['desired_short']
101
+ puts " just generating a short"
102
+ key = generate_short
103
+ else
104
+ do_check = $redis.get(options['desired_short'])
105
+ key = if do_check.nil? || passes_desired_short_check(url, do_check, options)
106
+ options['desired_short']
107
+ else
108
+ bad! 'Name is already taken. Use Allow override' unless options['allow_override'] == 'true'
109
+ generate_short
110
+ end
111
+ end
112
+
113
+ sha = Digest::SHA1.hexdigest(url.to_s)
114
+ $redis.set(key, sha)
115
+
116
+ hsh_data = data.merge('shortened' => key, 'url' => url, 'set_count' => 1)
117
+ hsh_data['max_clicks'] = options['max_clicks'].to_i if options['max_clicks']
118
+
119
+ if options['expire'] # set expire time if specified
120
+ ttl = options['expire'].to_i
121
+ ttl_key = "expire:#{sha}:#{key}"
122
+ $redis.set(ttl_key, "#{sha}:#{key}")
123
+ $redis.expire(ttl_key, ttl)
124
+ hsh_data[:expire] = ttl_key
125
+ end
126
+ $redis.hmset("data:#{sha}:#{key}", *arrayify_hash(hsh_data))
127
+
128
+ hsh_data
129
+ end
130
+ end
131
+
132
+ def bad! message
133
+ throw :halt, [412, {}, message]
134
+ end
135
+
136
+ def nope!(message = 'No luck.')
137
+ throw :halt, [404, {}, message]
138
+ end
139
+
140
+ def generate_short
141
+ begin
142
+ o = [('a'..'z'),('A'..'Z'),(0..9)].map{|i| i.to_a}.flatten;
143
+ key = (0..4).map{ o[rand(o.length)] }.join;
144
+ puts "testing #{key}"
145
+ end while !$redis.get(key).nil?
146
+ key
147
+ end
148
+
149
+ def passes_desired_short_check(url, check, options)
150
+ check_key = "data:#{check}:#{options['desired_short']}"
151
+ prev_set = $redis.hgetall(check_key)
152
+
153
+ return false if prev_set['expire']
154
+
155
+ # if we don't expire or have max clicks and previously set key
156
+ # doesn't expire or have max clicks we can go ahead and use it
157
+ # without any further setup.
158
+ unless options['expire'] || options['max_clicks']
159
+ if (!prev_set['max_clicks'] && !prev_set['expire'] &&
160
+ (prev_set['url'] == url.to_s))
161
+ $redis.hincrby(check_key, 'set_count', 1)
162
+ throw :stop_setting_up, prev_set
163
+ end
164
+ end
165
+
166
+ return (prev_set['clicks'].to_i > prev_set['max_clicks'].to_i)
167
+ end
168
+
169
+ def check_cache(url)
170
+ sha = Digest::SHA1.hexdigest(url.to_s)
171
+
172
+ $redis.keys("data:#{sha}:*").each do |key|
173
+ short = $redis.hgetall(key)
174
+ unless short == {} || short['expire'] || short['max_clicks']
175
+ $redis.hincrby(key, 'set_count', 1)
176
+ return short
177
+ end
178
+ end
179
+ nil
180
+ end
181
+
182
+ def arrayify_hash(hsh)
183
+ hsh.keys.map {|k| [k, hsh[k]] }.flatten
184
+ end
185
+ end # => class << self
186
+
187
+ end # => Shirt
188
+ end # => Server
189
+ end # => Shortener