properly delete file in case of errors in StoredFile.create
[coquelicot.git] / lib / coquelicot / stored_file.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 'base64'
18 require 'lockfile'
19 require 'openssl'
20 require 'yaml'
21
22 module Coquelicot
23   class BadKey < StandardError; end
24
25   class StoredFile
26     BUFFER_LEN = 4096
27
28     attr_reader :path, :meta, :expire_at
29
30     def self.open(path, pass = nil)
31       StoredFile.new(path, pass)
32     end
33
34     def created_at
35       Time.at(@meta['Created-at'])
36     end
37
38     def expired?
39       @expire_at < Time.now
40     end
41
42     def one_time_only?
43       @meta['One-time-only']
44     end
45
46     def self.create(path, pass, meta)
47       salt = gen_salt
48       clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
49                      "Salt" => Base64.encode64(salt).strip,
50                      "Expire-at" => meta.delete('Expire-at'),
51                    }
52       File.open(path, File::WRONLY|File::EXCL|File::CREAT) do |dest|
53         dest.write(YAML.dump(clear_meta) + YAML_START)
54
55         cipher = get_cipher(pass, salt, :encrypt)
56         dest.write(cipher.update(
57             YAML.dump(meta.merge("Created-at" => Time.now.to_i)) +
58             YAML_START))
59         while not (buf = yield).nil?
60           dest.write(cipher.update(buf))
61         end
62         dest.write(cipher.final)
63       end
64     rescue Errno::EEXIST
65       # do not remove the file if it already existed before!
66       raise
67     rescue
68       FileUtils.rm path, :force => true
69       raise
70     end
71
72     def empty!
73       # zero the content before truncating
74       File.open(@path, 'r+') do |f|
75         f.seek 0, IO::SEEK_END
76         length = f.tell
77         f.rewind
78         while length > 0 do
79           write_len = [StoredFile::BUFFER_LEN, length].min
80           length -= f.write("\0" * write_len)
81         end
82         f.fsync
83       end
84       File.truncate(@path, 0)
85     end
86
87     def lockfile
88       @lockfile ||= Lockfile.new "#{File.expand_path(@path)}.lock", :timeout => 4
89     end
90
91     # used by Rack streaming mechanism
92     def each
93       raise BadKey.new if @cipher.nil?
94
95       # output content
96       yield @initial_content
97       @initial_content = nil
98       until (buf = @file.read(BUFFER_LEN)).nil?
99         yield @cipher.update(buf)
100       end
101       yield @cipher.final
102       @fully_sent = true
103     end
104
105     def close
106       if @cipher
107         @cipher.reset
108         @cipher = nil
109       end
110       @file.close
111       if one_time_only?
112         empty! if @fully_sent
113         lockfile.unlock
114       end
115     end
116
117   private
118
119     YAML_START = "--- \n"
120     CIPHER = 'AES-256-CBC'
121     SALT_LEN = 8
122     COQUELICOT_VERSION = '1.0'
123
124     def self.get_cipher(pass, salt, method)
125       cipher = OpenSSL::Cipher.new CIPHER
126       hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(
127           pass, salt, 2000, cipher.key_len + cipher.iv_len)
128       cipher.method(method).call
129       cipher.key = hmac.slice!(0, cipher.key_len)
130       cipher.iv = hmac
131       cipher
132     end
133
134     def self.gen_salt
135       OpenSSL::Random::random_bytes(SALT_LEN)
136     end
137
138     def initialize(path, pass)
139       @path = path
140       @file = File.open(@path)
141       if @file.lstat.size == 0 then
142         @expire_at = Time.now - 1
143         return
144       end
145
146       if YAML_START != (buf = @file.read(YAML_START.length)) then
147         raise "unknown file, read #{buf.inspect}"
148       end
149       parse_clear_meta
150       return if pass.nil?
151
152       init_decrypt_cipher pass
153
154       yaml = find_meta
155       @meta.merge! YAML.load(yaml)
156     end
157
158     def parse_clear_meta
159       meta = ''
160       until YAML_START == (line = @file.readline) do
161         meta += line
162       end
163       @meta = YAML.load(meta)
164       unless @meta["Coquelicot"] == COQUELICOT_VERSION
165         raise 'unknown file'
166       end
167       @expire_at = Time.at(@meta['Expire-at'])
168     end
169
170     def init_decrypt_cipher(pass)
171       salt = Base64.decode64(@meta["Salt"])
172       @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
173     end
174
175     def find_meta
176       yaml = ''
177       buf = @file.read(BUFFER_LEN)
178       content = @cipher.update(buf)
179       content << @cipher.final if @file.eof?
180       raise BadKey.new unless content.start_with? YAML_START
181       yaml << YAML_START
182       block = content.split(YAML_START, 3)
183       yaml << block[1]
184       if block.length == 3 then
185         @initial_content = block[2]
186         return yaml
187       end
188
189       until (buf = @file.read(BUFFER_LEN)).nil? do
190         content = @cipher.update(buf)
191         block = content.split(YAML_START, 3)
192         yaml << block[0]
193         break if block.length == 2
194       end
195       @initial_content = block[1]
196       yaml
197     end
198   end
199 end