7 class BadKey < StandardError; end
12 attr_reader :path, :meta, :expire_at
14 def self.open(path, pass = nil)
15 StoredFile.new(path, pass)
20 yield @initial_content
21 @initial_content = nil
22 until (buf = @file.read(BUFFER_LEN)).nil?
23 yield @cipher.update(buf)
31 Time.at(@meta['Created-at'])
39 @meta['One-time-only'] && @meta['One-time-only'] == 'true'
42 def exclusively(&block)
45 new_path = "#{old_path}.#{Coquelicot.gen_random_base32(16)}"
46 end while File.exists? new_path
47 File.rename(old_path, new_path)
49 File.open(old_path, 'w').close
53 File.rename(new_path, old_path)
57 def self.create(src, pass, meta)
59 clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
60 "Salt" => Base64.encode64(salt).strip,
61 "Expire-at" => meta.delete('Expire-at'),
63 yield YAML.dump(clear_meta) + YAML_START
65 cipher = get_cipher(pass, salt, :encrypt)
66 yield cipher.update(YAML.dump(meta.merge("Created-at" => Time.now.to_i)) +
69 while not (buf = src.read(BUFFER_LEN)).nil?
70 yield cipher.update(buf)
76 # zero the content before truncating
77 File.open(@path, 'r+') do |f|
78 f.seek 0, IO::SEEK_END
82 write_len = [StoredFile::BUFFER_LEN, length].min
83 length -= f.write("\0" * write_len)
87 File.truncate(@path, 0)
93 CIPHER = 'AES-256-CBC'
95 COQUELICOT_VERSION = "1.0"
97 def self.get_cipher(pass, salt, method)
98 hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(pass, salt, 2000, 48)
99 cipher = OpenSSL::Cipher.new CIPHER
100 cipher.method(method).call
101 cipher.key = hmac[0..31]
102 cipher.iv = hmac[32..-1]
107 OpenSSL::Random::random_bytes(SALT_LEN)
110 def initialize(path, pass)
112 @file = File.open(@path)
113 if @file.lstat.size == 0 then
114 @expire_at = Time.now - 1
118 if YAML_START != (buf = @file.read(YAML_START.length)) then
119 raise "unknown file, read #{buf.inspect}"
123 init_decrypt_cipher pass
129 until YAML_START == (line = @file.readline) do
132 @meta = YAML.load(meta)
133 if @meta["Coquelicot"].nil? or @meta["Coquelicot"] != COQUELICOT_VERSION then
136 @expire_at = Time.at(@meta['Expire-at'])
139 def init_decrypt_cipher(pass)
140 salt = Base64.decode64(@meta["Salt"])
141 @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
146 buf = @file.read(BUFFER_LEN)
147 content = @cipher.update(buf)
148 raise BadKey unless content.start_with? YAML_START
150 block = content.split(YAML_START, 3)
152 if block.length == 3 then
153 @initial_content = block[2]
154 @meta.merge! YAML.load(yaml)
158 until (buf = @file.read(BUFFER_LEN)).nil? do
159 block = @cipher.update(buf).split(YAML_START, 3)
161 break if block.length == 2
163 @initial_content = block[1]
164 @meta.merge! YAML.load(yaml)
168 @cipher.reset unless @cipher.nil?
174 LOCKFILE_OPTIONS = { :timeout => 60,
185 def add_file(src, pass, options)
188 dst = gen_random_file_name
189 File.open(full_path(dst), 'w').close
192 File.open(full_path(dst), 'w') do |dest|
193 StoredFile.create(src, pass, options) { |data| dest.write data }
196 File.unlink full_path(dst)
199 link = gen_random_file_name
204 def get_file(link, pass=nil)
205 name = read_link(link)
206 return nil if name.nil?
207 StoredFile::open(full_path(name), pass)
210 def file_exists?(link)
211 name = read_link(link)
217 path = full_path(name)
218 if File.lstat(path).size > 0
219 file = StoredFile::open path
220 file.empty! if file.expired?
221 elsif Time.now - File.lstat(path).mtime > (Coquelicot.settings.gone_period * 60)
222 remove_from_links { |l| l.strip.end_with? " #{name}" }
231 Lockfile.new "#{@path}/.lock", LOCKFILE_OPTIONS
238 def add_link(src, dst)
240 File.open(links_path, 'a') do |f|
241 f.write("#{src} #{dst}\n")
246 def remove_from_links(&block)
249 File.open(links_path, 'r+') do |f|
250 f.readlines.each do |l|
251 links << l unless yield l
261 remove_from_links { |l| l.start_with? "#{src} " }
267 File.open(links_path) do |f|
269 line = f.readline rescue break
270 if line.start_with? "#{src} " then
274 end until line.empty?
282 File.open(links_path) do |f|
283 f.readlines.collect { |l| l.split[1] }
288 def gen_random_file_name
290 name = Coquelicot.gen_random_base32(Coquelicot.settings.filename_length)
291 end while File.exists?(full_path(name))
296 raise "Wrong name" unless name.each_char.collect { |c| Coquelicot::FILENAME_CHARS.include? c }.all?
301 DEFAULT_SETTINGS = { :default_expire => 60,
302 :gone_period => 10080,
303 :filename_length => 20,
304 :random_pass_length => 16,
307 # Like RFC 4648 (Base32)
308 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)
313 @settings = DEFAULT_SETTINGS.merge(settings)
314 @settings.each_key do |k|
315 @settings.class.send(:define_method, k) { self[k] }
322 @settings ||= setup({})
326 @depot ||= Depot.new(settings.depot_path)
329 def gen_random_base32(length)
331 OpenSSL::Random::random_bytes(length).each_byte do |i|
332 name << FILENAME_CHARS[i % FILENAME_CHARS.length]
337 gen_random_base32(settings.random_pass_length)
339 def remap_base32_extra_characters(str)
341 FILENAME_CHARS.each { |c| map[c] = c; map[c.upcase] = c }
342 map.merge!({ '1' => 'l', '0' => 'o' })
344 str.each_char { |c| result << map[c] }