reshuffle code around
authorLunar <lunar@anargeek.net>
Sun, 8 Aug 2010 10:56:12 +0000 (12:56 +0200)
committerLunar <lunar@anargeek.net>
Sun, 8 Aug 2010 10:56:12 +0000 (12:56 +0200)
Code that other scripts might want now lies in 'lib/coquelicot.rb', which
defines a Coquelicot module.

Web application lies in 'coquelicot_app.rb' for distinction.

Move away from Sinatra options for Coquelicot specific settings.

Rakefile
config.ru
coquelicot.rb [deleted file]
coquelicot_app.rb [new file with mode: 0644]
lib/coquelicot.rb [new file with mode: 0644]
test_coquelicot.rb

index 397b721..ead713a 100644 (file)
--- a/Rakefile
+++ b/Rakefile
@@ -6,7 +6,7 @@ task :updatepo do
   require 'haml_parser'
   GetText.update_pofiles(
     "coquelicot",
-    Dir.glob("views/**/*.{rb,haml}") << "coquelicot.rb",
+    Dir.glob("views/**/*.{rb,haml}") << "coquelicot_app.rb",
     "coquelicot 1.0.0")
 end
 
index 3f284cf..4238ddb 100644 (file)
--- a/config.ru
+++ b/config.ru
@@ -2,8 +2,8 @@ require 'sinatra'
 
 set :environment, :development
 set :raise_errors, true
-set :depot_path, Proc.new { File.join(public, "../files") }
 disable :run
 
-require 'coquelicot'
+require 'coquelicot_app'
+Coquelicot.setup :depot_path => File.expand("#{options.root}/../files")
 run Sinatra::Application
diff --git a/coquelicot.rb b/coquelicot.rb
deleted file mode 100644 (file)
index 95f87e9..0000000
+++ /dev/null
@@ -1,478 +0,0 @@
-$:.unshift File.join(File.dirname(__FILE__), 'lib')
-
-require 'sinatra'
-require 'haml'
-require 'digest/sha1'
-require 'base64'
-require 'openssl'
-require 'yaml'
-require 'lockfile'
-require 'singleton'
-require 'gettext'
-require 'haml_gettext'
-
-set :upload_password, '0e5f7d398e6f9cd1f6bac5cc823e363aec636495'
-set :default_expire, 60 # 1 hour
-set :gone_period, 10080 # 1 week
-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
-  BUFFER_LEN = 4096
-
-  attr_reader :path, :meta, :expire_at
-
-  def self.open(path, pass = nil)
-    StoredFile.new(path, pass)
-  end
-
-  def each
-    # output content
-    yield @initial_content
-    @initial_content = nil
-    until (buf = @file.read(BUFFER_LEN)).nil?
-      yield @cipher.update(buf)
-    end
-    yield @cipher.final
-    @cipher.reset
-    @cipher = nil
-  end
-
-  def created_at
-    Time.at(@meta['Created-at'])
-  end
-
-  def expired?
-    @expire_at < Time.now
-  end
-
-  def one_time_only?
-    @meta['One-time-only'] && @meta['One-time-only'] == 'true'
-  end
-
-  def exclusively(&block)
-    old_path = @path
-    begin
-      new_path = "#{old_path}.#{gen_random_base32(16)}"
-    end while File.exists? new_path
-    File.rename(old_path, new_path)
-    @path = new_path
-    File.open(old_path, 'w').close
-    begin
-      yield
-    ensure
-      File.rename(new_path, old_path)
-    end
-  end
-
-  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.merge("Created-at" => Time.now.to_i)) +
-                        YAML_START)
-    src.rewind
-    while not (buf = src.read(BUFFER_LEN)).nil?
-      yield cipher.update(buf)
-    end
-    yield cipher.final
-  end
-
-  def empty!
-    # zero the content before truncating
-    File.open(@path, '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
-      f.fsync
-    end
-    File.truncate(@path, 0)
-  end
-
-private
-
-  YAML_START = "--- \n"
-  CIPHER = 'AES-256-CBC'
-  SALT_LEN = 8
-  COQUELICOT_VERSION = "1.0"
-
-  def self.get_cipher(pass, salt, method)
-    hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(pass, salt, 2000, 48)
-    cipher = OpenSSL::Cipher.new CIPHER
-    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)
-    @path = path
-    @file = File.open(@path)
-    if @file.lstat.size == 0 then
-      @expire_at = Time.now - 1
-      return
-    end
-
-    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
-    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.decode64(@meta["Salt"])
-    @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
-  end
-
-  def parse_meta
-    yaml = ''
-    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
-
-    until (buf = @file.read(BUFFER_LEN)).nil? do
-      block = @cipher.update(buf).split(YAML_START, 3)
-      yaml << block[0]
-      break if block.length == 2
-    end
-    @initial_content = block[1]
-    @meta.merge! YAML.load(yaml)
-  end
-
-  def close
-    @cipher.reset unless @cipher.nil?
-    @file.close
-  end
-end
-
-class Depot
-  include Singleton
-
-  attr_accessor :path, :lockfile_options, :filename_length, :gone_period
-
-  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=nil)
-    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|
-      path = full_path(name)
-      if File.lstat(path).size > 0
-        file = StoredFile::open path
-        file.empty! if file.expired?
-      elsif Time.now - File.lstat(path).mtime > (gone_period * 60)
-        remove_from_links { |l| l.strip.end_with? " #{name}" }
-        File.unlink path
-      end
-    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 rescue break
-          if line.start_with? "#{src} " then
-            dst = line.split[1]
-            break
-          end
-        end until line.empty?
-      end
-    end
-    dst
-  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.gone_period = options.gone_period if @depot.gone_period.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
-
-GetText::bindtextdomain('coquelicot')
-before do
-  GetText::set_current_locale(params[:lang] || request.env['HTTP_ACCEPT_LANGUAGE'] || 'en')
-end
-
-get '/style.css' do
-  content_type 'text/css', :charset => 'utf-8'
-  sass :style
-end
-
-get '/' do
-  haml :index
-end
-
-get '/random_pass' do
-  "#{gen_random_pass}"
-end
-
-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
-  @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
-    error 403
-  end
-  if params[:file] then
-    tmpfile = params[:file][:tempfile]
-    name = params[:file][:filename]
-  end
-  if tmpfile.nil? || name.nil? then
-    @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
-  one_time_only = params[:one_time] and params[:one_time] == 'true'
-  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,
-       "One-time-only" => one_time_only,
-       "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(file)
-  last_modified file.created_at.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
-
-def send_link(link, pass)
-  file = depot.get_file(link, pass)
-  return false if file.nil?
-  return expired if file.expired?
-
-  return send_stored_file(file) unless file.one_time_only?
-
-  file.exclusively do
-    begin  send_stored_file(file)
-    ensure file.empty!            end
-  end
-end
-
-get '/:link-:pass' do |link, pass|
-  link = remap_base32_extra_characters(link)
-  pass = remap_base32_extra_characters(pass)
-  not_found unless send_link(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
-    # send Forbidden even if file is not found
-    return 403 unless send_link(link, pass)
-  rescue BadKey => ex
-    403
-  end
-end
-
-helpers do
-  def base_href
-    url = request.scheme + "://"
-    url << request.host
-    if request.scheme == "https" && request.port != 443 ||
-        request.scheme == "http" && request.port != 80
-      url << ":#{request.port}"
-    end
-    url << request.script_name
-    "#{url}/"
-  end
-end
diff --git a/coquelicot_app.rb b/coquelicot_app.rb
new file mode 100644 (file)
index 0000000..8168c01
--- /dev/null
@@ -0,0 +1,148 @@
+$:.unshift File.join(File.dirname(__FILE__), 'lib')
+
+require 'sinatra'
+require 'haml'
+require 'digest/sha1'
+require 'gettext'
+require 'coquelicot'
+require 'haml_gettext'
+
+set :upload_password, '0e5f7d398e6f9cd1f6bac5cc823e363aec636495'
+
+def password_match?(password)
+  return TRUE if settings.upload_password.nil?
+  (not password.nil?) && Digest::SHA1.hexdigest(password) == settings.upload_password
+end
+
+GetText::bindtextdomain('coquelicot')
+before do
+  GetText::set_current_locale(params[:lang] || request.env['HTTP_ACCEPT_LANGUAGE'] || 'en')
+end
+
+get '/style.css' do
+  content_type 'text/css', :charset => 'utf-8'
+  sass :style
+end
+
+get '/' do
+  haml :index
+end
+
+get '/random_pass' do
+  "#{Coquelicot.gen_random_pass}"
+end
+
+get '/ready/:link' do |link|
+  link, pass = link.split '-' if link.include? '-'
+  begin
+    file = Coquelicot.depot.get_file(link, nil)
+  rescue Errno::ENOENT => ex
+    not_found
+  end
+  @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
+    error 403
+  end
+  if params[:file] then
+    tmpfile = params[:file][:tempfile]
+    name = params[:file][:filename]
+  end
+  if tmpfile.nil? || name.nil? then
+    @error = "No file selected"
+    return haml(:index)
+  end
+  if params[:expire].nil? or params[:expire].to_i == 0 then
+    params[:expire] = Coquelicot.settings.default_expire
+  end
+  expire_at = Time.now + 60 * params[:expire].to_i
+  one_time_only = params[:one_time] and params[:one_time] == 'true'
+  if params[:file_key].nil? or params[:file_key].empty?then
+    pass = Coquelicot.gen_random_pass
+  else
+    pass = params[:file_key]
+  end
+  src = params[:file][:tempfile]
+  link = Coquelicot.depot.add_file(
+     src, pass,
+     { "Expire-at" => expire_at.to_i,
+       "One-time-only" => one_time_only,
+       "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(file)
+  last_modified file.created_at.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
+
+def send_link(link, pass)
+  file = Coquelicot.depot.get_file(link, pass)
+  return false if file.nil?
+  return expired if file.expired?
+
+  return send_stored_file(file) unless file.one_time_only?
+
+  file.exclusively do
+    begin  send_stored_file(file)
+    ensure file.empty!            end
+  end
+end
+
+get '/:link-:pass' do |link, pass|
+  link = Coquelicot.remap_base32_extra_characters(link)
+  pass = Coquelicot.remap_base32_extra_characters(pass)
+  not_found unless send_link(link, pass)
+end
+
+get '/:link' do |link|
+  link = Coquelicot.remap_base32_extra_characters(link)
+  not_found unless Coquelicot.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
+    # send Forbidden even if file is not found
+    return 403 unless send_link(link, pass)
+  rescue Coquelicot::BadKey => ex
+    403
+  end
+end
+
+helpers do
+  def base_href
+    url = request.scheme + "://"
+    url << request.host
+    if request.scheme == "https" && request.port != 443 ||
+        request.scheme == "http" && request.port != 80
+      url << ":#{request.port}"
+    end
+    url << request.script_name
+    "#{url}/"
+  end
+end
diff --git a/lib/coquelicot.rb b/lib/coquelicot.rb
new file mode 100644 (file)
index 0000000..2f199ef
--- /dev/null
@@ -0,0 +1,348 @@
+require 'base64'
+require 'lockfile'
+require 'openssl'
+require 'yaml'
+
+module Coquelicot
+  class BadKey < StandardError; end
+
+  class StoredFile
+    BUFFER_LEN = 4096
+
+    attr_reader :path, :meta, :expire_at
+
+    def self.open(path, pass = nil)
+      StoredFile.new(path, pass)
+    end
+
+    def each
+      # output content
+      yield @initial_content
+      @initial_content = nil
+      until (buf = @file.read(BUFFER_LEN)).nil?
+        yield @cipher.update(buf)
+      end
+      yield @cipher.final
+      @cipher.reset
+      @cipher = nil
+    end
+
+    def created_at
+      Time.at(@meta['Created-at'])
+    end
+
+    def expired?
+      @expire_at < Time.now
+    end
+
+    def one_time_only?
+      @meta['One-time-only'] && @meta['One-time-only'] == 'true'
+    end
+
+    def exclusively(&block)
+      old_path = @path
+      begin
+        new_path = "#{old_path}.#{Coquelicot.gen_random_base32(16)}"
+      end while File.exists? new_path
+      File.rename(old_path, new_path)
+      @path = new_path
+      File.open(old_path, 'w').close
+      begin
+        yield
+      ensure
+        File.rename(new_path, old_path)
+      end
+    end
+
+    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.merge("Created-at" => Time.now.to_i)) +
+                          YAML_START)
+      src.rewind
+      while not (buf = src.read(BUFFER_LEN)).nil?
+        yield cipher.update(buf)
+      end
+      yield cipher.final
+    end
+
+    def empty!
+      # zero the content before truncating
+      File.open(@path, '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
+        f.fsync
+      end
+      File.truncate(@path, 0)
+    end
+
+  private
+
+    YAML_START = "--- \n"
+    CIPHER = 'AES-256-CBC'
+    SALT_LEN = 8
+    COQUELICOT_VERSION = "1.0"
+
+    def self.get_cipher(pass, salt, method)
+      hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(pass, salt, 2000, 48)
+      cipher = OpenSSL::Cipher.new CIPHER
+      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)
+      @path = path
+      @file = File.open(@path)
+      if @file.lstat.size == 0 then
+        @expire_at = Time.now - 1
+        return
+      end
+
+      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
+      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.decode64(@meta["Salt"])
+      @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
+    end
+
+    def parse_meta
+      yaml = ''
+      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
+
+      until (buf = @file.read(BUFFER_LEN)).nil? do
+        block = @cipher.update(buf).split(YAML_START, 3)
+        yaml << block[0]
+        break if block.length == 2
+      end
+      @initial_content = block[1]
+      @meta.merge! YAML.load(yaml)
+    end
+
+    def close
+      @cipher.reset unless @cipher.nil?
+      @file.close
+    end
+  end
+
+  class Depot
+    LOCKFILE_OPTIONS = { :timeout => 60,
+                         :max_age => 8,
+                         :refresh => 2,
+                         :debug   => false }
+
+    attr_reader :path
+
+    def initialize(path)
+      @path = path
+    end
+
+    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=nil)
+      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|
+        path = full_path(name)
+        if File.lstat(path).size > 0
+          file = StoredFile::open path
+          file.empty! if file.expired?
+        elsif Time.now - File.lstat(path).mtime > (Coquelicot.settings.gone_period * 60)
+          remove_from_links { |l| l.strip.end_with? " #{name}" }
+          File.unlink path
+        end
+      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 rescue break
+            if line.start_with? "#{src} " then
+              dst = line.split[1]
+              break
+            end
+          end until line.empty?
+        end
+      end
+      dst
+    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 = Coquelicot.gen_random_base32(Coquelicot.settings.filename_length)
+      end while File.exists?(full_path(name))
+      name
+    end
+
+    def full_path(name)
+      raise "Wrong name" unless name.each_char.collect { |c| Coquelicot::FILENAME_CHARS.include? c }.all?
+      "#{@path}/#{name}"
+    end
+  end
+
+  DEFAULT_SETTINGS = { :default_expire => 60,
+                       :gone_period => 10080,
+                       :filename_length => 20,
+                       :random_pass_length => 16,
+                    }
+
+  # 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)
+
+  class << self
+
+    def setup(settings)
+      @settings = DEFAULT_SETTINGS.merge(settings)
+      @settings.each_key do |k|
+        @settings.class.send(:define_method, k) { self[k] }
+      end
+      @depot = nil
+      @settings
+    end
+
+    def settings
+      @settings ||= setup({})
+    end
+
+    def depot
+      @depot ||= Depot.new(settings.depot_path)
+    end
+
+    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(settings.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
+  end
+end
index 331c651..f3503e2 100644 (file)
@@ -2,7 +2,7 @@ $:.unshift File.join(File.dirname(__FILE__), '../rack-test/lib')
 $:.unshift File.join(File.dirname(__FILE__), '../timecop/lib')
 
 require 'sinatra'
-require 'coquelicot'
+require 'coquelicot_app'
 require 'spec'
 require 'rack/test'
 require 'timecop'
@@ -35,11 +35,11 @@ describe 'Coquelicot' do
   end
 
   before do
-    Depot.instance.path = Dir.mktmpdir('coquelicot') #"#{Time.now.to_f}"
+    Coquelicot.setup :depot_path => Dir.mktmpdir('coquelicot') #"#{Time.now.to_f}"
   end
 
   after do
-    FileUtils.remove_entry_secure Depot.instance.path
+    FileUtils.remove_entry_secure Coquelicot.depot.path
   end
 
   it "should offer an upload form" do
@@ -80,7 +80,7 @@ describe 'Coquelicot' do
 
   it "should not store an uploaded file in cleartext" do
     upload
-    files = Dir.glob("#{Depot.instance.path}/*")
+    files = Dir.glob("#{Coquelicot.depot.path}/*")
     files.should have(1).items
     File.new(files[0]).read().should_not include('should not store an uploaded file')
   end
@@ -93,7 +93,7 @@ describe 'Coquelicot' do
   it "should store files with a different name than then one in URL" do
     url = upload
     url_name = url.split('/')[-1]
-    files = Dir.glob("#{Depot.instance.path}/*")
+    files = Dir.glob("#{Coquelicot.depot.path}/*")
     files.should have(1).items
     url_name.should_not eql(File.basename(files[0]))
   end
@@ -160,20 +160,20 @@ describe 'Coquelicot' do
   it "should cleanup expired files" do
     url = upload :expire => 60, :file_key => 'test' # 1 hour
     url_name = url.split('/')[-1]
-    Dir.glob("#{Depot.instance.path}/*").should have(1).items
+    Dir.glob("#{Coquelicot.depot.path}/*").should have(1).items
     # let's be tomorrow
     Timecop.travel(Date.today + 1) do
-      Depot.instance.gc!
-      files = Dir.glob("#{Depot.instance.path}/*")
+      Coquelicot.depot.gc!
+      files = Dir.glob("#{Coquelicot.depot.path}/*")
       files.should have(1).items
       File.lstat(files[0]).size.should eql(0)
-      Depot.instance.get_file(url_name).expired?.should be_true
+      Coquelicot.depot.get_file(url_name).expired?.should be_true
     end
     # let's be after 'gone' period
-    Timecop.travel(Time.now + (Depot.instance.gone_period * 60)) do
-      Depot.instance.gc!
-      Dir.glob("#{Depot.instance.path}/*").should have(0).items
-      Depot.instance.get_file(url_name).should be_nil
+    Timecop.travel(Time.now + (Coquelicot.settings.gone_period * 60)) do
+      Coquelicot.depot.gc!
+      Dir.glob("#{Coquelicot.depot.path}/*").should have(0).items
+      Coquelicot.depot.get_file(url_name).should be_nil
     end
   end