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
24 attr_reader :meta, :expire_at
26 def self.open(path, pass = nil)
27 StoredFile.new(path, pass)
32 yield @initial_content
33 @initial_content = nil
34 until (buf = @file.read(BUFFER_LEN)).nil?
35 yield @cipher.update(buf)
46 def self.create(src, pass, meta)
48 clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
49 "Salt" => Base64.encode64(salt).strip,
50 "Expire-at" => meta.delete('Expire-at') }
51 yield YAML.dump(clear_meta) + YAML_START
53 cipher = get_cipher(pass, salt, :encrypt)
54 yield cipher.update(YAML.dump(meta) + YAML_START)
56 while not (buf = src.read(BUFFER_LEN)).nil?
57 yield cipher.update(buf)
65 CIPHER = 'AES-256-CBC'
68 COQUELICOT_VERSION = "1.0"
70 def self.get_cipher(pass, salt, method)
71 hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(pass, salt, 2000, 48)
72 cipher = OpenSSL::Cipher.new CIPHER
73 cipher.method(method).call
74 cipher.key = hmac[0..31]
75 cipher.iv = hmac[32..-1]
80 OpenSSL::Random::random_bytes(SALT_LEN)
83 def initialize(path, pass)
84 @file = File.open(path)
85 if YAML_START != (buf = @file.read(YAML_START.length)) then
86 raise "unknown file, read #{buf.inspect}"
90 init_decrypt_cipher pass
96 until YAML_START == (line = @file.readline) do
99 @meta = YAML.load(meta)
100 if @meta["Coquelicot"].nil? or @meta["Coquelicot"] != COQUELICOT_VERSION then
103 @expire_at = Time.at(@meta['Expire-at'].to_i)
106 def init_decrypt_cipher(pass)
107 salt = Base64.decode64(@meta["Salt"])
108 @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
113 buf = @file.read(BUFFER_LEN)
114 content = @cipher.update(buf)
115 raise BadKey unless content.start_with? YAML_START
117 block = content.split(YAML_START, 3)
119 if block.length == 3 then
120 @initial_content = block[2]
121 @meta.merge! YAML.load(yaml)
125 until (buf = @file.read(BUFFER_LEN)).nil? do
126 block = @cipher.update(buf).split(YAML_START, 3)
128 break if block.length == 2
130 @initial_content = block[1]
131 @meta.merge! YAML.load(yaml)
135 @cipher.reset unless @cipher.nil?
143 attr_accessor :path, :lockfile_options, :filename_length
145 def add_file(src, pass, options)
148 dst = gen_random_file_name
149 File.open(full_path(dst), 'w').close
152 File.open(full_path(dst), 'w') do |dest|
153 StoredFile.create(src, pass, options) { |data| dest.write data }
156 File.unlink full_path(dst)
159 link = gen_random_file_name
164 def get_file(link, pass)
165 name = read_link(link)
166 return nil if name.nil?
167 StoredFile::open(full_path(name), pass)
170 def file_exists?(link)
171 name = read_link(link)
178 Lockfile.new "#{@path}/.lock", @lockfile_options
185 def add_link(src, dst)
187 File.open(links_path, 'a') do |f|
188 f.write("#{src} #{dst}\n")
196 File.open(links_path, 'r+') do |f|
197 f.readlines.each do |l|
198 links << l unless l.start_with? "#{src} "
210 File.open(links_path) do |f|
213 if line.start_with? "#{src} " then
217 end until line.empty?
223 def gen_random_file_name
225 name = gen_random_base32(@filename_length)
226 end while File.exists?(full_path(name))
231 raise "Wrong name" unless name.each_char.collect { |c| FILENAME_CHARS.include? c }.all?
236 @depot unless @depot.nil?
238 @depot = Depot.instance
239 @depot.path = options.depot_path if @depot.path.nil?
240 @depot.lockfile_options = options.lockfile_options if @depot.lockfile_options.nil?
241 @depot.filename_length = options.filename_length if @depot.filename_length.nil?
245 # Like RFC 4648 (Base32)
246 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)
247 def gen_random_base32(length)
249 OpenSSL::Random::random_bytes(length).each_byte do |i|
250 name << FILENAME_CHARS[i % FILENAME_CHARS.length]
255 gen_random_base32(options.random_pass_length)
258 def password_match?(password)
259 return TRUE if settings.upload_password.nil?
260 (not password.nil?) && Digest::SHA1.hexdigest(password) == settings.upload_password
264 content_type 'text/css', :charset => 'utf-8'
272 get '/ready/:link' do |link|
273 link, pass = link.split '-' if link.include? '-'
274 unless depot.file_exists? link then
277 base = request.url.gsub(/\/ready\/[^\/]*$/, '')
278 @url = "#{base}/#{link}-#{pass}" unless pass.nil?
279 @url ||= "#{base}/#{link}"
284 unless password_match? params[:upload_password] then
287 if params[:file] then
288 tmpfile = params[:file][:tempfile]
289 name = params[:file][:filename]
291 if tmpfile.nil? || name.nil? then
292 @error = "No file selected"
295 if params[:expire].nil? or params[:expire].to_i == 0 then
296 params[:expire] = options.default_expire
298 expire_at = Time.now + 60 * params[:expire].to_i
299 if params[:file_key].nil? or params[:file_key].empty?then
300 pass = gen_random_pass
302 pass = params[:file_key]
304 src = params[:file][:tempfile]
305 link = depot.add_file(
307 { "Expire-at" => expire_at.strftime('%s'),
308 "Filename" => params[:file][:filename],
309 "Length" => src.stat.size,
310 "Content-Type" => params[:file][:type]
312 redirect "ready/#{link}-#{pass}" if params[:file_key].nil? or params[:file_key].empty?
313 redirect "ready/#{link}"
317 throw :halt, [410, haml(:expired)]
320 def send_stored_file(link, pass)
321 file = depot.get_file(link, pass)
322 return false if file.nil?
323 return expired if Time.now > file.expire_at
325 last_modified file.mtime.httpdate
326 attachment file.meta['Filename']
327 response['Content-Length'] = "#{file.meta['Length']}"
328 response['Content-Type'] = file.meta['Content-Type'] || 'application/octet-stream'
329 throw :halt, [200, file]
332 get '/:link' do |link|
334 link, pass = link.split '-'
335 not_found unless send_stored_file(link, pass)
337 not_found unless depot.file_exists? link
342 post '/:link' do |link|
343 pass = params[:file_key]
344 return 403 if pass.nil? or pass.empty?
346 return 403 unless send_stored_file(link, pass)
354 url = request.scheme + "://"
356 if request.scheme == "https" && request.port != 443 ||
357 request.scheme == "http" && request.port != 80
358 url << ":#{request.port}"
360 url << request.script_name
371 %base{ :href => base_href }
372 %link{ :rel => 'stylesheet', :href => "style.css", :type => 'text/css',
373 :media => "screen, projection" }
374 %script{ :type => 'text/javascript', :src => 'javascripts/jquery.min.js' }
375 %script{ :type => 'text/javascript', :src => 'javascripts/jquery.lightBoxFu.js' }
376 %script{ :type => 'text/javascript', :src => 'javascripts/jquery.uploadProgress.js' }
377 %script{ :type => 'text/javascript', :src => 'javascripts/coquelicot.js' }
386 %form#upload{ :enctype => 'multipart/form-data',
387 :action => 'upload', :method => 'post' }
389 %input{ :type => 'file', :name => 'file' }
391 %select{ :name => 'expire' }
392 %option{ :value => 5 } 5 minutes
393 %option{ :value => 60 } 1 hour
394 %option{ :value => 60 * 24 } 1 day
395 %option{ :value => 60 * 24 * 7 } 1 week
396 %option{ :value => 60 * 24 * 30 } 1 month
398 %input{ :type => 'submit', :value => 'Send file' }
403 %a{ :href => @url }= @url
407 %form{ :action => @link, :method => 'post' }
409 %input{ :type => 'text', :id => 'file_key', :name => 'file_key' }
411 %input{ :type => 'submit', :value => 'Get file' }
415 %p Sorry, file has expired.
421 background-color: $green
426 text-decoration: underline
430 background-color: red
432 border: black solid 1px
440 background: url('images/ajax-loader.gif') no-repeat