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