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