firefly 1.2.2 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,94 +3,100 @@ require 'haml'
3
3
  require 'digest/md5'
4
4
 
5
5
  module Firefly
6
+ class InvalidUrlError < StandardError
7
+ end
8
+
9
+ class InvalidCodeError < StandardError
10
+ end
11
+
6
12
  class Server < Sinatra::Base
7
13
  enable :sessions
8
-
14
+
9
15
  dir = File.join(File.dirname(__FILE__), '..', '..')
10
16
 
11
17
  set :views, "#{dir}/views"
12
18
  set :public, "#{dir}/public"
13
19
  set :haml, {:format => :html5 }
14
20
  set :static, true
15
-
21
+
16
22
  attr_accessor :config
17
-
23
+
18
24
  helpers do
19
25
  include Rack::Utils
20
26
  alias_method :h, :escape_html
21
-
27
+
22
28
  def url(*path_parts)
23
29
  [ path_prefix, path_parts ].join("/").squeeze('/')
24
30
  end
25
31
  alias_method :u, :url
26
-
32
+
27
33
  def path_prefix
28
34
  request.env['SCRIPT_NAME']
29
35
  end
30
-
36
+
31
37
  def set_api_cookie(key)
32
38
  session["firefly_session"] = Digest::MD5.hexdigest(key)
33
39
  end
34
-
40
+
35
41
  def has_valid_api_cookie?
36
42
  key = session["firefly_session"]
37
43
  key == Digest::MD5.hexdigest(config[:api_key])
38
44
  end
39
-
45
+
40
46
  def validate_api_permission
41
47
  if !has_valid_api_cookie? && params[:api_key] != config[:api_key]
42
48
  status 401
43
49
  return false
44
- else
45
- return true
50
+ else
51
+ return true
46
52
  end
47
53
  end
48
-
54
+
49
55
  def short_url(url)
50
56
  "http://#{config[:hostname]}/#{url.code}"
51
57
  end
52
-
58
+
53
59
  def generate_short_url(url = nil, requested_code = nil)
54
60
  code, result = nil, nil
55
61
 
56
- if !url.nil? && url != ""
62
+ begin
57
63
  ff_url = Firefly::Url.shorten(url, requested_code)
58
- if !ff_url.nil?
59
- code = ff_url.code
60
- result = "http://#{config[:hostname]}/#{code}"
61
- else
62
- code = nil
63
- result = "ERROR: The URL you posted is invalid."
64
- end
64
+ code, result = ff_url.code, "http://#{config[:hostname]}/#{ff_url.code}"
65
+ rescue Firefly::InvalidUrlError
66
+ code, result = nil, "ERROR: The URL you posted is invalid."
67
+ rescue Firefly::InvalidCodeError
68
+ code, result = nil, "ERROR: The code is invalid or already exists."
69
+ rescue
70
+ code, result = nil, "ERROR: An unknown error occured"
65
71
  end
66
-
72
+
67
73
  return code, result
68
74
  end
69
-
75
+
70
76
  def is_highlighted?(url)
71
77
  return false unless @highlight
72
78
  @highlight == url.code
73
79
  end
74
-
80
+
75
81
  # Format a tweet
76
82
  def tweet(url)
77
83
  config[:tweet].gsub('%short_url%', url)
78
84
  end
79
-
85
+
80
86
  def store_api_key(key)
81
87
  if key == config[:api_key]
82
88
  set_api_cookie(config[:api_key])
83
89
  end
84
90
  end
85
91
  end
86
-
92
+
87
93
  before do
88
94
  @authenticated = has_valid_api_cookie?
89
95
  @config = config
90
96
  @highlight = nil
91
- @title = "Firefly &mdash; #{@config[:hostname]}"
97
+ @title = "Firefly at http://#{@config[:hostname]}"
92
98
  end
93
-
99
+
94
100
  get '/' do
95
101
  @highlight = Firefly::Url.first(:code => params[:highlight])
96
102
  @error = params[:highlight] == "error"
@@ -102,12 +108,12 @@ module Firefly
102
108
 
103
109
  haml :index
104
110
  end
105
-
111
+
106
112
  post '/api/set' do
107
113
  store_api_key(params[:api_key])
108
114
  redirect '/'
109
115
  end
110
-
116
+
111
117
  # GET /add?url=http://ariejan.net&api_key=test
112
118
  # POST /add?url=http://ariejan.net&api_key=test
113
119
  #
@@ -117,23 +123,21 @@ module Firefly
117
123
 
118
124
  @url = params[:url]
119
125
  @requested_code = params[:short]
120
- @code, @result = generate_short_url(@url, @requested_code)
121
- invalid = @result =~ /you posted is invalid/
122
- @result ||= "Invalid URL specified."
123
-
126
+ @code, @result = generate_short_url(@url, @requested_code)
127
+ invalid = @code.nil?
128
+
124
129
  if params[:visual]
125
130
  store_api_key(params[:api_key])
126
- @code ||= "error"
127
- redirect "/?highlight=#{@code}"
131
+ @code.nil? ? haml(:error) : redirect("/?highlight=#{@code}")
128
132
  else
129
133
  head 422 if invalid
130
134
  @result
131
135
  end
132
136
  }
133
-
137
+
134
138
  get '/api/add', &api_add
135
139
  post '/api/add', &api_add
136
-
140
+
137
141
  # GET /b3d+
138
142
  #
139
143
  # Show info on the URL
@@ -141,7 +145,7 @@ module Firefly
141
145
  validate_api_permission or return "Permission denied: Invalid API key"
142
146
 
143
147
  @url = Firefly::Url.first(:code => params[:code])
144
-
148
+
145
149
  if @url.nil?
146
150
  status 404
147
151
  "Sorry, that code is unknown."
@@ -150,7 +154,7 @@ module Firefly
150
154
  haml :info
151
155
  end
152
156
  end
153
-
157
+
154
158
  # GET /api/export.csv
155
159
  #
156
160
  # Download a CSV file with all shortened URLs
@@ -217,7 +221,6 @@ module Firefly
217
221
  YAML::dump(output)
218
222
  end
219
223
 
220
-
221
224
  if defined? Barby
222
225
  # GET /b3d.png
223
226
  #
@@ -251,49 +254,54 @@ module Firefly
251
254
  redirect @url.url, 301
252
255
  end
253
256
  end
254
-
257
+
255
258
  def initialize config = {}, &blk
256
259
  super
257
260
  @config = config.is_a?(Config) ? config : Firefly::Config.new(config)
258
261
  @config.instance_eval(&blk) if block_given?
259
-
262
+
260
263
  begin
261
264
  DataMapper.setup(:default, @config[:database])
262
265
  DataMapper.auto_upgrade!
263
266
  check_mysql_collation
267
+ check_code_factory
264
268
  rescue
265
269
  puts "Error setting up database connection. Please check the `database` setting in config.ru"
266
- puts $!
267
- puts "-------"
268
- puts $!.backtrace
270
+ puts $!
271
+ puts "-------"
272
+ puts $!.backtrace
269
273
  exit(1)
270
274
  end
271
275
  end
272
276
 
273
- def check_mysql_collation(first_try = true)
274
- # Make sure the 'code' column is case-sensitive. This hack is for
275
- # MySQL only, other database systems don't have this problem.
276
- if DataMapper.repository(:default).adapter =~ "DataMapper::Adapters::MysqlAdapter"
277
- query = "SHOW FULL COLUMNS FROM firefly_urls WHERE Field='code';"
278
- collation = DataMapper.repository(:default).adapter.select(query)[0][:collation]
279
-
280
- if collation != "utf8_bin"
281
- if first_try
282
- puts " ~ Your MySQL database is not using the 'utf8-bin' collation. Trying to fix..."
283
- DataMapper.repository(:default).adapter.execute("ALTER TABLE firefly_urls MODIFY `code` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin;")
284
- return check_mysql_collation(false)
285
- else
286
- puts " ~ Failed to set the collation for `code` in `firefly_urls`. Please see http://wiki.github.com/ariejan/firefly/faq for details."
287
- return false
288
- end
289
- else
290
- if !first_try
291
- puts " ~ Successfully fixed your database."
292
- end
293
- return true
294
- end
295
- end
296
- end
277
+ def check_code_factory
278
+ Firefly::CodeFactory.first || Firefly::CodeFactory.create(:count => 0)
279
+ end
280
+
281
+ def check_mysql_collation(first_try = true)
282
+ # Make sure the 'code' column is case-sensitive. This hack is for
283
+ # MySQL only, other database systems don't have this problem.
284
+ if DataMapper.repository(:default).adapter =~ "DataMapper::Adapters::MysqlAdapter"
285
+ query = "SHOW FULL COLUMNS FROM firefly_urls WHERE Field='code';"
286
+ collation = DataMapper.repository(:default).adapter.select(query)[0][:collation]
287
+
288
+ if collation != "utf8_bin"
289
+ if first_try
290
+ puts " ~ Your MySQL database is not using the 'utf8-bin' collation. Trying to fix..."
291
+ DataMapper.repository(:default).adapter.execute("ALTER TABLE firefly_urls MODIFY `code` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin;")
292
+ return check_mysql_collation(false)
293
+ else
294
+ puts " ~ Failed to set the collation for `code` in `firefly_urls`. Please see http://wiki.github.com/ariejan/firefly/faq for details."
295
+ return false
296
+ end
297
+ else
298
+ if !first_try
299
+ puts " ~ Successfully fixed your database."
300
+ end
301
+ return true
302
+ end
303
+ end
304
+ end
297
305
  end
298
306
  end
299
307
 
data/lib/firefly/url.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  module Firefly
2
2
  class Url
3
3
  include DataMapper::Resource
4
-
4
+
5
5
  VALID_URL_REGEX = /^(http|https):\/\/[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/ix
6
6
  VALID_CODE_REGEX = /^[a-z0-9\-_]{3,64}$/i
7
7
 
@@ -10,33 +10,47 @@ module Firefly
10
10
  property :code, String, :index => true, :length => 64
11
11
  property :clicks, Integer, :default => 0
12
12
  property :created_at, DateTime, :default => Proc.new{Time.now}
13
-
13
+
14
14
  # Increase the visits counter by 1
15
15
  def register_click!
16
16
  self.update(:clicks => self.clicks + 1)
17
17
  end
18
-
18
+
19
19
  # Shorten a long_url and return a new FireFly::Url
20
20
  def self.shorten(long_url, code = nil)
21
21
  code = nil if code !~ /\S/
22
22
 
23
- return nil unless valid_url?(long_url)
24
- return nil unless valid_code?(code)
23
+ raise Firefly::InvalidUrlError.new unless valid_url?(long_url)
24
+ raise Firefly::InvalidCodeError.new unless valid_code?(code)
25
25
 
26
26
  long_url = normalize_url(long_url)
27
-
27
+
28
28
  the_url = Firefly::Url.first(:url => long_url) || Firefly::Url.create(:url => long_url)
29
- code ||= Firefly::Base62.encode(the_url.id.to_i)
30
- the_url.update(:code => code) if the_url.code.nil?
29
+ return the_url unless the_url.code.nil?
30
+
31
+ code ||= get_me_a_code
32
+ the_url.update(:code => code)
31
33
  the_url
32
- end
33
-
34
+ end
35
+
34
36
  private
37
+
38
+ # Generate a unique code, not already in use.
39
+ def self.get_me_a_code
40
+ code = Firefly::CodeFactory.next_code!
41
+
42
+ if Firefly::Url.count(:code => code) > 0
43
+ code = get_me_a_code
44
+ end
45
+
46
+ code
47
+ end
48
+
35
49
  # Normalize the URL
36
50
  def self.normalize_url(url)
37
51
  URI.parse(URI.escape(url)).normalize.to_s
38
52
  end
39
-
53
+
40
54
  # Validates the URL to be a valid http or https one.
41
55
  def self.valid_url?(url)
42
56
  url.match(Firefly::Url::VALID_URL_REGEX)
@@ -1,3 +1,3 @@
1
1
  module Firefly
2
- Version = File.read(File.join(File.dirname(__FILE__), '..', '..', 'VERSION')).strip
3
- end
2
+ VERSION = "1.3.0"
3
+ end
data/public/style.css CHANGED
@@ -2,7 +2,7 @@ html { background:#efefef; font-family:Arial, Verdana, sans-serif; font-size:13p
2
2
  body { padding:0; margin:0; }
3
3
 
4
4
  .header { background:#000; padding:8px 5% 0 5%; border-bottom:1px solid #444;border-bottom:5px solid #ce1212;}
5
- .header h1 { color:#333; font-size:90%; font-weight:bold; margin-bottom:6px;}
5
+ .header h1 { color:#e0e0e0; font-size:140%; font-weight:bold; margin-bottom:12px; margin-top:6px;}
6
6
  .header ul li { display:inline;}
7
7
  .header ul li a { color:#fff; text-decoration:none; margin-right:10px; display:inline-block; padding:8px; -webkit-border-top-right-radius:6px; -webkit-border-top-left-radius:6px; -moz-border-radius-topleft:6px; -moz-border-radius-topright:6px; }
8
8
  .header ul li a:hover { background:#333;}
@@ -58,7 +58,7 @@ body { padding:0; margin:0; }
58
58
  #footer { padding:10px 5%; background:#efefef; color:#999; font-size:85%; line-height:1.5; border-top:5px solid #ccc; padding-top:10px;}
59
59
  #footer p a { color:#999;}
60
60
 
61
- #main p.poll { background:url(poll.png) no-repeat 0 2px; padding:3px 0; padding-left:23px; float:right; font-size:85%; }
61
+ #main blockquote { background: #efefef; color: #999; line-height: 1.5; margin: 10px; padding: 10px; }
62
62
 
63
63
  #main ul.failed {}
64
64
  #main ul.failed li {background:-webkit-gradient(linear, left top, left bottom, from(#efefef), to(#fff)) #efefef; margin-top:10px; padding:10px; overflow:hidden; -webkit-border-radius:5px; border:1px solid #ccc; }
@@ -90,5 +90,5 @@ body { padding:0; margin:0; }
90
90
  #main table tr td.label a { text-decoration: none; font-size: 11px; }
91
91
  #main table tr td.label a.highlight { color: #f00; }
92
92
 
93
- #main table tr td input.short_url { border: 1px solid #CCC; color: #666; font-family: Monaco, 'Courier New', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace; font-size: 11px; height: 16px; padding: 3px 5px 2px; width: 160px; }
93
+ #main table tr td input.short_url { border: 1px solid #CCC; color: #666; font-family: Monaco, 'Courier New', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace; font-size: 11px; height: 16px; padding: 3px 5px 2px; width: 200px; }
94
94
  #main table tr td img.twitter { border: 0; margin: 0; padding: 0; float: right; width: 16px; height: 16px; }
@@ -6,7 +6,7 @@ describe "API" do
6
6
  def app
7
7
  @@app
8
8
  end
9
-
9
+
10
10
  [:post, :get].each do |verb|
11
11
  describe "adding a URL by #{verb.to_s.upcase}" do
12
12
  it "should be okay adding a new URL" do
@@ -31,58 +31,58 @@ describe "API" do
31
31
 
32
32
  it "should permit including a requested short code" do
33
33
  self.send verb, '/api/add', :url => "http://example.org", :short => 'orz', :api_key => 'test'
34
-
34
+
35
35
  last_response.should be_ok
36
36
  last_response.body.should eql("http://test.host/orz")
37
37
  end
38
38
 
39
- it "should not permit too-short, too-long, or duplicate short codes" do
39
+ it "should not allow the same short code twice" do
40
40
  self.send verb, '/api/add', :url => "http://example.org", :short => 'orz', :api_key => 'test'
41
41
  last_response.should be_ok
42
42
  self.send verb, '/api/add', :url => "http://example.com", :short => 'orz', :api_key => 'test'
43
43
  last_response.should_not be_ok
44
- last_response.body.should match("The URL you posted is invalid")
44
+ end
45
+
46
+ it "should not allow short codes of size < 3" do
45
47
  self.send verb, '/api/add', :url => "http://example.org", :short => 'or', :api_key => 'test'
46
48
  last_response.should_not be_ok
47
- last_response.body.should match("The URL you posted is invalid")
48
- self.send verb, '/api/add', :url => "http://example.org", :short => 'orz' * 37, :api_key => 'test'
49
+ end
50
+
51
+ it "should not allow short codes of size > 64" do
52
+ self.send verb, '/api/add', :url => "http://example.org", :short => 'x' * 65, :api_key => 'test'
49
53
  last_response.should_not be_ok
50
- last_response.body.should match("The URL you posted is invalid")
51
54
  end
52
55
 
53
56
  it "should show an error when shortening an invalid URL" do
54
57
  self.send verb, '/api/add', :url => 'ftp://example.org', :api_key => 'test'
55
-
56
58
  last_response.body.should match("The URL you posted is invalid")
57
59
  end
58
-
60
+
59
61
  it "should show an error when shortening an invalid URL in visual mode" do
60
62
  self.send verb, '/api/add', :url => 'ftp://example.org', :api_key => 'test', :visual => "1"
61
- follow_redirect!
62
-
63
63
  last_response.body.should match("The URL you posted is invalid")
64
64
  end
65
-
65
+
66
66
  it "should redirect to the highlighted URL when visual is enabled" do
67
67
  self.send verb, '/api/add', :url => 'http://example.org/', :api_key => 'test', :visual => "1"
68
68
  follow_redirect!
69
-
69
+
70
70
  last_request.path.should eql("/")
71
71
  last_request.should be_get
72
72
  end
73
-
73
+
74
74
  it "should store the API key in the session with visual enabled" do
75
75
  self.send verb, '/api/add', :url => 'http://example.org/', :api_key => 'test', :visual => "1"
76
- follow_redirect!
77
-
76
+ follow_redirect!
77
+
78
78
  last_response.body.should_not match(/API Key/)
79
79
  end
80
-
80
+
81
81
  it "should highlight the shortened URL" do
82
82
  self.send verb, '/api/add', :url => 'http://example.org/', :api_key => 'test', :visual => "1"
83
83
  url = Firefly::Url.first(:url => "http://example.org/")
84
- follow_redirect!
85
-
84
+ follow_redirect!
85
+
86
86
  last_request.query_string.should match(/highlight=#{url.code}/)
87
87
  end
88
88
 
@@ -91,10 +91,10 @@ describe "API" do
91
91
  last_response.status.should eql(401)
92
92
  end
93
93
 
94
- it "should not return a shortened URL on 401" do
94
+ it "should not return a shortened URL on 401" do
95
95
  self.send verb, '/api/add', :url => 'http://example.org', :api_key => 'false'
96
- last_response.body.should match(/Permission denied: Invalid API key/)
97
- end
96
+ last_response.body.should match(/Permission denied: Invalid API key/)
97
+ end
98
98
 
99
99
  it "should create a new Firefly::Url" do
100
100
  lambda {
@@ -107,42 +107,42 @@ describe "API" do
107
107
 
108
108
  lambda {
109
109
  self.send verb, '/api/add', :url => 'http://example.org', :api_key => 'test'
110
- }.should_not change(Firefly::Url, :count).by(1)
110
+ }.should_not change(Firefly::Url, :count).by(1)
111
111
  end
112
112
  end
113
- end
114
-
113
+ end
114
+
115
115
  describe "getting information" do
116
116
  before(:each) do
117
117
  @created_at = Time.now
118
118
  @url = Firefly::Url.create(:url => 'http://example.com/123', :code => 'alpha', :clicks => 69, :created_at => @created_at)
119
119
  end
120
-
120
+
121
121
  it "should work" do
122
122
  get '/api/info/alpha', :api_key => "test"
123
123
  last_response.should be_ok
124
124
  end
125
-
125
+
126
126
  it "should show the click count" do
127
127
  get '/api/info/alpha', :api_key => "test"
128
128
  last_response.body.should match(/69/)
129
129
  end
130
-
130
+
131
131
  it "should show the short URL" do
132
132
  get '/api/info/alpha', :api_key => "test"
133
133
  last_response.body.should match(/alpha/)
134
134
  end
135
-
135
+
136
136
  it "should show the shortened at time" do
137
137
  get '/api/info/alpha', :api_key => "test"
138
138
  last_response.body.should match(/#{@created_at.strftime("%Y-%m-%d %H:%M")}/)
139
139
  end
140
-
140
+
141
141
  it "should show the full URL" do
142
142
  get '/api/info/alpha', :api_key => "test"
143
143
  last_response.body.should match(/http:\/\/example.com\/123/)
144
144
  end
145
-
145
+
146
146
  it "should validate API permissions" do
147
147
  get '/api/info/alpha', :api_key => false
148
148
  last_response.status.should be(401)
@@ -175,10 +175,10 @@ describe "API" do
175
175
  Firefly::Server.new do
176
176
  set :hostname, "test.host"
177
177
  set :api_key, "test#!"
178
- set :database, "sqlite3://#{Dir.pwd}/firefly_test.sqlite3"
178
+ set :database, "mysql://root@localhost/firefly_test"
179
179
  end
180
180
  end
181
-
181
+
182
182
  it "should be okay adding a new URL" do
183
183
  self.send :get, '/api/add', :url => 'http://example.org/api_key_test', :api_key => 'test#!'
184
184
  last_response.should be_ok