implement 'should not allow retrieval of a password protected file with a wrong password'
[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 :filename_length, 20
14 set :random_pass_length, 16
15 set :lockfile_options, { :timeout => 60,
16                          :max_age => 8,
17                          :refresh => 2,
18                          :debug   => false }
19
20 class BadKey < StandardError; end
21
22 class StoredFile
23   attr_reader :meta
24
25   def self.open(path, pass)
26     StoredFile.new(path, pass)
27   end
28
29   def each
30     # output content
31     yield @initial_content
32     @initial_content = nil
33     until (buf = @file.read(BUFFER_LEN)).nil?
34       yield @cipher.update(buf)
35     end
36     yield @cipher.final
37     @cipher.reset
38     @cipher = nil
39   end
40
41   def mtime
42     @file.mtime
43   end
44
45   def self.create(src, pass, meta)
46     salt = gen_salt
47     clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
48                    "Salt" => Base64.encode64(salt).strip }
49     yield YAML.dump(clear_meta) + YAML_START
50
51     cipher = get_cipher(pass, salt, :encrypt)
52     yield cipher.update(YAML.dump(meta) + YAML_START)
53     src.rewind
54     while not (buf = src.read(BUFFER_LEN)).nil?
55       yield cipher.update(buf)
56     end
57     yield cipher.final
58   end
59
60 private
61
62   YAML_START = "--- \n"
63   CIPHER = 'AES-256-CBC'
64   SALT_LEN = 8
65   BUFFER_LEN = 4096
66   COQUELICOT_VERSION = "1.0"
67
68   def self.get_cipher(pass, salt, method)
69     hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(pass, salt, 2000, 48)
70     cipher = OpenSSL::Cipher.new CIPHER
71     cipher.method(method).call
72     cipher.key = hmac[0..31]
73     cipher.iv = hmac[32..-1]
74     cipher
75   end
76
77   def self.gen_salt
78     OpenSSL::Random::random_bytes(SALT_LEN)
79   end
80
81   def initialize(path, pass)
82     @file = File.open(path)
83     if YAML_START != (buf = @file.read(YAML_START.length)) then
84       raise "unknown file, read #{buf.inspect}"
85     end
86     parse_clear_meta
87     init_decrypt_cipher pass
88     parse_meta
89   end
90
91   def parse_clear_meta
92     meta = ''
93     until YAML_START == (line = @file.readline) do
94       meta += line
95     end
96     @meta = YAML.load(meta)
97     if @meta["Coquelicot"].nil? or @meta["Coquelicot"] != COQUELICOT_VERSION then
98       raise "unknown file"
99     end
100   end
101
102   def init_decrypt_cipher(pass)
103     salt = Base64.decode64(@meta["Salt"])
104     @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
105   end
106
107   def parse_meta
108     yaml = ''
109     buf = @file.read(BUFFER_LEN)
110     content = @cipher.update(buf)
111     raise BadKey unless content.start_with? YAML_START
112     yaml << YAML_START
113     block = content.split(YAML_START, 3)
114     yaml << block[1]
115     if block.length == 3 then
116       @initial_content = block[2]
117       @meta.merge! YAML.load(yaml)
118       return
119     end
120
121     until (buf = @file.read(BUFFER_LEN)).nil? do
122       block = @cipher.update(buf).split(YAML_START, 3)
123       yaml << block[0]
124       break if block.length == 2
125     end
126     @initial_content = block[1]
127     @meta.merge! YAML.load(yaml)
128   end
129
130   def close
131     @cipher.reset unless @cipher.nil?
132     @file.close
133   end
134 end
135
136 class Depot
137   include Singleton
138
139   attr_accessor :path, :lockfile_options, :filename_length
140
141   def add_file(src, pass, options)
142     dst = nil
143     lockfile.lock do
144       dst = gen_random_file_name
145       File.open(full_path(dst), 'w').close
146     end
147     begin
148       File.open(full_path(dst), 'w') do |dest|
149         StoredFile.create(src, pass, options) { |data| dest.write data }
150       end
151     rescue
152       File.unlink full_path(dst)
153       raise
154     end
155     link = gen_random_file_name
156     add_link(link, dst)
157     link
158   end
159
160   def get_file(link, pass)
161     name = read_link(link)
162     return nil if name.nil?
163     StoredFile::open(full_path(name), pass)
164   end
165
166   def file_exists?(link)
167     name = read_link(link)
168     return !name.nil?
169   end
170
171 private
172
173   def lockfile
174     Lockfile.new "#{@path}/.lock", @lockfile_options
175   end
176
177   def links_path
178     "#{@path}/.links"
179   end
180
181   def add_link(src, dst)
182     lockfile.lock do
183       File.open(links_path, 'a') do |f|
184         f.write("#{src} #{dst}\n")
185       end
186     end
187   end
188
189   def remove_link(src)
190     lockfile.lock do
191       links = []
192       File.open(links_path, 'r+') do |f|
193         f.readlines.each do |l|
194           links << l unless l.start_with? "#{src} "
195         end
196         f.rewind
197         f.truncate(0)
198         f.write links.join
199       end
200     end
201   end
202
203   def read_link(src)
204     dst = nil
205     lockfile.lock do
206       File.open(links_path) do |f|
207         begin
208           line = f.readline
209           if line.start_with? "#{src} " then
210             dst = line.split[1]
211             break
212           end
213         end until line.empty?
214       end
215     end
216     dst
217   end
218
219   def gen_random_file_name
220     begin
221       name = gen_random_base32(@filename_length)
222     end while File.exists?(full_path(name))
223     name
224   end
225
226   def full_path(name)
227     raise "Wrong name" unless name.each_char.collect { |c| FILENAME_CHARS.include? c }.all?
228     "#{@path}/#{name}"
229   end
230 end
231 def depot
232   @depot unless @depot.nil?
233
234   @depot = Depot.instance
235   @depot.path = options.depot_path if @depot.path.nil?
236   @depot.lockfile_options = options.lockfile_options if @depot.lockfile_options.nil?
237   @depot.filename_length = options.filename_length if @depot.filename_length.nil?
238   @depot
239 end
240
241 # Like RFC 4648 (Base32)
242 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)
243 def gen_random_base32(length)
244   name = ''
245   OpenSSL::Random::random_bytes(length).each_byte do |i|
246     name << FILENAME_CHARS[i % FILENAME_CHARS.length]
247   end
248   name
249 end
250 def gen_random_pass
251   gen_random_base32(options.random_pass_length)
252 end
253
254 def password_match?(password)
255   return TRUE if settings.upload_password.nil?
256   (not password.nil?) && Digest::SHA1.hexdigest(password) == settings.upload_password
257 end
258
259 get '/style.css' do
260   content_type 'text/css', :charset => 'utf-8'
261   sass :style
262 end
263
264 get '/' do
265   haml :index
266 end
267
268 get '/ready/:link' do |link|
269   link, pass = link.split '-' if link.include? '-'
270   unless depot.file_exists? link then
271     not_found
272   end
273   base = request.url.gsub(/\/ready\/[^\/]*$/, '')
274   @url = "#{base}/#{link}-#{pass}" unless pass.nil?
275   @url ||= "#{base}/#{link}"
276   haml :ready
277 end
278
279 post '/upload' do
280   unless password_match? params[:upload_password] then
281     error 403
282   end
283   if params[:file] then
284     tmpfile = params[:file][:tempfile]
285     name = params[:file][:filename]
286   end
287   if tmpfile.nil? || name.nil? then
288     @error = "No file selected"
289     return haml(:index)
290   end
291   if params[:file_key].nil? or params[:file_key].empty?then
292     pass = gen_random_pass
293   else
294     pass = params[:file_key]
295   end
296   src = params[:file][:tempfile]
297   link = depot.add_file(
298      src, pass,
299      { "Filename" => params[:file][:filename],
300        "Length" => src.stat.size,
301        "Content-Type" => params[:file][:type]
302      })
303   redirect "ready/#{link}-#{pass}" if params[:file_key].nil? or params[:file_key].empty?
304   redirect "ready/#{link}"
305 end
306
307 def send_stored_file(link, pass)
308   file = depot.get_file(link, pass)
309   return false if file.nil?
310
311   last_modified file.mtime.httpdate
312   attachment file.meta['Filename']
313   response['Content-Length'] = "#{file.meta['Length']}"
314   response['Content-Type'] = file.meta['Content-Type'] || 'application/octet-stream'
315   throw :halt, [200, file]
316 end
317
318 get '/:link' do |link|
319   if link.include? '-'
320     link, pass = link.split '-'
321     not_found unless send_stored_file(link, pass)
322   end
323   not_found unless depot.file_exists? link
324   @link = link
325   haml :enter_file_key
326 end
327
328 post '/:link' do |link|
329   pass = params[:file_key]
330   return 403 if pass.nil? or pass.empty?
331   begin
332     return 403 unless send_stored_file(link, pass)
333   rescue BadKey => ex
334     403
335   end
336 end
337
338 helpers do
339   def base_href
340     url = request.scheme + "://"
341     url << request.host
342     if request.scheme == "https" && request.port != 443 ||
343         request.scheme == "http" && request.port != 80
344       url << ":#{request.port}"
345     end
346     url << request.script_name
347     "#{url}/"
348   end
349 end
350
351 __END__
352
353 @@ layout
354 %html
355   %head
356     %title coquelicot
357     %base{ :href => base_href }
358     %link{ :rel => 'stylesheet', :href => "style.css", :type => 'text/css',
359            :media => "screen, projection" }
360     %script{ :type => 'text/javascript', :src => 'javascripts/jquery.min.js' }
361     %script{ :type => 'text/javascript', :src => 'javascripts/jquery.lightBoxFu.js' }
362     %script{ :type => 'text/javascript', :src => 'javascripts/jquery.uploadProgress.js' }
363     %script{ :type => 'text/javascript', :src => 'javascripts/coquelicot.js' }
364   %body
365     #container
366       = yield
367
368 @@ index
369 %h1 Upload!
370 - unless @error.nil?
371   .error= @error
372 %form#upload{ :enctype => 'multipart/form-data',
373               :action  => 'upload', :method => 'post' }
374   .field
375     %input{ :type => 'file', :name => 'file' }
376   .field
377     %input{ :type => 'submit', :value => 'Send file' }
378
379 @@ ready
380 %h1 Pass this on!
381 .url
382   %a{ :href => @url }= @url
383
384 @@ enter_file_key
385 %h1 Enter file key…
386 %form{ :action => @link, :method => 'post' }
387   .field
388     %input{ :type => 'text', :id => 'file_key', :name => 'file_key' }
389   .field
390     %input{ :type => 'submit', :value => 'Get file' }
391
392 @@ style
393 $green: #00ff26
394
395 body
396   background-color: $green
397   font-family: Georgia
398   color: darkgreen
399
400 a, a:visited
401   text-decoration: underline
402   color: white
403
404 .error
405   background-color: red
406   color: white
407   border: black solid 1px
408
409 #progress
410   margin: 8px
411   width: 220px
412   height: 19px
413
414 #progressbar
415   background: url('images/ajax-loader.gif') no-repeat
416   width: 0px
417   height: 19px