tg-firefly 0.9.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.
@@ -0,0 +1,25 @@
1
+ # encoding: UTF-8
2
+ module Firefly
3
+ class Base62
4
+
5
+ CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".split('')
6
+ BASE = 62
7
+
8
+ def self.encode(value)
9
+ s = ""
10
+ while value > 0
11
+ value, rem = value.divmod(BASE)
12
+ s << CHARS[rem]
13
+ end
14
+ s.reverse
15
+ end
16
+
17
+ def self.decode(str)
18
+ total = 0
19
+ str.split('').reverse.each_with_index do |v,k|
20
+ total += (CHARS.index(v) * (BASE ** k))
21
+ end
22
+ total
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,14 @@
1
+ # encoding: UTF-8
2
+ module Firefly
3
+ class Click
4
+ include DataMapper::Resource
5
+
6
+ # limit click abuse!
7
+ MAX_CLICKS_PER_IP = 10
8
+
9
+ property :id, Serial
10
+ property :ip, String, :index => true, :length => 20
11
+ property :code, String, :index => true, :length => 64
12
+ property :created_at, DateTime, :default => Proc.new{Time.now}
13
+ end
14
+ end
@@ -0,0 +1,38 @@
1
+ # encoding: UTF-8
2
+ module Firefly
3
+ class CodeFactory
4
+ class << self
5
+ attr_accessor :order
6
+ end
7
+
8
+ include DataMapper::Resource
9
+
10
+ property :id, Serial
11
+ property :count, Integer, :default => 0
12
+
13
+ # Redirect to the selected order method
14
+ def self.next_code!
15
+ self.send("next_#{self.order || :sequential}_code!".to_sym)
16
+ end
17
+
18
+ # Returns the next value randomly
19
+ def self.next_random_code!
20
+ Firefly::Base62.encode(rand(200000000))
21
+ end
22
+
23
+ # Returns the next auto increment value and updates
24
+ # the counter
25
+ def self.next_sequential_code!
26
+ code = nil
27
+
28
+ Firefly::CodeFactory.transaction do
29
+ c = Firefly::CodeFactory.first
30
+ code = Firefly::Base62.encode(c.count + 1)
31
+ c.update(:count => c.count + 1)
32
+ end
33
+
34
+ code
35
+ end
36
+
37
+ end
38
+ end
@@ -0,0 +1,31 @@
1
+ # encoding: UTF-8
2
+ module Firefly
3
+ class Config < Hash
4
+
5
+ DEFAULTS = {
6
+ :hostname => "localhost:3000",
7
+ :api_key => "test",
8
+ :database => "sqlite3://#{Dir.pwd}/firefly_#{ENV['RACK_ENV']}.sqlite3",
9
+ :recent_urls => 25,
10
+ :twitter => "Check this out: %short_url%",
11
+ :hyves_path => "toevoegen/tips",
12
+ :hyves_args => "type=12&rating=5",
13
+ :hyves_title => "Check this out",
14
+ :hyves_body => "Check this out: %short_url%",
15
+ :qr_size => "150x150"
16
+ }
17
+
18
+ def initialize obj
19
+ self.update DEFAULTS
20
+ self.update obj
21
+ end
22
+
23
+ def set key, val = nil, &blk
24
+ if val.is_a? Hash
25
+ self[key].update val
26
+ else
27
+ self[key] = block_given?? blk : val
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,303 @@
1
+ # encoding: UTF-8
2
+ require 'sinatra/base'
3
+ require 'sinatra/reloader'
4
+ require 'haml'
5
+ require 'digest/md5'
6
+
7
+ module Firefly
8
+ class InvalidUrlError < StandardError
9
+ end
10
+
11
+ class InvalidCodeError < StandardError
12
+ end
13
+
14
+ class Server < Sinatra::Base
15
+ configure :development do
16
+ register Sinatra::Reloader
17
+ enable :logging, :dump_errors, :raise_errors
18
+ end
19
+
20
+ enable :sessions
21
+
22
+ dir = File.join(File.dirname(__FILE__), '..', '..')
23
+
24
+ set :root, dir
25
+ set :haml, {:format => :html5 }
26
+ set :session_secret, nil
27
+
28
+ attr_accessor :config
29
+
30
+ helpers do
31
+ include Rack::Utils
32
+ include Firefly::Share
33
+ alias_method :h, :escape_html
34
+
35
+ def url(*path_parts)
36
+ [ path_prefix, path_parts ].join("/").squeeze('/')
37
+ end
38
+ alias_method :u, :url
39
+
40
+ def path_prefix
41
+ request.env['SCRIPT_NAME']
42
+ end
43
+
44
+ def set_api_cookie(key)
45
+ session["firefly_session"] = Digest::MD5.hexdigest(key)
46
+ end
47
+
48
+ # Taken from Rails
49
+ def truncate(text, length, options = {})
50
+ options[:omission] ||= "..."
51
+
52
+ length_with_room_for_omission = length - options[:omission].length
53
+ chars = text
54
+ stop = options[:separator] ?
55
+ (chars.rindex(options[:separator], length_with_room_for_omission) || length_with_room_for_omission) : length_with_room_for_omission
56
+
57
+ (chars.length > length ? chars[0...stop] + options[:omission] : text).to_s
58
+ end
59
+
60
+ def has_valid_api_cookie?
61
+ key = session["firefly_session"]
62
+ key == Digest::MD5.hexdigest(config[:api_key])
63
+ end
64
+
65
+ def has_valid_share_key?
66
+ return false unless @config.has_key?(:sharing_key)
67
+ @config[:sharing_key] == params[:key]
68
+ end
69
+
70
+ def has_valid_share_domain?
71
+ return false unless @config.has_key?(:sharing_domains)
72
+ return true if @config[:sharing_domains].empty?
73
+ @config[:sharing_domains].any? { |domain| params[:url].include?(domain) }
74
+ end
75
+
76
+ def has_valid_share_target?
77
+ return false unless @config.has_key?(:sharing_domains)
78
+ @config[:sharing_targets].include?(params[:target].downcase.to_sym)
79
+ end
80
+
81
+ def validate_share_permission
82
+ if has_valid_share_key? && has_valid_share_domain? && has_valid_share_target?
83
+ return true
84
+ else
85
+ status 401
86
+ return false
87
+ end
88
+ end
89
+
90
+ def validate_api_permission
91
+ true
92
+ # TODO: need api validation?
93
+ # if !has_valid_api_cookie? && params[:api_key] != config[:api_key]
94
+ # status 401
95
+ # return false
96
+ # else
97
+ # return true
98
+ # end
99
+ end
100
+
101
+ def short_url(url)
102
+ "http://#{config[:hostname]}/#{url.code}"
103
+ end
104
+
105
+ def generate_short_url(url = nil, user_id = nil, requested_code = nil)
106
+ code, result = nil, nil
107
+
108
+ begin
109
+ ff_url = Firefly::Url.shorten(url, user_id, requested_code)
110
+ code, result = ff_url.code, "http://#{config[:hostname]}/#{ff_url.code}"
111
+ rescue Firefly::InvalidUrlError
112
+ code, result = nil, "ERROR: The URL you posted is invalid."
113
+ rescue Firefly::InvalidCodeError
114
+ code, result = nil, "ERROR: The code is invalid or already exists."
115
+ rescue
116
+ code, result = nil, "ERROR: An unknown error occured"
117
+ end
118
+
119
+ return code, result
120
+ end
121
+
122
+ def is_highlighted?(url)
123
+ return false unless @highlight
124
+ @highlight.code == url.code
125
+ end
126
+
127
+ def store_api_key(key)
128
+ if key == config[:api_key]
129
+ set_api_cookie(config[:api_key])
130
+ end
131
+ end
132
+ end
133
+
134
+ before do
135
+ @authenticated = has_valid_api_cookie?
136
+ @config = config
137
+ @highlight = nil
138
+ @title = "Firefly at http://#{@config[:hostname]}"
139
+
140
+ set :session_secret, @config[:session_secret]
141
+ end
142
+
143
+ get '/' do
144
+ @highlight = Firefly::Url.first(:code => params[:highlight]) if not params[:highlight].nil?
145
+ @error = params[:highlight] == "error"
146
+
147
+ sort_column = params[:s] || 'created_at'
148
+ sort_order = params[:d] || 'desc'
149
+
150
+ @urls = Firefly::Url.all(:limit => config[:recent_urls], :order => [ sort_column.to_sym.send(sort_order.to_sym) ] )
151
+
152
+ haml :index
153
+ end
154
+
155
+ post '/api/set' do
156
+ store_api_key(params[:api_key])
157
+ redirect '/'
158
+ end
159
+
160
+ # GET /add?url=http://ariejan.net&api_key=test&user_id=42
161
+ # POST /add?url=http://ariejan.net&api_key=test&user_id=42
162
+ #
163
+ # Returns the shortened URL
164
+ api_add = lambda {
165
+ validate_api_permission or return "Permission denied: Invalid API key"
166
+
167
+ @url = params[:url]
168
+ @requested_code = params[:short]
169
+ @user_id = params[:user_id]
170
+ @code, @result = generate_short_url(@url, @user_id, @requested_code)
171
+ invalid = @code.nil?
172
+
173
+ if params[:visual]
174
+ store_api_key(params[:api_key])
175
+ @code.nil? ? haml(:error) : redirect("/?highlight=#{@code}")
176
+ else
177
+ head 422 if invalid
178
+ @result
179
+ end
180
+ }
181
+
182
+ get '/api/add', &api_add
183
+ post '/api/add', &api_add
184
+
185
+ api_share = lambda {
186
+ validate_share_permission or return "Cannot share that URL."
187
+
188
+ @url = params[:url]
189
+ @code, @result = generate_short_url(@url, nil)
190
+ invalid = @code.nil?
191
+
192
+ params[:title] ||= ""
193
+ title = URI.unescape(params[:title])
194
+
195
+ case (params[:target].downcase.to_sym)
196
+ when :twitter
197
+ redirect(twitter("http://#{config[:hostname]}/#{@code}", title))
198
+ when :hyves
199
+ redirect(hyves("http://#{config[:hostname]}/#{@code}", title))
200
+ when :facebook
201
+ redirect(facebook("http://#{config[:hostname]}/#{@code}"))
202
+ end
203
+ }
204
+
205
+ get '/api/share', &api_share
206
+ post '/api/share', &api_share
207
+
208
+ # GET /api/info/b3d
209
+ #
210
+ # Show info on the URL
211
+ get '/api/info/:code' do
212
+ validate_api_permission or return "Permission denied: Invalid API key"
213
+
214
+ @url = Firefly::Url.first(:code => params[:code])
215
+
216
+ if @url.nil?
217
+ status 404
218
+ "Sorry, that code is unknown."
219
+ else
220
+ @short_url = "http://#{config[:hostname]}/#{@url.code}"
221
+ haml :info
222
+ end
223
+ end
224
+
225
+ # GET /b3d.png
226
+ #
227
+ # Return a QR code image
228
+ get '/:code.png' do
229
+ @url = Firefly::Url.first(:code => params[:code])
230
+
231
+ if @url.nil?
232
+ status 404
233
+ "Sorry, that code is unknown."
234
+ else
235
+ redirect("http://chart.googleapis.com/chart?cht=qr&chl=#{URI.escape(short_url(@url), Regexp.new("[^#{URI::PATTERN::UNRESERVED}]"))}&chs=#{config[:qr_size]}")
236
+ end
237
+ end
238
+
239
+ # GET /b3d
240
+ #
241
+ # Redirect to the shortened URL
242
+ get '/:code' do
243
+ @url = Firefly::Url.first(:code => params[:code])
244
+
245
+ if @url.nil?
246
+ status 404
247
+ "Sorry, that code is unknown."
248
+ else
249
+ @url.register_click!(request.ip)
250
+ redirect @url.url, 301
251
+ end
252
+ end
253
+
254
+ def initialize config = {}, &blk
255
+ super
256
+ @config = config.is_a?(Config) ? config : Firefly::Config.new(config)
257
+ @config.instance_eval(&blk) if block_given?
258
+ Firefly::CodeFactory.order = @config[:order]
259
+ begin
260
+ DataMapper.setup(:default, @config[:database])
261
+ DataMapper.auto_upgrade!
262
+ check_mysql_collation
263
+ check_code_factory
264
+ rescue
265
+ puts "Error setting up database connection. Please check the `database` setting in config.ru"
266
+ puts $!
267
+ puts "-------"
268
+ puts $!.backtrace
269
+ exit(1)
270
+ end
271
+ end
272
+
273
+ def check_code_factory
274
+ Firefly::CodeFactory.first || Firefly::CodeFactory.create(:count => 0)
275
+ end
276
+
277
+ def check_mysql_collation(first_try = true)
278
+ # Make sure the 'code' column is case-sensitive. This hack is for
279
+ # MySQL only, other database systems don't have this problem.
280
+ if DataMapper.repository(:default).adapter =~ "DataMapper::Adapters::MysqlAdapter"
281
+ query = "SHOW FULL COLUMNS FROM firefly_urls WHERE Field='code';"
282
+ collation = DataMapper.repository(:default).adapter.select(query)[0][:collation]
283
+
284
+ if collation != "utf8_bin"
285
+ if first_try
286
+ puts " ~ Your MySQL database is not using the 'utf8-bin' collation. Trying to fix..."
287
+ DataMapper.repository(:default).adapter.execute("ALTER TABLE firefly_urls MODIFY `code` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin;")
288
+ return check_mysql_collation(false)
289
+ else
290
+ puts " ~ Failed to set the collation for `code` in `firefly_urls`. Please see http://wiki.github.com/ariejan/firefly/faq for details."
291
+ return false
292
+ end
293
+ else
294
+ if !first_try
295
+ puts " ~ Successfully fixed your database."
296
+ end
297
+ return true
298
+ end
299
+ end
300
+ end
301
+ end
302
+ end
303
+
@@ -0,0 +1,29 @@
1
+ module Firefly
2
+ module Share
3
+ def twitter(url, message = nil)
4
+ status = if message.nil? || message == ""
5
+ config[:twitter].gsub('%short_url%', url)
6
+ else
7
+ max_length = 140-1-url.size
8
+ [message.strip.slice(0...max_length), url].join(' ')
9
+ end
10
+ "http://twitter.com/home?status=#{URI.escape(status)}"
11
+ end
12
+
13
+ def hyves(url, title = nil, body = nil)
14
+ if title.nil? || title == ""
15
+ title = config[:hyves_title]
16
+ end
17
+
18
+ if body.nil? || body == ""
19
+ body = config[:hyves_body].gsub('%short_url%', url)
20
+ end
21
+
22
+ "http://www.hyves.nl/profielbeheer/#{config[:hyves_path]}/?name=#{URI.escape(title.strip)}&text=#{URI.escape(body.strip)}&#{config[:hyves_args]}"
23
+ end
24
+
25
+ def facebook(url)
26
+ "http://www.facebook.com/share.php?u=#{URI.escape(url)}"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,69 @@
1
+ # encoding: UTF-8
2
+ module Firefly
3
+ class Url
4
+ include DataMapper::Resource
5
+
6
+ VALID_CODE_REGEX = /^[A-Za-z0-9\-_]{3,64}$/u # case-insensitive, UTF-8 encoded
7
+
8
+ property :id, Serial
9
+ property :url, String, :index => true, :length => 255
10
+ property :code, String, :index => true, :length => 64
11
+ property :clicks, Integer, :default => 0
12
+ property :created_at, DateTime, :default => Proc.new{Time.now}
13
+ property :user_id, String, :index => true, :length => 50
14
+ # Increase the visits counter by 1
15
+ def register_click!(ip)
16
+ Firefly::Click.create(:code => code, :ip => ip)
17
+ if Firefly::Click.count(:code => code, :ip => ip) <= Firefly::Click::MAX_CLICKS_PER_IP
18
+ self.update(:clicks => self.clicks + 1)
19
+ end
20
+ end
21
+
22
+ # Shorten a long_url and return a new FireFly::Url
23
+ def self.shorten(long_url, user_id, code = nil)
24
+ code = nil if code !~ /\S/
25
+
26
+ raise Firefly::InvalidUrlError.new unless valid_url?(long_url)
27
+ raise Firefly::InvalidCodeError.new unless valid_code?(code)
28
+
29
+ long_url = normalize_url(long_url)
30
+
31
+ the_url = Firefly::Url.first(:url => long_url, :user_id => user_id) || Firefly::Url.create(:url => long_url, :user_id => user_id)
32
+ return the_url unless the_url.code.nil?
33
+
34
+ code ||= get_me_a_code
35
+ the_url.update(:code => code)
36
+ the_url
37
+ end
38
+
39
+ private
40
+
41
+ # Generate a unique code, not already in use.
42
+ def self.get_me_a_code
43
+ code = Firefly::CodeFactory.next_code!
44
+
45
+ if Firefly::Url.count(:code => code) > 0
46
+ code = get_me_a_code
47
+ end
48
+
49
+ code
50
+ end
51
+
52
+ # Normalize the URL
53
+ def self.normalize_url(url)
54
+ url = URI.escape(URI.unescape(url))
55
+ URI.parse(url).normalize.to_s
56
+ end
57
+
58
+ # Validates the URL to be a valid http or https one.
59
+ def self.valid_url?(url)
60
+ url.match URI.regexp(['http', 'https'])
61
+ end
62
+
63
+ def self.valid_code?(code)
64
+ return true if code.nil?
65
+ code.match(Firefly::Url::VALID_CODE_REGEX) && Firefly::Url.count(:code => code) == 0
66
+ end
67
+
68
+ end
69
+ end