implement 'should not store an uploaded file in cleartext' test
[coquelicot.git] / coquelicot.rb
1 require 'sinatra'
2 require 'haml'
3 require 'digest/sha1'
4 require 'base64'
5 require 'openssl'
6 require 'yaml'
7
8 enable :inline_templates
9
10 set :upload_password, '0e5f7d398e6f9cd1f6bac5cc823e363aec636495'
11
12 class StoredFile
13   attr_reader :meta
14
15   def self.open(path, pass)
16     StoredFile.new(path, pass)
17   end
18
19   def each
20     # output content
21     yield @initial_content
22     @initial_content = nil
23     until (buf = @file.read(BUFFER_LEN)).nil?
24       yield @cipher.update(buf)
25     end
26     yield @cipher.final
27     @cipher.reset
28     @cipher = nil
29   end
30
31   def self.create(src, pass, meta)
32     salt = gen_salt
33     clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
34                    "Salt" => Base64.encode64(salt).strip }
35     yield YAML.dump(clear_meta) + YAML_START
36
37     cipher = get_cipher(pass, salt, :encrypt)
38     yield cipher.update(YAML.dump(meta) + YAML_START)
39     src.rewind
40     while not (buf = src.read(BUFFER_LEN)).nil?
41       yield cipher.update(buf)
42     end
43     yield cipher.final
44   end
45
46 private
47
48   YAML_START = "--- \n"
49   CIPHER = 'AES-256-CBC'
50   SALT_LEN = 8
51   BUFFER_LEN = 4096
52   COQUELICOT_VERSION = "1.0"
53
54   def self.get_cipher(pass, salt, method)
55     hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(pass, salt, 2000, 48)
56     cipher = OpenSSL::Cipher.new CIPHER
57     cipher.method(method).call
58     cipher.key = hmac[0..31]
59     cipher.iv = hmac[32..-1]
60     cipher
61   end
62
63   def self.gen_salt
64     OpenSSL::Random::random_bytes(SALT_LEN)
65   end
66
67   def initialize(path, pass)
68     @file = File.open(path)
69     if YAML_START != (buf = @file.read(YAML_START.length)) then
70       raise "unknown file, read #{buf.inspect}"
71     end
72     parse_clear_meta
73     init_decrypt_cipher pass
74     parse_meta
75   end
76
77   def parse_clear_meta
78     meta = ''
79     until YAML_START == (line = @file.readline) do
80       meta += line
81     end
82     @meta = YAML.load(meta)
83     if @meta["Coquelicot"].nil? or @meta["Coquelicot"] != COQUELICOT_VERSION then
84       raise "unknown file"
85     end
86   end
87
88   def init_decrypt_cipher(pass)
89     salt = Base64.decode64(@meta["Salt"])
90     @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
91   end
92
93   def parse_meta
94     yaml = ''
95     buf = @file.read(BUFFER_LEN)
96     content = @cipher.update(buf)
97     raise "bad key" unless content.start_with? YAML_START
98     yaml << YAML_START
99     block = content.split(YAML_START, 3)
100     yaml << block[1]
101     if block.length == 3 then
102       @initial_content = block[2]
103       @meta.merge! YAML.load(yaml)
104       return
105     end
106
107     until (buf = @file.read(BUFFER_LEN)).nil? do
108       block = @cipher.update(buf).split(YAML_START, 3)
109       yaml << block[0]
110       break if block.length == 2
111     end
112     @initial_content = block[1]
113     @meta.merge! YAML.load(yaml)
114   end
115
116   def close
117     @cipher.reset unless @cipher.nil?
118     @file.close
119   end
120 end
121
122 def password_match?(password)
123   return TRUE if settings.upload_password.nil?
124   (not password.nil?) && Digest::SHA1.hexdigest(password) == settings.upload_password
125 end
126
127 def uploaded_file(file)
128   "#{options.depot_path}/#{file}"
129 end
130
131 get '/style.css' do
132   content_type 'text/css', :charset => 'utf-8'
133   sass :style
134 end
135
136 get '/' do
137   haml :index
138 end
139
140 get '/ready/:name' do |name|
141   path = uploaded_file(name)
142   unless File.exists? path then
143     not_found
144   end
145   base = request.url.gsub(/\/ready\/[^\/]*$/, '')
146   @url = "#{base}/#{name}"
147   haml :ready
148 end
149
150 get '/:name' do |name|
151   path = uploaded_file(name)
152   unless File.exists? path then
153     not_found
154   end
155   file = StoredFile.open(path, 'XXXsecret')
156   last_modified File.mtime(path).httpdate
157   attachment file.meta['Filename']
158   response['Content-Length'] = "#{file.meta['Length']}"
159   response['Content-Type'] = file.meta['Content-Type'] || 'application/octet-stream'
160   throw :halt, [200, file]
161 end
162
163 post '/upload' do
164   unless password_match? params[:upload_password] then
165     error 403
166   end
167   if params[:file] then
168     tmpfile = params[:file][:tempfile]
169     name = params[:file][:filename]
170   end
171   if tmpfile.nil? || name.nil? then
172     @error = "No file selected"
173     return haml(:index)
174   end
175   src = params[:file][:tempfile]
176   File.open(uploaded_file(name), 'w') do |dest|
177     StoredFile.create(
178      src,
179      'XXXsecret',
180      { "Filename" => params[:file][:filename],
181        "Length" => src.stat.size,
182        "Content-Type" => params[:file][:type]
183      }) { |data| dest.write data }
184   end
185   redirect "ready/#{name}"
186 end
187
188 helpers do
189   def base_href
190     url = request.scheme + "://"
191     url << request.host
192     if request.scheme == "https" && request.port != 443 ||
193         request.scheme == "http" && request.port != 80
194       url << ":#{request.port}"
195     end
196     url << request.script_name
197     "#{url}/"
198   end
199 end
200
201 __END__
202
203 @@ layout
204 %html
205   %head
206     %title coquelicot
207     %base{ :href => base_href }
208     %link{ :rel => 'stylesheet', :href => "style.css", :type => 'text/css',
209            :media => "screen, projection" }
210     %script{ :type => 'text/javascript', :src => 'javascripts/jquery.min.js' }
211     %script{ :type => 'text/javascript', :src => 'javascripts/jquery.lightBoxFu.js' }
212     %script{ :type => 'text/javascript', :src => 'javascripts/jquery.uploadProgress.js' }
213     %script{ :type => 'text/javascript', :src => 'javascripts/coquelicot.js' }
214   %body
215     #container
216       = yield
217
218 @@ index
219 %h1 Upload!
220 - unless @error.nil?
221   .error= @error
222 %form#upload{ :enctype => 'multipart/form-data',
223               :action  => 'upload', :method => 'post' }
224   .field
225     %input{ :type => 'file', :name => 'file' }
226   .field
227     %input{ :type => 'submit', :value => 'Send file' }
228
229 @@ ready
230 %h1 Pass this on!
231 .url
232   %a{ :href => @url }= @url
233
234 @@ style
235 $green: #00ff26
236
237 body
238   background-color: $green
239   font-family: Georgia
240   color: darkgreen
241
242 a, a:visited
243   text-decoration: underline
244   color: white
245
246 .error
247   background-color: red
248   color: white
249   border: black solid 1px
250
251 #progress
252   margin: 8px
253   width: 220px
254   height: 19px
255
256 #progressbar
257   background: url('images/ajax-loader.gif') no-repeat
258   width: 0px
259   height: 19px