95f87e95e2fcaba1c74fa5a1b5af94f23ee42219
[coquelicot.git] / coquelicot.rb
1 $:.unshift File.join(File.dirname(__FILE__), 'lib')
2
3 require 'sinatra'
4 require 'haml'
5 require 'digest/sha1'
6 require 'base64'
7 require 'openssl'
8 require 'yaml'
9 require 'lockfile'
10 require 'singleton'
11 require 'gettext'
12 require 'haml_gettext'
13
14 set :upload_password, '0e5f7d398e6f9cd1f6bac5cc823e363aec636495'
15 set :default_expire, 60 # 1 hour
16 set :gone_period, 10080 # 1 week
17 set :filename_length, 20
18 set :random_pass_length, 16
19 set :lockfile_options, { :timeout => 60,
20                          :max_age => 8,
21                          :refresh => 2,
22                          :debug   => false }
23
24 class BadKey < StandardError; end
25
26 class StoredFile
27   BUFFER_LEN = 4096
28
29   attr_reader :path, :meta, :expire_at
30
31   def self.open(path, pass = nil)
32     StoredFile.new(path, pass)
33   end
34
35   def each
36     # output content
37     yield @initial_content
38     @initial_content = nil
39     until (buf = @file.read(BUFFER_LEN)).nil?
40       yield @cipher.update(buf)
41     end
42     yield @cipher.final
43     @cipher.reset
44     @cipher = nil
45   end
46
47   def created_at
48     Time.at(@meta['Created-at'])
49   end
50
51   def expired?
52     @expire_at < Time.now
53   end
54
55   def one_time_only?
56     @meta['One-time-only'] && @meta['One-time-only'] == 'true'
57   end
58
59   def exclusively(&block)
60     old_path = @path
61     begin
62       new_path = "#{old_path}.#{gen_random_base32(16)}"
63     end while File.exists? new_path
64     File.rename(old_path, new_path)
65     @path = new_path
66     File.open(old_path, 'w').close
67     begin
68       yield
69     ensure
70       File.rename(new_path, old_path)
71     end
72   end
73
74   def self.create(src, pass, meta)
75     salt = gen_salt
76     clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
77                    "Salt" => Base64.encode64(salt).strip,
78                    "Expire-at" => meta.delete('Expire-at'),
79                  }
80     yield YAML.dump(clear_meta) + YAML_START
81
82     cipher = get_cipher(pass, salt, :encrypt)
83     yield cipher.update(YAML.dump(meta.merge("Created-at" => Time.now.to_i)) +
84                         YAML_START)
85     src.rewind
86     while not (buf = src.read(BUFFER_LEN)).nil?
87       yield cipher.update(buf)
88     end
89     yield cipher.final
90   end
91
92   def empty!
93     # zero the content before truncating
94     File.open(@path, 'r+') do |f|
95       f.seek 0, IO::SEEK_END
96       length = f.tell
97       f.rewind
98       while length > 0 do
99         write_len = [StoredFile::BUFFER_LEN, length].min
100         length -= f.write("\0" * write_len)
101       end
102       f.fsync
103     end
104     File.truncate(@path, 0)
105   end
106
107 private
108
109   YAML_START = "--- \n"
110   CIPHER = 'AES-256-CBC'
111   SALT_LEN = 8
112   COQUELICOT_VERSION = "1.0"
113
114   def self.get_cipher(pass, salt, method)
115     hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(pass, salt, 2000, 48)
116     cipher = OpenSSL::Cipher.new CIPHER
117     cipher.method(method).call
118     cipher.key = hmac[0..31]
119     cipher.iv = hmac[32..-1]
120     cipher
121   end
122
123   def self.gen_salt
124     OpenSSL::Random::random_bytes(SALT_LEN)
125   end
126
127   def initialize(path, pass)
128     @path = path
129     @file = File.open(@path)
130     if @file.lstat.size == 0 then
131       @expire_at = Time.now - 1
132       return
133     end
134
135     if YAML_START != (buf = @file.read(YAML_START.length)) then
136       raise "unknown file, read #{buf.inspect}"
137     end
138     parse_clear_meta
139     return if pass.nil?
140     init_decrypt_cipher pass
141     parse_meta
142   end
143
144   def parse_clear_meta
145     meta = ''
146     until YAML_START == (line = @file.readline) do
147       meta += line
148     end
149     @meta = YAML.load(meta)
150     if @meta["Coquelicot"].nil? or @meta["Coquelicot"] != COQUELICOT_VERSION then
151       raise "unknown file"
152     end
153     @expire_at = Time.at(@meta['Expire-at'])
154   end
155
156   def init_decrypt_cipher(pass)
157     salt = Base64.decode64(@meta["Salt"])
158     @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
159   end
160
161   def parse_meta
162     yaml = ''
163     buf = @file.read(BUFFER_LEN)
164     content = @cipher.update(buf)
165     raise BadKey unless content.start_with? YAML_START
166     yaml << YAML_START
167     block = content.split(YAML_START, 3)
168     yaml << block[1]
169     if block.length == 3 then
170       @initial_content = block[2]
171       @meta.merge! YAML.load(yaml)
172       return
173     end
174
175     until (buf = @file.read(BUFFER_LEN)).nil? do
176       block = @cipher.update(buf).split(YAML_START, 3)
177       yaml << block[0]
178       break if block.length == 2
179     end
180     @initial_content = block[1]
181     @meta.merge! YAML.load(yaml)
182   end
183
184   def close
185     @cipher.reset unless @cipher.nil?
186     @file.close
187   end
188 end
189
190 class Depot
191   include Singleton
192
193   attr_accessor :path, :lockfile_options, :filename_length, :gone_period
194
195   def add_file(src, pass, options)
196     dst = nil
197     lockfile.lock do
198       dst = gen_random_file_name
199       File.open(full_path(dst), 'w').close
200     end
201     begin
202       File.open(full_path(dst), 'w') do |dest|
203         StoredFile.create(src, pass, options) { |data| dest.write data }
204       end
205     rescue
206       File.unlink full_path(dst)
207       raise
208     end
209     link = gen_random_file_name
210     add_link(link, dst)
211     link
212   end
213
214   def get_file(link, pass=nil)
215     name = read_link(link)
216     return nil if name.nil?
217     StoredFile::open(full_path(name), pass)
218   end
219
220   def file_exists?(link)
221     name = read_link(link)
222     return !name.nil?
223   end
224
225   def gc!
226     files.each do |name|
227       path = full_path(name)
228       if File.lstat(path).size > 0
229         file = StoredFile::open path
230         file.empty! if file.expired?
231       elsif Time.now - File.lstat(path).mtime > (gone_period * 60)
232         remove_from_links { |l| l.strip.end_with? " #{name}" }
233         File.unlink path
234       end
235     end
236   end
237
238 private
239
240   def lockfile
241     Lockfile.new "#{@path}/.lock", @lockfile_options
242   end
243
244   def links_path
245     "#{@path}/.links"
246   end
247
248   def add_link(src, dst)
249     lockfile.lock do
250       File.open(links_path, 'a') do |f|
251         f.write("#{src} #{dst}\n")
252       end
253     end
254   end
255
256   def remove_from_links(&block)
257     lockfile.lock do
258       links = []
259       File.open(links_path, 'r+') do |f|
260         f.readlines.each do |l|
261           links << l unless yield l
262         end
263         f.rewind
264         f.truncate(0)
265         f.write links.join
266       end
267     end
268   end
269
270   def remove_link(src)
271     remove_from_links { |l| l.start_with? "#{src} " }
272   end
273
274   def read_link(src)
275     dst = nil
276     lockfile.lock do
277       File.open(links_path) do |f|
278         begin
279           line = f.readline rescue break
280           if line.start_with? "#{src} " then
281             dst = line.split[1]
282             break
283           end
284         end until line.empty?
285       end
286     end
287     dst
288   end
289
290   def files
291     lockfile.lock do
292       File.open(links_path) do |f|
293         f.readlines.collect { |l| l.split[1] }
294       end
295     end
296   end
297
298   def gen_random_file_name
299     begin
300       name = gen_random_base32(@filename_length)
301     end while File.exists?(full_path(name))
302     name
303   end
304
305   def full_path(name)
306     raise "Wrong name" unless name.each_char.collect { |c| FILENAME_CHARS.include? c }.all?
307     "#{@path}/#{name}"
308   end
309 end
310 def depot
311   @depot unless @depot.nil?
312
313   @depot = Depot.instance
314   @depot.path = options.depot_path if @depot.path.nil?
315   @depot.lockfile_options = options.lockfile_options if @depot.lockfile_options.nil?
316   @depot.filename_length = options.filename_length if @depot.filename_length.nil?
317   @depot.gone_period = options.gone_period if @depot.gone_period.nil?
318   @depot
319 end
320
321 # Like RFC 4648 (Base32)
322 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)
323 def gen_random_base32(length)
324   name = ''
325   OpenSSL::Random::random_bytes(length).each_byte do |i|
326     name << FILENAME_CHARS[i % FILENAME_CHARS.length]
327   end
328   name
329 end
330 def gen_random_pass
331   gen_random_base32(options.random_pass_length)
332 end
333 def remap_base32_extra_characters(str)
334   map = {}
335   FILENAME_CHARS.each { |c| map[c] = c; map[c.upcase] = c }
336   map.merge!({ '1' => 'l', '0' => 'o' })
337   result = ''
338   str.each_char { |c| result << map[c] }
339   result
340 end
341
342 def password_match?(password)
343   return TRUE if settings.upload_password.nil?
344   (not password.nil?) && Digest::SHA1.hexdigest(password) == settings.upload_password
345 end
346
347 GetText::bindtextdomain('coquelicot')
348 before do
349   GetText::set_current_locale(params[:lang] || request.env['HTTP_ACCEPT_LANGUAGE'] || 'en')
350 end
351
352 get '/style.css' do
353   content_type 'text/css', :charset => 'utf-8'
354   sass :style
355 end
356
357 get '/' do
358   haml :index
359 end
360
361 get '/random_pass' do
362   "#{gen_random_pass}"
363 end
364
365 get '/ready/:link' do |link|
366   link, pass = link.split '-' if link.include? '-'
367   begin
368     file = depot.get_file(link, nil)
369   rescue Errno::ENOENT => ex
370     not_found
371   end
372   @expire_at = file.expire_at
373   @base = request.url.gsub(/\/ready\/[^\/]*$/, '')
374   @name = "#{link}"
375   unless pass.nil?
376     @name << "-#{pass}"
377     @unprotected = true
378   end 
379   @url = "#{@base}/#{@name}"
380   haml :ready
381 end
382
383 post '/upload' do
384   unless password_match? params[:upload_password] then
385     error 403
386   end
387   if params[:file] then
388     tmpfile = params[:file][:tempfile]
389     name = params[:file][:filename]
390   end
391   if tmpfile.nil? || name.nil? then
392     @error = "No file selected"
393     return haml(:index)
394   end
395   if params[:expire].nil? or params[:expire].to_i == 0 then
396     params[:expire] = options.default_expire
397   end
398   expire_at = Time.now + 60 * params[:expire].to_i
399   one_time_only = params[:one_time] and params[:one_time] == 'true'
400   if params[:file_key].nil? or params[:file_key].empty?then
401     pass = gen_random_pass
402   else
403     pass = params[:file_key]
404   end
405   src = params[:file][:tempfile]
406   link = depot.add_file(
407      src, pass,
408      { "Expire-at" => expire_at.to_i,
409        "One-time-only" => one_time_only,
410        "Filename" => params[:file][:filename],
411        "Length" => src.stat.size,
412        "Content-Type" => params[:file][:type],
413      })
414   redirect "ready/#{link}-#{pass}" if params[:file_key].nil? or params[:file_key].empty?
415   redirect "ready/#{link}"
416 end
417
418 def expired
419   throw :halt, [410, haml(:expired)]
420 end
421
422 def send_stored_file(file)
423   last_modified file.created_at.httpdate
424   attachment file.meta['Filename']
425   response['Content-Length'] = "#{file.meta['Length']}"
426   response['Content-Type'] = file.meta['Content-Type'] || 'application/octet-stream'
427   throw :halt, [200, file]
428 end
429
430 def send_link(link, pass)
431   file = depot.get_file(link, pass)
432   return false if file.nil?
433   return expired if file.expired?
434
435   return send_stored_file(file) unless file.one_time_only?
436
437   file.exclusively do
438     begin  send_stored_file(file)
439     ensure file.empty!            end
440   end
441 end
442
443 get '/:link-:pass' do |link, pass|
444   link = remap_base32_extra_characters(link)
445   pass = remap_base32_extra_characters(pass)
446   not_found unless send_link(link, pass)
447 end
448
449 get '/:link' do |link|
450   link = remap_base32_extra_characters(link)
451   not_found unless depot.file_exists? link
452   @link = link
453   haml :enter_file_key
454 end
455
456 post '/:link' do |link|
457   pass = params[:file_key]
458   return 403 if pass.nil? or pass.empty?
459   begin
460     # send Forbidden even if file is not found
461     return 403 unless send_link(link, pass)
462   rescue BadKey => ex
463     403
464   end
465 end
466
467 helpers do
468   def base_href
469     url = request.scheme + "://"
470     url << request.host
471     if request.scheme == "https" && request.port != 443 ||
472         request.scheme == "http" && request.port != 80
473       url << ":#{request.port}"
474     end
475     url << request.script_name
476     "#{url}/"
477   end
478 end