properly delete file in case of errors in StoredFile.create
[coquelicot.git] / lib / coquelicot / depot.rb
1 # Coquelicot: "one-click" file sharing with a focus on users' privacy.
2 # Copyright © 2010-2012 potager.org <jardiniers@potager.org>
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as
6 # published by the Free Software Foundation, either version 3 of the
7 # License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17 require 'lockfile'
18 require 'openssl'
19
20 module Coquelicot
21   class Depot
22     attr_reader :path
23
24     def initialize(path)
25       @path = path
26     end
27
28     def add_file(pass, options, &block)
29       dst = nil
30
31       # Ensure that the generated name is not already used
32       loop do
33         dst = gen_random_file_name
34         begin
35           StoredFile.create(full_path(dst), pass, options, &block)
36           break
37         rescue Errno::EEXIST => e
38           raise unless e.message =~ /(?:^|\s)#{Regexp.escape(full_path(dst))}(?:\s|$)/
39           next # let's try again
40         end
41       end
42       link = gen_random_file_name
43       add_link(link, dst)
44       link
45     end
46
47     def get_file(link, pass=nil)
48       name = read_link(link)
49       return nil if name.nil?
50       StoredFile::open(full_path(name), pass)
51     end
52
53     def file_exists?(link)
54       name = read_link(link)
55       return !name.nil?
56     end
57
58     def gc!
59       files.each do |name|
60         path = full_path(name)
61         if File.lstat(path).size > 0
62           file = StoredFile::open path
63           file.empty! if file.expired?
64         elsif Time.now - File.lstat(path).mtime > (Coquelicot.settings.gone_period * 60)
65           remove_from_links { |l| l.strip.end_with? " #{name}" }
66           File.unlink path
67         end
68       end
69     end
70
71   private
72
73     LOCKFILE_OPTIONS = { :timeout => 60,
74                          :max_age => 8,
75                          :refresh => 2,
76                          :debug   => false }
77
78     def lockfile
79       Lockfile.new "#{@path}/.lock", LOCKFILE_OPTIONS
80     end
81
82     def links_path
83       "#{@path}/.links"
84     end
85
86     def add_link(src, dst)
87       lockfile.lock do
88         File.open(links_path, 'a') do |f|
89           f.write("#{src} #{dst}\n")
90         end
91       end
92     end
93
94     def remove_from_links(&block)
95       lockfile.lock do
96         links = []
97         File.open(links_path, 'r+') do |f|
98           f.readlines.each do |l|
99             links << l unless yield l
100           end
101           f.rewind
102           f.truncate(0)
103           f.write links.join
104         end
105       end
106     end
107
108     def remove_link(src)
109       remove_from_links { |l| l.start_with? "#{src} " }
110     end
111
112     def read_link(src)
113       dst = nil
114       lockfile.lock do
115         File.open(links_path) do |f|
116           begin
117             line = f.readline rescue break
118             if line.start_with? "#{src} " then
119               dst = line.split[1]
120               break
121             end
122           end until line.empty?
123         end if File.exists?(links_path)
124       end
125       dst
126     end
127
128     def files
129       lockfile.lock do
130         File.open(links_path) do |f|
131           f.readlines.collect { |l| l.split[1] }
132         end
133       end
134     end
135
136     def gen_random_file_name
137       begin
138         name = Coquelicot.gen_random_base32(Coquelicot.settings.filename_length)
139       end while File.exists?(full_path(name))
140       name
141     end
142
143     def full_path(name)
144       raise "Wrong name" unless name.each_char.collect { |c| Coquelicot::FILENAME_CHARS.include? c }.all?
145       "#{@path}/#{name}"
146     end
147   end
148
149   # Like RFC 4648 (Base32)
150   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)
151
152   class << self
153     def gen_random_base32(length)
154       name = ''
155       OpenSSL::Random::random_bytes(length).each_byte do |i|
156         name << FILENAME_CHARS[i % FILENAME_CHARS.length]
157       end
158       name
159     end
160     def gen_random_pass
161       gen_random_base32(settings.random_pass_length)
162     end
163     def remap_base32_extra_characters(str)
164       map = {}
165       FILENAME_CHARS.each { |c| map[c] = c; map[c.upcase] = c }
166       map.merge!({ '1' => 'l', '0' => 'o' })
167       result = ''
168       str.each_char { |c| result << map[c] if map[c] }
169       result
170     end
171   end
172 end