short 0.5.4 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
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