Get nicer behaviour wrt. '410 Gone' and expiration
authorLunar <lunar@anargeek.net>
Sat, 7 Aug 2010 11:02:25 +0000 (13:02 +0200)
committerLunar <lunar@anargeek.net>
Sat, 7 Aug 2010 13:20:05 +0000 (15:20 +0200)
Once a file has expired, it is now truncated to zero length and the
URL is still kept. This allows to return '410 Gone' messages for a configurable
period of time (1 week by default).

README
coquelicot.rb
test_coquelicot.rb

diff --git a/README b/README
index c98a9ab..43fa382 100644 (file)
--- a/README
+++ b/README
@@ -25,6 +25,9 @@ Features
    When uploading, a time limit has to be specified. The file will be
    unavailable once this limit has been reached.
 
+   During a configurable period of time, trying to download the file
+   will return a page saying "too late" instead of "not found".
+
  * Upload progress bar
 
    If the web server tracks upload progress, users having javascript
@@ -181,6 +184,8 @@ Once decrypted, content has the following format:
 
 Headers must be parseable using the YAML standard.
 
+File are truncated to zero length when they are "expired".
+
 In order to map download URLs to file name, a simple text file ".links"
 is used. It contains a line for each file in the form:
 
index fe5f4fe..e73d690 100644 (file)
@@ -13,6 +13,7 @@ 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,
@@ -25,7 +26,7 @@ class BadKey < StandardError; end
 class StoredFile
   BUFFER_LEN = 4096
 
-  attr_reader :meta, :expire_at
+  attr_reader :path, :meta, :expire_at
 
   def self.open(path, pass = nil)
     StoredFile.new(path, pass)
@@ -47,6 +48,10 @@ class StoredFile
     Time.at(@meta['Created-at'])
   end
 
+  def expired?
+    @expire_at < Time.now
+  end
+
   def self.create(src, pass, meta)
     salt = gen_salt
     clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
@@ -65,6 +70,21 @@ class StoredFile
     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"
@@ -86,7 +106,13 @@ private
   end
 
   def initialize(path, pass)
-    @file = File.open(path)
+    @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
@@ -145,7 +171,7 @@ end
 class Depot
   include Singleton
 
-  attr_accessor :path, :lockfile_options, :filename_length
+  attr_accessor :path, :lockfile_options, :filename_length, :gone_period
 
   def add_file(src, pass, options)
     dst = nil
@@ -166,7 +192,7 @@ class Depot
     link
   end
 
-  def get_file(link, pass)
+  def get_file(link, pass=nil)
     name = read_link(link)
     return nil if name.nil?
     StoredFile::open(full_path(name), pass)
@@ -179,7 +205,14 @@ class Depot
 
   def gc!
     files.each do |name|
-      remove_file(name) if Time.now > StoredFile::open(full_path(name)).expire_at
+      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
 
@@ -224,7 +257,7 @@ private
     lockfile.lock do
       File.open(links_path) do |f|
         begin
-          line = f.readline
+          line = f.readline rescue break
           if line.start_with? "#{src} " then
             dst = line.split[1]
             break
@@ -235,21 +268,6 @@ private
     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|
@@ -277,6 +295,7 @@ def depot
   @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
 
@@ -382,7 +401,7 @@ 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
+  return expired if file.expired?
 
   last_modified file.created_at.httpdate
   attachment file.meta['Filename']
index 1b7ef10..02009ee 100644 (file)
@@ -148,12 +148,22 @@ describe 'Coquelicot' do
   end
 
   it "should cleanup expired files" do
-    upload :expire => 60 # 1 hour
+    url = upload :expire => 60, :file_key => 'test' # 1 hour
+    url_name = url.split('/')[-1]
     Dir.glob("#{Depot.instance.path}/*").should have(1).items
     # let's be tomorrow
     Timecop.travel(Date.today + 1) do
       Depot.instance.gc!
+      files = Dir.glob("#{Depot.instance.path}/*")
+      files.should have(1).items
+      File.lstat(files[0]).size.should eql(0)
+      Depot.instance.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
     end
   end