store 'Expire-at' as integer instead of string
[coquelicot.git] / coquelicot.rb
1 require 'sinatra'
2 require 'haml'
3 require 'digest/sha1'
4 require 'base64'
5 require 'openssl'
6 require 'yaml'
7 require 'lockfile'
8 require 'singleton'
9
10 enable :inline_templates
11
12 set :upload_password, '0e5f7d398e6f9cd1f6bac5cc823e363aec636495'
13 set :default_expire, 60 # 1 hour
14 set :filename_length, 20
15 set :random_pass_length, 16
16 set :lockfile_options, { :timeout => 60,
17                          :max_age => 8,
18                          :refresh => 2,
19                          :debug   => false }
20
21 class BadKey < StandardError; end
22
23 class StoredFile
24   BUFFER_LEN = 4096
25
26   attr_reader :meta, :expire_at
27
28   def self.open(path, pass = nil)
29     StoredFile.new(path, pass)
30   end
31
32   def each
33     # output content
34     yield @initial_content
35     @initial_content = nil
36     until (buf = @file.read(BUFFER_LEN)).nil?
37       yield @cipher.update(buf)
38     end
39     yield @cipher.final
40     @cipher.reset
41     @cipher = nil
42   end
43
44   def mtime
45     @file.mtime
46   end
47
48   def self.create(src, pass, meta)
49     salt = gen_salt
50     clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
51                    "Salt" => Base64.encode64(salt).strip,
52                    "Expire-at" => meta.delete('Expire-at') }
53     yield YAML.dump(clear_meta) + YAML_START
54
55     cipher = get_cipher(pass, salt, :encrypt)
56     yield cipher.update(YAML.dump(meta) + YAML_START)
57     src.rewind
58     while not (buf = src.read(BUFFER_LEN)).nil?
59       yield cipher.update(buf)
60     end
61     yield cipher.final
62   end
63
64 private
65
66   YAML_START = "--- \n"
67   CIPHER = 'AES-256-CBC'
68   SALT_LEN = 8
69   COQUELICOT_VERSION = "1.0"
70
71   def self.get_cipher(pass, salt, method)
72     hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(pass, salt, 2000, 48)
73     cipher = OpenSSL::Cipher.new CIPHER
74     cipher.method(method).call
75     cipher.key = hmac[0..31]
76     cipher.iv = hmac[32..-1]
77     cipher
78   end
79
80   def self.gen_salt
81     OpenSSL::Random::random_bytes(SALT_LEN)
82   end
83
84   def initialize(path, pass)
85     @file = File.open(path)
86     if YAML_START != (buf = @file.read(YAML_START.length)) then
87       raise "unknown file, read #{buf.inspect}"
88     end
89     parse_clear_meta
90     return if pass.nil?
91     init_decrypt_cipher pass
92     parse_meta
93   end
94
95   def parse_clear_meta
96     meta = ''
97     until YAML_START == (line = @file.readline) do
98       meta += line
99     end
100     @meta = YAML.load(meta)
101     if @meta["Coquelicot"].nil? or @meta["Coquelicot"] != COQUELICOT_VERSION then
102       raise "unknown file"
103     end
104     @expire_at = Time.at(@meta['Expire-at'])
105   end
106
107   def init_decrypt_cipher(pass)
108     salt = Base64.decode64(@meta["Salt"])
109     @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
110   end
111
112   def parse_meta
113     yaml = ''
114     buf = @file.read(BUFFER_LEN)
115     content = @cipher.update(buf)
116     raise BadKey unless content.start_with? YAML_START
117     yaml << YAML_START
118     block = content.split(YAML_START, 3)
119     yaml << block[1]
120     if block.length == 3 then
121       @initial_content = block[2]
122       @meta.merge! YAML.load(yaml)
123       return
124     end
125
126     until (buf = @file.read(BUFFER_LEN)).nil? do
127       block = @cipher.update(buf).split(YAML_START, 3)
128       yaml << block[0]
129       break if block.length == 2
130     end
131     @initial_content = block[1]
132     @meta.merge! YAML.load(yaml)
133   end
134
135   def close
136     @cipher.reset unless @cipher.nil?
137     @file.close
138   end
139 end
140
141 class Depot
142   include Singleton
143
144   attr_accessor :path, :lockfile_options, :filename_length
145
146   def add_file(src, pass, options)
147     dst = nil
148     lockfile.lock do
149       dst = gen_random_file_name
150       File.open(full_path(dst), 'w').close
151     end
152     begin
153       File.open(full_path(dst), 'w') do |dest|
154         StoredFile.create(src, pass, options) { |data| dest.write data }
155       end
156     rescue
157       File.unlink full_path(dst)
158       raise
159     end
160     link = gen_random_file_name
161     add_link(link, dst)
162     link
163   end
164
165   def get_file(link, pass)
166     name = read_link(link)
167     return nil if name.nil?
168     StoredFile::open(full_path(name), pass)
169   end
170
171   def file_exists?(link)
172     name = read_link(link)
173     return !name.nil?
174   end
175
176   def gc!
177     files.each do |name|
178       remove_file(name) if Time.now > StoredFile::open(full_path(name)).expire_at
179     end
180   end
181
182 private
183
184   def lockfile
185     Lockfile.new "#{@path}/.lock", @lockfile_options
186   end
187
188   def links_path
189     "#{@path}/.links"
190   end
191
192   def add_link(src, dst)
193     lockfile.lock do
194       File.open(links_path, 'a') do |f|
195         f.write("#{src} #{dst}\n")
196       end
197     end
198   end
199
200   def remove_from_links(&block)
201     lockfile.lock do
202       links = []
203       File.open(links_path, 'r+') do |f|
204         f.readlines.each do |l|
205           links << l unless yield l
206         end
207         f.rewind
208         f.truncate(0)
209         f.write links.join
210       end
211     end
212   end
213
214   def remove_link(src)
215     remove_from_links { |l| l.start_with? "#{src} " }
216   end
217
218   def read_link(src)
219     dst = nil
220     lockfile.lock do
221       File.open(links_path) do |f|
222         begin
223           line = f.readline
224           if line.start_with? "#{src} " then
225             dst = line.split[1]
226             break
227           end
228         end until line.empty?
229       end
230     end
231     dst
232   end
233
234   def remove_file(name)
235     # zero the content before unlinking
236     File.open(full_path(name), 'r+') do |f|
237       f.seek 0, IO::SEEK_END
238       length = f.tell
239       f.rewind
240       while length > 0 do
241         write_len = [StoredFile::BUFFER_LEN, length].min
242         length -= f.write("\0" * write_len)
243       end
244     end
245     File.unlink full_path(name)
246     remove_from_links { |l| l.end_with? " #{name}" }
247   end
248
249   def files
250     lockfile.lock do
251       File.open(links_path) do |f|
252         f.readlines.collect { |l| l.split[1] }
253       end
254     end
255   end
256
257   def gen_random_file_name
258     begin
259       name = gen_random_base32(@filename_length)
260     end while File.exists?(full_path(name))
261     name
262   end
263
264   def full_path(name)
265     raise "Wrong name" unless name.each_char.collect { |c| FILENAME_CHARS.include? c }.all?
266     "#{@path}/#{name}"
267   end
268 end
269 def depot
270   @depot unless @depot.nil?
271
272   @depot = Depot.instance
273   @depot.path = options.depot_path if @depot.path.nil?
274   @depot.lockfile_options = options.lockfile_options if @depot.lockfile_options.nil?
275   @depot.filename_length = options.filename_length if @depot.filename_length.nil?
276   @depot
277 end
278
279 # Like RFC 4648 (Base32)
280 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)
281 def gen_random_base32(length)
282   name = ''
283   OpenSSL::Random::random_bytes(length).each_byte do |i|
284     name << FILENAME_CHARS[i % FILENAME_CHARS.length]
285   end
286   name
287 end
288 def gen_random_pass
289   gen_random_base32(options.random_pass_length)
290 end
291 def remap_base32_extra_characters(str)
292   map = {}
293   FILENAME_CHARS.each { |c| map[c] = c; map[c.upcase] = c }
294   map.merge!({ '1' => 'l', '0' => 'o' })
295   result = ''
296   str.each_char { |c| result << map[c] }
297   result
298 end
299
300 def password_match?(password)
301   return TRUE if settings.upload_password.nil?
302   (not password.nil?) && Digest::SHA1.hexdigest(password) == settings.upload_password
303 end
304
305 get '/style.css' do
306   content_type 'text/css', :charset => 'utf-8'
307   sass :style
308 end
309
310 get '/' do
311   haml :index
312 end
313
314 get '/random_pass' do
315   "#{gen_random_pass}"
316 end
317
318 get '/ready/:link' do |link|
319   link, pass = link.split '-' if link.include? '-'
320   begin
321     file = depot.get_file(link, nil)
322   rescue Errno::ENOENT => ex
323     not_found
324   end
325   @expire_at = file.expire_at
326   @base = request.url.gsub(/\/ready\/[^\/]*$/, '')
327   @name = "#{link}"
328   unless pass.nil?
329     @name << "-#{pass}"
330     @unprotected = true
331   end 
332   @url = "#{@base}/#{@name}"
333   haml :ready
334 end
335
336 post '/upload' do
337   unless password_match? params[:upload_password] then
338     error 403
339   end
340   if params[:file] then
341     tmpfile = params[:file][:tempfile]
342     name = params[:file][:filename]
343   end
344   if tmpfile.nil? || name.nil? then
345     @error = "No file selected"
346     return haml(:index)
347   end
348   if params[:expire].nil? or params[:expire].to_i == 0 then
349     params[:expire] = options.default_expire
350   end
351   expire_at = Time.now + 60 * params[:expire].to_i
352   if params[:file_key].nil? or params[:file_key].empty?then
353     pass = gen_random_pass
354   else
355     pass = params[:file_key]
356   end
357   src = params[:file][:tempfile]
358   link = depot.add_file(
359      src, pass,
360      { "Expire-at" => expire_at.to_i,
361        "Filename" => params[:file][:filename],
362        "Length" => src.stat.size,
363        "Content-Type" => params[:file][:type]
364      })
365   redirect "ready/#{link}-#{pass}" if params[:file_key].nil? or params[:file_key].empty?
366   redirect "ready/#{link}"
367 end
368
369 def expired
370   throw :halt, [410, haml(:expired)]
371 end
372
373 def send_stored_file(link, pass)
374   file = depot.get_file(link, pass)
375   return false if file.nil?
376   return expired if Time.now > file.expire_at
377
378   last_modified file.mtime.httpdate
379   attachment file.meta['Filename']
380   response['Content-Length'] = "#{file.meta['Length']}"
381   response['Content-Type'] = file.meta['Content-Type'] || 'application/octet-stream'
382   throw :halt, [200, file]
383 end
384
385 get '/:link-:pass' do |link, pass|
386   link = remap_base32_extra_characters(link)
387   pass = remap_base32_extra_characters(pass)
388   not_found unless send_stored_file(link, pass)
389 end
390
391 get '/:link' do |link|
392   link = remap_base32_extra_characters(link)
393   not_found unless depot.file_exists? link
394   @link = link
395   haml :enter_file_key
396 end
397
398 post '/:link' do |link|
399   pass = params[:file_key]
400   return 403 if pass.nil? or pass.empty?
401   begin
402     return 403 unless send_stored_file(link, pass)
403   rescue BadKey => ex
404     403
405   end
406 end
407
408 helpers do
409   def base_href
410     url = request.scheme + "://"
411     url << request.host
412     if request.scheme == "https" && request.port != 443 ||
413         request.scheme == "http" && request.port != 80
414       url << ":#{request.port}"
415     end
416     url << request.script_name
417     "#{url}/"
418   end
419 end
420
421 __END__
422
423 @@ layout
424 !!! XML
425 !!! Strict
426 %html
427   %head
428     %title coquelicot
429     %base{ :href => base_href }
430     %link{ :rel => 'stylesheet', :href => "style.css", :type => 'text/css',
431            :media => "screen, projection" }
432     %script{ :type => 'text/javascript', :src => 'javascripts/jquery.min.js' }
433     %script{ :type => 'text/javascript', :src => 'javascripts/jquery.lightBoxFu.js' }
434     %script{ :type => 'text/javascript', :src => 'javascripts/jquery.uploadProgress.js' }
435     :javascript
436       var generateRandomPassword = 'Generate random';
437       var generatingRandomPassword = 'Generating…';
438     %script{ :type => 'text/javascript', :src => 'javascripts/coquelicot.js' }
439   %body
440     #container
441       = yield
442     #footer
443       %span Coquelicot © 2010 potager.org
444       %span
445         —
446         %a{ :href => 'http://www.gnu.org/licenses/agpl.txt' } AGPLv3
447         —
448       %span
449         %code git clone #{base_href}coquelicot.git
450
451 @@ index
452 %h1 Share a file!
453 - unless @error.nil?
454   .error= @error
455 %form#upload{ :enctype => 'multipart/form-data',
456               :action  => 'upload', :method => 'post' }
457   .field
458     %label{ :for => 'upload_password' } Upload password:
459     %input.input{ :type => 'password', :id => 'upload_password', :name => 'upload_password' }
460   .field
461     %label{ :for => 'file' } File:
462     %input.input{ :type => 'file', id => 'file', :name => 'file' }
463   .field
464     %label{ :for => 'expire' } Available for:
465     %select.input{ :id => 'expire',:name => 'expire' }
466       %option{ :value => 5            } 5 minutes
467       %option{ :value => 60           } 1 hour
468       %option{ :value => 60 * 24      } 1 day
469       %option{ :value => 60 * 24 * 7  } 1 week
470       %option{ :value => 60 * 24 * 30 } 1 month
471   .field
472     %label{ :for => 'file_key' } Download password:
473     %input.input{ :type => 'password', :id => 'file_key', :name => 'file_key' }
474   .field
475     .submit
476       %input.submit{ :type => 'submit', :value => 'Share!' }
477
478 @@ ready
479 %h1 Pass this on!
480 #content
481   .url
482     %a{ :href => @url }
483       %span.base> #{@base}/
484       %span.name= @name
485   - unless @unprotected
486     %p A password is required to download this file.
487   %p The file will be available until #{@expire_at}.
488   .again
489     %a{ :href => base_href } Share another file…
490
491 @@ enter_file_key
492 %h1 Enter download password…
493 #content
494   %form{ :action => @link, :method => 'post' }
495     .field
496       %label{ :for => 'file_key' } Password:
497       %input{ :type => 'text', :id => 'file_key', :name => 'file_key' }
498     .field
499       .submit
500         %input{ :type => 'submit', :value => 'Get file' }
501
502 @@ expired
503 %h1 Too late…
504 #content
505   %p Sorry, file has expired.
506
507 @@ style
508 $green: #00ff26
509
510 body
511   background-color: $green
512   font-family: Georgia
513   color: darkgreen
514
515 a, a:visited
516   text-decoration: underline
517   color: blue
518
519 .error
520   background-color: red
521   color: white
522   border: black solid 1px
523
524 h1
525   margin-top: 0.1ex
526   border-bottom: solid 1px #ccc
527   text-align: center
528
529 #container
530   width: 550px
531   margin: 2em auto
532   -moz-border-radius: 25px
533   -webkit-border-radius: 25px
534   background: white
535   border: solid 1px black
536   padding: 5px 25px
537
538 .url
539   text-align: center
540
541 .url a
542   text-decoration: none
543   color: black
544
545 .url .base
546   display: block
547   font-size: small
548
549 .url .name
550   display: block
551   font-size: x-large
552   white-space: nowrap
553
554 .again
555   margin-top: 1ex
556   text-align: right
557
558 .field label
559   float: left
560   width: 12em
561   text-align: right
562
563 .input, .random-pass
564   float: left
565   width: 15em
566
567 .random-pass
568   font-family: monospace
569   font-size: large
570   color: black
571
572 #gen_pass
573   font-size: small
574
575 .field
576   clear: left
577
578 .submit
579   text-align: center
580
581 #progress
582   margin: 8px
583   width: 220px
584   height: 19px
585
586 #progressbar
587   background: url('images/ajax-loader.gif') no-repeat
588   width: 0px
589   height: 19px
590
591 #footer
592   margin-top: 7em
593   padding-top: 1em
594   border-top: dashed 1px black
595   text-align: center
596   font-size: small