enable :inline_templates
set :upload_password, '0e5f7d398e6f9cd1f6bac5cc823e363aec636495'
+set :default_expire, 60 # 1 hour
set :filename_length, 20
set :random_pass_length, 16
set :lockfile_options, { :timeout => 60,
class BadKey < StandardError; end
class StoredFile
- attr_reader :meta
+ BUFFER_LEN = 4096
+
+ attr_reader :meta, :expire_at
- def self.open(path, pass)
+ def self.open(path, pass = nil)
StoredFile.new(path, pass)
end
def self.create(src, pass, meta)
salt = gen_salt
clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
- "Salt" => Base64.encode64(salt).strip }
+ "Salt" => Base64.encode64(salt).strip,
+ "Expire-at" => meta.delete('Expire-at') }
yield YAML.dump(clear_meta) + YAML_START
cipher = get_cipher(pass, salt, :encrypt)
YAML_START = "--- \n"
CIPHER = 'AES-256-CBC'
SALT_LEN = 8
- BUFFER_LEN = 4096
COQUELICOT_VERSION = "1.0"
def self.get_cipher(pass, salt, method)
raise "unknown file, read #{buf.inspect}"
end
parse_clear_meta
+ return if pass.nil?
init_decrypt_cipher pass
parse_meta
end
if @meta["Coquelicot"].nil? or @meta["Coquelicot"] != COQUELICOT_VERSION then
raise "unknown file"
end
+ @expire_at = Time.at(@meta['Expire-at'])
end
def init_decrypt_cipher(pass)
return !name.nil?
end
+ def gc!
+ files.each do |name|
+ remove_file(name) if Time.now > StoredFile::open(full_path(name)).expire_at
+ end
+ end
+
private
def lockfile
end
end
- def remove_link(src)
+ def remove_from_links(&block)
lockfile.lock do
links = []
File.open(links_path, 'r+') do |f|
f.readlines.each do |l|
- links << l unless l.start_with? "#{src} "
+ links << l unless yield l
end
f.rewind
f.truncate(0)
end
end
+ def remove_link(src)
+ remove_from_links { |l| l.start_with? "#{src} " }
+ end
+
def read_link(src)
dst = nil
lockfile.lock do
dst
end
+ def remove_file(name)
+ # zero the content before unlinking
+ File.open(full_path(name), 'r+') do |f|
+ f.seek 0, IO::SEEK_END
+ length = f.tell
+ f.rewind
+ while length > 0 do
+ write_len = [StoredFile::BUFFER_LEN, length].min
+ length -= f.write("\0" * write_len)
+ end
+ end
+ File.unlink full_path(name)
+ remove_from_links { |l| l.end_with? " #{name}" }
+ end
+
+ def files
+ lockfile.lock do
+ File.open(links_path) do |f|
+ f.readlines.collect { |l| l.split[1] }
+ end
+ end
+ end
+
def gen_random_file_name
begin
name = gen_random_base32(@filename_length)
def gen_random_pass
gen_random_base32(options.random_pass_length)
end
+def remap_base32_extra_characters(str)
+ map = {}
+ FILENAME_CHARS.each { |c| map[c] = c; map[c.upcase] = c }
+ map.merge!({ '1' => 'l', '0' => 'o' })
+ result = ''
+ str.each_char { |c| result << map[c] }
+ result
+end
def password_match?(password)
return TRUE if settings.upload_password.nil?
haml :index
end
+get '/random_pass' do
+ "#{gen_random_pass}"
+end
+
get '/ready/:link' do |link|
link, pass = link.split '-' if link.include? '-'
- unless depot.file_exists? link then
+ begin
+ file = depot.get_file(link, nil)
+ rescue Errno::ENOENT => ex
not_found
end
- base = request.url.gsub(/\/ready\/[^\/]*$/, '')
- @url = "#{base}/#{link}-#{pass}" unless pass.nil?
- @url ||= "#{base}/#{link}"
+ @expire_at = file.expire_at
+ @base = request.url.gsub(/\/ready\/[^\/]*$/, '')
+ @name = "#{link}"
+ unless pass.nil?
+ @name << "-#{pass}"
+ @unprotected = true
+ end
+ @url = "#{@base}/#{@name}"
haml :ready
end
@error = "No file selected"
return haml(:index)
end
+ if params[:expire].nil? or params[:expire].to_i == 0 then
+ params[:expire] = options.default_expire
+ end
+ expire_at = Time.now + 60 * params[:expire].to_i
if params[:file_key].nil? or params[:file_key].empty?then
pass = gen_random_pass
else
src = params[:file][:tempfile]
link = depot.add_file(
src, pass,
- { "Filename" => params[:file][:filename],
+ { "Expire-at" => expire_at.to_i,
+ "Filename" => params[:file][:filename],
"Length" => src.stat.size,
"Content-Type" => params[:file][:type]
})
redirect "ready/#{link}"
end
+def expired
+ throw :halt, [410, haml(:expired)]
+end
+
def send_stored_file(link, pass)
file = depot.get_file(link, pass)
return false if file.nil?
+ return expired if Time.now > file.expire_at
last_modified file.mtime.httpdate
attachment file.meta['Filename']
throw :halt, [200, file]
end
+get '/:link-:pass' do |link, pass|
+ link = remap_base32_extra_characters(link)
+ pass = remap_base32_extra_characters(pass)
+ not_found unless send_stored_file(link, pass)
+end
+
get '/:link' do |link|
- if link.include? '-'
- link, pass = link.split '-'
- not_found unless send_stored_file(link, pass)
- end
+ link = remap_base32_extra_characters(link)
not_found unless depot.file_exists? link
@link = link
haml :enter_file_key
__END__
@@ layout
+!!! XML
+!!! Strict
%html
%head
%title coquelicot
%script{ :type => 'text/javascript', :src => 'javascripts/jquery.min.js' }
%script{ :type => 'text/javascript', :src => 'javascripts/jquery.lightBoxFu.js' }
%script{ :type => 'text/javascript', :src => 'javascripts/jquery.uploadProgress.js' }
+ :javascript
+ var generateRandomPassword = 'Generate random';
+ var generatingRandomPassword = 'Generating…';
%script{ :type => 'text/javascript', :src => 'javascripts/coquelicot.js' }
%body
#container
= yield
+ #footer
+ %span Coquelicot © 2010 potager.org
+ %span
+ —
+ %a{ :href => 'http://www.gnu.org/licenses/agpl.txt' } AGPLv3
+ —
+ %span
+ %code git clone #{base_href}coquelicot.git
@@ index
-%h1 Upload!
+%h1 Share a file!
- unless @error.nil?
.error= @error
%form#upload{ :enctype => 'multipart/form-data',
:action => 'upload', :method => 'post' }
.field
- %input{ :type => 'file', :name => 'file' }
+ %label{ :for => 'upload_password' } Upload password:
+ %input.input{ :type => 'password', :id => 'upload_password', :name => 'upload_password' }
+ .field
+ %label{ :for => 'file' } File:
+ %input.input{ :type => 'file', id => 'file', :name => 'file' }
+ .field
+ %label{ :for => 'expire' } Available for:
+ %select.input{ :id => 'expire',:name => 'expire' }
+ %option{ :value => 5 } 5 minutes
+ %option{ :value => 60 } 1 hour
+ %option{ :value => 60 * 24 } 1 day
+ %option{ :value => 60 * 24 * 7 } 1 week
+ %option{ :value => 60 * 24 * 30 } 1 month
+ .field
+ %label{ :for => 'file_key' } Download password:
+ %input.input{ :type => 'password', :id => 'file_key', :name => 'file_key' }
.field
- %input{ :type => 'submit', :value => 'Send file' }
+ .submit
+ %input.submit{ :type => 'submit', :value => 'Share!' }
@@ ready
%h1 Pass this on!
-.url
- %a{ :href => @url }= @url
+#content
+ .url
+ %a{ :href => @url }
+ %span.base> #{@base}/
+ %span.name= @name
+ - unless @unprotected
+ %p A password is required to download this file.
+ %p The file will be available until #{@expire_at}.
+ .again
+ %a{ :href => base_href } Share another file…
@@ enter_file_key
-%h1 Enter file key…
-%form{ :action => @link, :method => 'post' }
- .field
- %input{ :type => 'text', :id => 'file_key', :name => 'file_key' }
- .field
- %input{ :type => 'submit', :value => 'Get file' }
+%h1 Enter download password…
+#content
+ %form{ :action => @link, :method => 'post' }
+ .field
+ %label{ :for => 'file_key' } Password:
+ %input{ :type => 'text', :id => 'file_key', :name => 'file_key' }
+ .field
+ .submit
+ %input{ :type => 'submit', :value => 'Get file' }
+
+@@ expired
+%h1 Too late…
+#content
+ %p Sorry, file has expired.
@@ style
$green: #00ff26
a, a:visited
text-decoration: underline
- color: white
+ color: blue
.error
background-color: red
color: white
border: black solid 1px
+h1
+ margin-top: 0.1ex
+ border-bottom: solid 1px #ccc
+ text-align: center
+
+#container
+ width: 550px
+ margin: 2em auto
+ -moz-border-radius: 25px
+ -webkit-border-radius: 25px
+ background: white
+ border: solid 1px black
+ padding: 5px 25px
+
+.url
+ text-align: center
+
+.url a
+ text-decoration: none
+ color: black
+
+.url .base
+ display: block
+ font-size: small
+
+.url .name
+ display: block
+ font-size: x-large
+ white-space: nowrap
+
+.again
+ margin-top: 1ex
+ text-align: right
+
+.field label
+ float: left
+ width: 12em
+ text-align: right
+
+.input, .random-pass
+ float: left
+ width: 15em
+
+.random-pass
+ font-family: monospace
+ font-size: large
+ color: black
+
+#gen_pass
+ font-size: small
+
+.field
+ clear: left
+
+.submit
+ text-align: center
+
#progress
margin: 8px
width: 220px
background: url('images/ajax-loader.gif') no-repeat
width: 0px
height: 19px
+
+#footer
+ margin-top: 7em
+ padding-top: 1em
+ border-top: dashed 1px black
+ text-align: center
+ font-size: small