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