0559ecd3c9aefe262886e9c302615a1d7856251e
[coquelicot.git] / lib / coquelicot.rb
1 require 'base64'
2 require 'lockfile'
3 require 'openssl'
4 require 'yaml'
5
6 require 'coquelicot/auth'
7 require 'coquelicot/configure'
8 require 'coquelicot/app'
9
10 module Coquelicot
11   class BadKey < StandardError; end
12
13   class StoredFile
14     BUFFER_LEN = 4096
15
16     attr_reader :path, :meta, :expire_at
17
18     def self.open(path, pass = nil)
19       StoredFile.new(path, pass)
20     end
21
22     def created_at
23       Time.at(@meta['Created-at'])
24     end
25
26     def expired?
27       @expire_at < Time.now
28     end
29
30     def one_time_only?
31       @meta['One-time-only'] == 'true'
32     end
33
34     def self.create(src, pass, meta)
35       salt = gen_salt
36       clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
37                      "Salt" => Base64.encode64(salt).strip,
38                      "Expire-at" => meta.delete('Expire-at'),
39                    }
40       yield YAML.dump(clear_meta) + YAML_START
41
42       cipher = get_cipher(pass, salt, :encrypt)
43       yield cipher.update(YAML.dump(meta.merge("Created-at" => Time.now.to_i)) +
44                           YAML_START)
45       src.rewind
46       while not (buf = src.read(BUFFER_LEN)).nil?
47         yield cipher.update(buf)
48       end
49       yield cipher.final
50     end
51
52     def empty!
53       # zero the content before truncating
54       File.open(@path, 'r+') do |f|
55         f.seek 0, IO::SEEK_END
56         length = f.tell
57         f.rewind
58         while length > 0 do
59           write_len = [StoredFile::BUFFER_LEN, length].min
60           length -= f.write("\0" * write_len)
61         end
62         f.fsync
63       end
64       File.truncate(@path, 0)
65     end
66
67   private
68
69     YAML_START = "--- \n"
70     CIPHER = 'AES-256-CBC'
71     SALT_LEN = 8
72     COQUELICOT_VERSION = "1.0"
73
74     def self.get_cipher(pass, salt, method)
75       hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(pass, salt, 2000, 48)
76       cipher = OpenSSL::Cipher.new CIPHER
77       cipher.method(method).call
78       cipher.key = hmac[0..31]
79       cipher.iv = hmac[32..-1]
80       cipher
81     end
82
83     def self.gen_salt
84       OpenSSL::Random::random_bytes(SALT_LEN)
85     end
86
87     def initialize(path, pass)
88       @path = path
89       @file = File.open(@path)
90       if @file.lstat.size == 0 then
91         @expire_at = Time.now - 1
92         return
93       end
94
95       if YAML_START != (buf = @file.read(YAML_START.length)) then
96         raise "unknown file, read #{buf.inspect}"
97       end
98       parse_clear_meta
99       return if pass.nil?
100       init_decrypt_cipher pass
101       parse_meta
102     end
103
104     def parse_clear_meta
105       meta = ''
106       until YAML_START == (line = @file.readline) do
107         meta += line
108       end
109       @meta = YAML.load(meta)
110       if @meta["Coquelicot"].nil? or @meta["Coquelicot"] != COQUELICOT_VERSION then
111         raise "unknown file"
112       end
113       @expire_at = Time.at(@meta['Expire-at'])
114     end
115
116     def init_decrypt_cipher(pass)
117       salt = Base64.decode64(@meta["Salt"])
118       @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
119     end
120
121     def parse_meta
122       yaml = ''
123       buf = @file.read(BUFFER_LEN)
124       content = @cipher.update(buf)
125       raise BadKey unless content.start_with? YAML_START
126       yaml << YAML_START
127       block = content.split(YAML_START, 3)
128       yaml << block[1]
129       if block.length == 3 then
130         @initial_content = block[2]
131         @meta.merge! YAML.load(yaml)
132         return
133       end
134
135       until (buf = @file.read(BUFFER_LEN)).nil? do
136         block = @cipher.update(buf).split(YAML_START, 3)
137         yaml << block[0]
138         break if block.length == 2
139       end
140       @initial_content = block[1]
141       @meta.merge! YAML.load(yaml)
142     end
143   end
144
145   class Depot
146     attr_reader :path
147
148     def initialize(path)
149       @path = path
150     end
151
152     def add_file(src, pass, options)
153       dst = nil
154       lockfile.lock do
155         dst = gen_random_file_name
156         File.open(full_path(dst), 'w').close
157       end
158       begin
159         File.open(full_path(dst), 'w') do |dest|
160           StoredFile.create(src, pass, options) { |data| dest.write data }
161         end
162       rescue
163         File.unlink full_path(dst)
164         raise
165       end
166       link = gen_random_file_name
167       add_link(link, dst)
168       link
169     end
170
171     def get_file(link, pass=nil)
172       name = read_link(link)
173       return nil if name.nil?
174       StoredFile::open(full_path(name), pass)
175     end
176
177     def file_exists?(link)
178       name = read_link(link)
179       return !name.nil?
180     end
181
182     def gc!
183       files.each do |name|
184         path = full_path(name)
185         if File.lstat(path).size > 0
186           file = StoredFile::open path
187           file.empty! if file.expired?
188         elsif Time.now - File.lstat(path).mtime > (Coquelicot.settings.gone_period * 60)
189           remove_from_links { |l| l.strip.end_with? " #{name}" }
190           File.unlink path
191         end
192       end
193     end
194
195   private
196
197     LOCKFILE_OPTIONS = { :timeout => 60,
198                          :max_age => 8,
199                          :refresh => 2,
200                          :debug   => false }
201
202     def lockfile
203       Lockfile.new "#{@path}/.lock", LOCKFILE_OPTIONS
204     end
205
206     def links_path
207       "#{@path}/.links"
208     end
209
210     def add_link(src, dst)
211       lockfile.lock do
212         File.open(links_path, 'a') do |f|
213           f.write("#{src} #{dst}\n")
214         end
215       end
216     end
217
218     def remove_from_links(&block)
219       lockfile.lock do
220         links = []
221         File.open(links_path, 'r+') do |f|
222           f.readlines.each do |l|
223             links << l unless yield l
224           end
225           f.rewind
226           f.truncate(0)
227           f.write links.join
228         end
229       end
230     end
231
232     def remove_link(src)
233       remove_from_links { |l| l.start_with? "#{src} " }
234     end
235
236     def read_link(src)
237       dst = nil
238       lockfile.lock do
239         File.open(links_path) do |f|
240           begin
241             line = f.readline rescue break
242             if line.start_with? "#{src} " then
243               dst = line.split[1]
244               break
245             end
246           end until line.empty?
247         end if File.exists?(links_path)
248       end
249       dst
250     end
251
252     def files
253       lockfile.lock do
254         File.open(links_path) do |f|
255           f.readlines.collect { |l| l.split[1] }
256         end
257       end
258     end
259
260     def gen_random_file_name
261       begin
262         name = Coquelicot.gen_random_base32(Coquelicot.settings.filename_length)
263       end while File.exists?(full_path(name))
264       name
265     end
266
267     def full_path(name)
268       raise "Wrong name" unless name.each_char.collect { |c| Coquelicot::FILENAME_CHARS.include? c }.all?
269       "#{@path}/#{name}"
270     end
271   end
272
273   # Like RFC 4648 (Base32)
274   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)
275
276   class << self
277     def gen_random_base32(length)
278       name = ''
279       OpenSSL::Random::random_bytes(length).each_byte do |i|
280         name << FILENAME_CHARS[i % FILENAME_CHARS.length]
281       end
282       name
283     end
284     def gen_random_pass
285       gen_random_base32(settings.random_pass_length)
286     end
287     def remap_base32_extra_characters(str)
288       map = {}
289       FILENAME_CHARS.each { |c| map[c] = c; map[c.upcase] = c }
290       map.merge!({ '1' => 'l', '0' => 'o' })
291       result = ''
292       str.each_char { |c| result << map[c] if map[c] }
293       result
294     end
295   end
296 end