10 enable :inline_templates
12 set :upload_password, '0e5f7d398e6f9cd1f6bac5cc823e363aec636495'
13 set :default_expire, 60 # 1 hour
14 set :filename_length, 20
15 set :random_pass_length, 16
16 set :lockfile_options, { :timeout => 60,
21 class BadKey < StandardError; end
26 attr_reader :meta, :expire_at
28 def self.open(path, pass = nil)
29 StoredFile.new(path, pass)
34 yield @initial_content
35 @initial_content = nil
36 until (buf = @file.read(BUFFER_LEN)).nil?
37 yield @cipher.update(buf)
48 def self.create(src, pass, meta)
50 clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
51 "Salt" => Base64.encode64(salt).strip,
52 "Expire-at" => meta.delete('Expire-at') }
53 yield YAML.dump(clear_meta) + YAML_START
55 cipher = get_cipher(pass, salt, :encrypt)
56 yield cipher.update(YAML.dump(meta) + YAML_START)
58 while not (buf = src.read(BUFFER_LEN)).nil?
59 yield cipher.update(buf)
67 CIPHER = 'AES-256-CBC'
69 COQUELICOT_VERSION = "1.0"
71 def self.get_cipher(pass, salt, method)
72 hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(pass, salt, 2000, 48)
73 cipher = OpenSSL::Cipher.new CIPHER
74 cipher.method(method).call
75 cipher.key = hmac[0..31]
76 cipher.iv = hmac[32..-1]
81 OpenSSL::Random::random_bytes(SALT_LEN)
84 def initialize(path, pass)
85 @file = File.open(path)
86 if YAML_START != (buf = @file.read(YAML_START.length)) then
87 raise "unknown file, read #{buf.inspect}"
91 init_decrypt_cipher pass
97 until YAML_START == (line = @file.readline) do
100 @meta = YAML.load(meta)
101 if @meta["Coquelicot"].nil? or @meta["Coquelicot"] != COQUELICOT_VERSION then
104 @expire_at = Time.at(@meta['Expire-at'].to_i)
107 def init_decrypt_cipher(pass)
108 salt = Base64.decode64(@meta["Salt"])
109 @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
114 buf = @file.read(BUFFER_LEN)
115 content = @cipher.update(buf)
116 raise BadKey unless content.start_with? YAML_START
118 block = content.split(YAML_START, 3)
120 if block.length == 3 then
121 @initial_content = block[2]
122 @meta.merge! YAML.load(yaml)
126 until (buf = @file.read(BUFFER_LEN)).nil? do
127 block = @cipher.update(buf).split(YAML_START, 3)
129 break if block.length == 2
131 @initial_content = block[1]
132 @meta.merge! YAML.load(yaml)
136 @cipher.reset unless @cipher.nil?
144 attr_accessor :path, :lockfile_options, :filename_length
146 def add_file(src, pass, options)
149 dst = gen_random_file_name
150 File.open(full_path(dst), 'w').close
153 File.open(full_path(dst), 'w') do |dest|
154 StoredFile.create(src, pass, options) { |data| dest.write data }
157 File.unlink full_path(dst)
160 link = gen_random_file_name
165 def get_file(link, pass)
166 name = read_link(link)
167 return nil if name.nil?
168 StoredFile::open(full_path(name), pass)
171 def file_exists?(link)
172 name = read_link(link)
178 remove_file(name) if Time.now > StoredFile::open(full_path(name)).expire_at
185 Lockfile.new "#{@path}/.lock", @lockfile_options
192 def add_link(src, dst)
194 File.open(links_path, 'a') do |f|
195 f.write("#{src} #{dst}\n")
200 def remove_from_links(&block)
203 File.open(links_path, 'r+') do |f|
204 f.readlines.each do |l|
205 links << l unless yield l
215 remove_from_links { |l| l.start_with? "#{src} " }
221 File.open(links_path) do |f|
224 if line.start_with? "#{src} " then
228 end until line.empty?
234 def remove_file(name)
235 # zero the content before unlinking
236 File.open(full_path(name), 'r+') do |f|
237 f.seek 0, IO::SEEK_END
241 write_len = [StoredFile::BUFFER_LEN, length].min
242 length -= f.write("\0" * write_len)
245 File.unlink full_path(name)
246 remove_from_links { |l| l.end_with? " #{name}" }
251 File.open(links_path) do |f|
252 f.readlines.collect { |l| l.split[1] }
257 def gen_random_file_name
259 name = gen_random_base32(@filename_length)
260 end while File.exists?(full_path(name))
265 raise "Wrong name" unless name.each_char.collect { |c| FILENAME_CHARS.include? c }.all?
270 @depot unless @depot.nil?
272 @depot = Depot.instance
273 @depot.path = options.depot_path if @depot.path.nil?
274 @depot.lockfile_options = options.lockfile_options if @depot.lockfile_options.nil?
275 @depot.filename_length = options.filename_length if @depot.filename_length.nil?
279 # Like RFC 4648 (Base32)
280 FILENAME_CHARS = %w(a b c d e f g h i j k l m n o p q r s t u v w x y z 2 3 4 5 6 7)
281 def gen_random_base32(length)
283 OpenSSL::Random::random_bytes(length).each_byte do |i|
284 name << FILENAME_CHARS[i % FILENAME_CHARS.length]
289 gen_random_base32(options.random_pass_length)
291 def remap_base32_extra_characters(str)
293 FILENAME_CHARS.each { |c| map[c] = c; map[c.upcase] = c }
294 map.merge!({ '1' => 'l', '0' => 'o' })
296 str.each_char { |c| result << map[c] }
300 def password_match?(password)
301 return TRUE if settings.upload_password.nil?
302 (not password.nil?) && Digest::SHA1.hexdigest(password) == settings.upload_password
306 content_type 'text/css', :charset => 'utf-8'
314 get '/random_pass' do
318 get '/ready/:link' do |link|
319 link, pass = link.split '-' if link.include? '-'
321 file = depot.get_file(link, nil)
322 rescue Errno::ENOENT => ex
325 @expire_at = file.expire_at
326 @base = request.url.gsub(/\/ready\/[^\/]*$/, '')
332 @url = "#{@base}/#{@name}"
337 unless password_match? params[:upload_password] then
340 if params[:file] then
341 tmpfile = params[:file][:tempfile]
342 name = params[:file][:filename]
344 if tmpfile.nil? || name.nil? then
345 @error = "No file selected"
348 if params[:expire].nil? or params[:expire].to_i == 0 then
349 params[:expire] = options.default_expire
351 expire_at = Time.now + 60 * params[:expire].to_i
352 if params[:file_key].nil? or params[:file_key].empty?then
353 pass = gen_random_pass
355 pass = params[:file_key]
357 src = params[:file][:tempfile]
358 link = depot.add_file(
360 { "Expire-at" => expire_at.strftime('%s'),
361 "Filename" => params[:file][:filename],
362 "Length" => src.stat.size,
363 "Content-Type" => params[:file][:type]
365 redirect "ready/#{link}-#{pass}" if params[:file_key].nil? or params[:file_key].empty?
366 redirect "ready/#{link}"
370 throw :halt, [410, haml(:expired)]
373 def send_stored_file(link, pass)
374 file = depot.get_file(link, pass)
375 return false if file.nil?
376 return expired if Time.now > file.expire_at
378 last_modified file.mtime.httpdate
379 attachment file.meta['Filename']
380 response['Content-Length'] = "#{file.meta['Length']}"
381 response['Content-Type'] = file.meta['Content-Type'] || 'application/octet-stream'
382 throw :halt, [200, file]
385 get '/:link-:pass' do |link, pass|
386 link = remap_base32_extra_characters(link)
387 pass = remap_base32_extra_characters(pass)
388 not_found unless send_stored_file(link, pass)
391 get '/:link' do |link|
392 link = remap_base32_extra_characters(link)
393 not_found unless depot.file_exists? link
398 post '/:link' do |link|
399 pass = params[:file_key]
400 return 403 if pass.nil? or pass.empty?
402 return 403 unless send_stored_file(link, pass)
410 url = request.scheme + "://"
412 if request.scheme == "https" && request.port != 443 ||
413 request.scheme == "http" && request.port != 80
414 url << ":#{request.port}"
416 url << request.script_name
429 %base{ :href => base_href }
430 %link{ :rel => 'stylesheet', :href => "style.css", :type => 'text/css',
431 :media => "screen, projection" }
432 %script{ :type => 'text/javascript', :src => 'javascripts/jquery.min.js' }
433 %script{ :type => 'text/javascript', :src => 'javascripts/jquery.lightBoxFu.js' }
434 %script{ :type => 'text/javascript', :src => 'javascripts/jquery.uploadProgress.js' }
436 var generateRandomPassword = 'Generate random';
437 var generatingRandomPassword = 'Generating…';
438 %script{ :type => 'text/javascript', :src => 'javascripts/coquelicot.js' }
443 %span Coquelicot © 2010 potager.org
446 %a{ :href => 'http://www.gnu.org/licenses/agpl.txt' } AGPLv3
449 %code git clone #{base_href}coquelicot.git
455 %form#upload{ :enctype => 'multipart/form-data',
456 :action => 'upload', :method => 'post' }
458 %label{ :for => 'upload_password' } Upload password:
459 %input.input{ :type => 'password', :id => 'upload_password', :name => 'upload_password' }
461 %label{ :for => 'file' } File:
462 %input.input{ :type => 'file', id => 'file', :name => 'file' }
464 %label{ :for => 'expire' } Available for:
465 %select.input{ :id => 'expire',:name => 'expire' }
466 %option{ :value => 5 } 5 minutes
467 %option{ :value => 60 } 1 hour
468 %option{ :value => 60 * 24 } 1 day
469 %option{ :value => 60 * 24 * 7 } 1 week
470 %option{ :value => 60 * 24 * 30 } 1 month
472 %label{ :for => 'file_key' } Download password:
473 %input.input{ :type => 'password', :id => 'file_key', :name => 'file_key' }
476 %input.submit{ :type => 'submit', :value => 'Share!' }
483 %span.base> #{@base}/
485 - unless @unprotected
486 %p A password is required to download this file.
487 %p The file will be available until #{@expire_at}.
489 %a{ :href => base_href } Share another file…
492 %h1 Enter download password…
494 %form{ :action => @link, :method => 'post' }
496 %label{ :for => 'file_key' } Password:
497 %input{ :type => 'text', :id => 'file_key', :name => 'file_key' }
500 %input{ :type => 'submit', :value => 'Get file' }
505 %p Sorry, file has expired.
511 background-color: $green
516 text-decoration: underline
520 background-color: red
522 border: black solid 1px
526 border-bottom: solid 1px #ccc
532 -moz-border-radius: 25px
533 -webkit-border-radius: 25px
535 border: solid 1px black
542 text-decoration: none
568 font-family: monospace
587 background: url('images/ajax-loader.gif') no-repeat
594 border-top: dashed 1px black