store 'Expire-at' as integer instead of string
[coquelicot.git] / coquelicot.rb
index e99a953..d71bd7a 100644 (file)
@@ -3,14 +3,29 @@ require 'haml'
 require 'digest/sha1'
 require 'base64'
 require 'openssl'
+require 'yaml'
+require 'lockfile'
 require 'singleton'
 
 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,
+                         :max_age => 8,
+                         :refresh => 2,
+                         :debug   => false }
+
+class BadKey < StandardError; end
 
 class StoredFile
-  def self.open(path, pass)
+  BUFFER_LEN = 4096
+
+  attr_reader :meta, :expire_at
+
+  def self.open(path, pass = nil)
     StoredFile.new(path, pass)
   end
 
@@ -18,7 +33,7 @@ class StoredFile
     # output content
     yield @initial_content
     @initial_content = nil
-    while "" != (buf = @file.read(BUFFER_LEN))
+    until (buf = @file.read(BUFFER_LEN)).nil?
       yield @cipher.update(buf)
     end
     yield @cipher.final
@@ -26,78 +41,95 @@ class StoredFile
     @cipher = nil
   end
 
-  def self.create(path, pass, meta, content)
-    File.new(path, 'w') do |file|
-      salt = gen_salt
-      clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
-                     "Salt" => Base64.encode64(salt).strip }
-      YAML.dump(clear_meta, file)
-      file.write YAML_START
+  def mtime
+    @file.mtime
+  end
 
-      cipher = get_cipher(pass, salt, :encrypt)
-      file << cipher.update(YAML.dump(meta) + YAML_START)
-      while '' != (buf = content.read(BUFFER_LEN)) do
-        file << cipher.update(buf)
-      end
-      file << cipher.final
+  def self.create(src, pass, meta)
+    salt = gen_salt
+    clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
+                   "Salt" => Base64.encode64(salt).strip,
+                   "Expire-at" => meta.delete('Expire-at') }
+    yield YAML.dump(clear_meta) + YAML_START
+
+    cipher = get_cipher(pass, salt, :encrypt)
+    yield cipher.update(YAML.dump(meta) + YAML_START)
+    src.rewind
+    while not (buf = src.read(BUFFER_LEN)).nil?
+      yield cipher.update(buf)
     end
+    yield cipher.final
   end
 
 private
 
-  YAML_START = "---\n"
+  YAML_START = "--- \n"
   CIPHER = 'AES-256-CBC'
-  BUFFER_LEN = 4096
+  SALT_LEN = 8
   COQUELICOT_VERSION = "1.0"
 
   def self.get_cipher(pass, salt, method)
-    hmac = PKCS5.pbkdf2_hmac_sha1(pass, salt, 2000, 48)
+    hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(pass, salt, 2000, 48)
     cipher = OpenSSL::Cipher.new CIPHER
-    cipher.call(method)
+    cipher.method(method).call
     cipher.key = hmac[0..31]
     cipher.iv = hmac[32..-1]
     cipher
   end
 
+  def self.gen_salt
+    OpenSSL::Random::random_bytes(SALT_LEN)
+  end
+
   def initialize(path, pass)
     @file = File.open(path)
     if YAML_START != (buf = @file.read(YAML_START.length)) then
       raise "unknown file, read #{buf.inspect}"
     end
     parse_clear_meta
+    return if pass.nil?
     init_decrypt_cipher pass
     parse_meta
   end
 
   def parse_clear_meta
-    while YAML_START != (line = @file.readline) do
+    meta = ''
+    until YAML_START == (line = @file.readline) do
       meta += line
     end
     @meta = YAML.load(meta)
     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)
-    salt = Base64.decode(@meta["Salt"])
-    @cipher = get_cipher(pass, salt, :decrypt)
+    salt = Base64.decode64(@meta["Salt"])
+    @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
   end
 
   def parse_meta
-    buf = @file.read(BUFFER_LEN)
     yaml = ''
-    yaml << @cipher.update(buf)
-    unless yaml.start_with? YAML_START
-      raise "bad key"
+    buf = @file.read(BUFFER_LEN)
+    content = @cipher.update(buf)
+    raise BadKey unless content.start_with? YAML_START
+    yaml << YAML_START
+    block = content.split(YAML_START, 3)
+    yaml << block[1]
+    if block.length == 3 then
+      @initial_content = block[2]
+      @meta.merge! YAML.load(yaml)
+      return
     end
-    while "" != (buf = @file.read(BUFFER_LEN))
-      block = @cipher.update(buf).split(/^---$/, 2)
+
+    until (buf = @file.read(BUFFER_LEN)).nil? do
+      block = @cipher.update(buf).split(YAML_START, 3)
       yaml << block[0]
-      loop unless block.length == 2
-      @meta.merge(YAML.load(yaml))
-      @initial_content = block[1]
+      break if block.length == 2
     end
+    @initial_content = block[1]
+    @meta.merge! YAML.load(yaml)
   end
 
   def close
@@ -106,15 +138,170 @@ private
   end
 end
 
+class Depot
+  include Singleton
+
+  attr_accessor :path, :lockfile_options, :filename_length
+
+  def add_file(src, pass, options)
+    dst = nil
+    lockfile.lock do
+      dst = gen_random_file_name
+      File.open(full_path(dst), 'w').close
+    end
+    begin
+      File.open(full_path(dst), 'w') do |dest|
+        StoredFile.create(src, pass, options) { |data| dest.write data }
+      end
+    rescue
+      File.unlink full_path(dst)
+      raise
+    end
+    link = gen_random_file_name
+    add_link(link, dst)
+    link
+  end
+
+  def get_file(link, pass)
+    name = read_link(link)
+    return nil if name.nil?
+    StoredFile::open(full_path(name), pass)
+  end
+
+  def file_exists?(link)
+    name = read_link(link)
+    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
+    Lockfile.new "#{@path}/.lock", @lockfile_options
+  end
+
+  def links_path
+    "#{@path}/.links"
+  end
+
+  def add_link(src, dst)
+    lockfile.lock do
+      File.open(links_path, 'a') do |f|
+        f.write("#{src} #{dst}\n")
+      end
+    end
+  end
+
+  def remove_from_links(&block)
+    lockfile.lock do
+      links = []
+      File.open(links_path, 'r+') do |f|
+        f.readlines.each do |l|
+          links << l unless yield l
+        end
+        f.rewind
+        f.truncate(0)
+        f.write links.join
+      end
+    end
+  end
+
+  def remove_link(src)
+    remove_from_links { |l| l.start_with? "#{src} " }
+  end
+
+  def read_link(src)
+    dst = nil
+    lockfile.lock do
+      File.open(links_path) do |f|
+        begin
+          line = f.readline
+          if line.start_with? "#{src} " then
+            dst = line.split[1]
+            break
+          end
+        end until line.empty?
+      end
+    end
+    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)
+    end while File.exists?(full_path(name))
+    name
+  end
+
+  def full_path(name)
+    raise "Wrong name" unless name.each_char.collect { |c| FILENAME_CHARS.include? c }.all?
+    "#{@path}/#{name}"
+  end
+end
+def depot
+  @depot unless @depot.nil?
+
+  @depot = Depot.instance
+  @depot.path = options.depot_path if @depot.path.nil?
+  @depot.lockfile_options = options.lockfile_options if @depot.lockfile_options.nil?
+  @depot.filename_length = options.filename_length if @depot.filename_length.nil?
+  @depot
+end
+
+# Like RFC 4648 (Base32)
+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)
+def gen_random_base32(length)
+  name = ''
+  OpenSSL::Random::random_bytes(length).each_byte do |i|
+    name << FILENAME_CHARS[i % FILENAME_CHARS.length]
+  end
+  name
+end
+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?
   (not password.nil?) && Digest::SHA1.hexdigest(password) == settings.upload_password
 end
 
-def uploaded_file(file)
-  "#{options.root}/files/#{file}"
-end
-
 get '/style.css' do
   content_type 'text/css', :charset => 'utf-8'
   sass :style
@@ -124,27 +311,31 @@ get '/' do
   haml :index
 end
 
-get '/ready/:name' do |name|
-  path = uploaded_file(name)
-  unless File.exists? path then
-    return 404
-  end
-  base = request.url.gsub(/\/ready\/[^\/]*$/, '')
-  @url = "#{base}/#{name}"
-  haml :ready
+get '/random_pass' do
+  "#{gen_random_pass}"
 end
 
-get '/:name' do |name|
-  path = uploaded_file(name)
-  unless File.exists? path then
-    return 404
+get '/ready/:link' do |link|
+  link, pass = link.split '-' if link.include? '-'
+  begin
+    file = depot.get_file(link, nil)
+  rescue Errno::ENOENT => ex
+    not_found
   end
-  send_file path
+  @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
 
 post '/upload' do
   unless password_match? params[:upload_password] then
-    return 403
+    error 403
   end
   if params[:file] then
     tmpfile = params[:file][:tempfile]
@@ -154,8 +345,64 @@ post '/upload' do
     @error = "No file selected"
     return haml(:index)
   end
-  FileUtils::cp(tmpfile.path, uploaded_file(name))
-  redirect "ready/#{name}"
+  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
+    pass = params[:file_key]
+  end
+  src = params[:file][:tempfile]
+  link = depot.add_file(
+     src, pass,
+     { "Expire-at" => expire_at.to_i,
+       "Filename" => params[:file][:filename],
+       "Length" => src.stat.size,
+       "Content-Type" => params[:file][:type]
+     })
+  redirect "ready/#{link}-#{pass}" if params[:file_key].nil? or params[:file_key].empty?
+  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']
+  response['Content-Length'] = "#{file.meta['Length']}"
+  response['Content-Type'] = file.meta['Content-Type'] || 'application/octet-stream'
+  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|
+  link = remap_base32_extra_characters(link)
+  not_found unless depot.file_exists? link
+  @link = link
+  haml :enter_file_key
+end
+
+post '/:link' do |link|
+  pass = params[:file_key]
+  return 403 if pass.nil? or pass.empty?
+  begin
+    return 403 unless send_stored_file(link, pass)
+  rescue BadKey => ex
+    403
+  end
 end
 
 helpers do
@@ -174,6 +421,8 @@ end
 __END__
 
 @@ layout
+!!! XML
+!!! Strict
 %html
   %head
     %title coquelicot
@@ -183,26 +432,77 @@ __END__
     %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
-    %input{ :type => 'submit', :value => 'Send file' }
+    %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
+    .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 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
@@ -214,13 +514,70 @@ body
 
 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
@@ -230,3 +587,10 @@ a, a:visited
   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