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