change StoredFile.create interface
[coquelicot.git] / lib / coquelicot / app.rb
1 # Coquelicot: "one-click" file sharing with a focus on users' privacy.
2 # Copyright © 2010-2012 potager.org <jardiniers@potager.org>
3 #           © 2011 mh / immerda.ch <mh+coquelicot@immerda.ch>
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License as
7 # published by the Free Software Foundation, either version 3 of the
8 # License, or (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU Affero General Public License for more details.
14 #
15 # You should have received a copy of the GNU Affero General Public License
16 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
18 require 'lockfile'
19 require 'sinatra/base'
20 require 'sinatra/config_file'
21 require 'haml'
22 require 'haml/magic_translations'
23 require 'sass'
24 require 'digest/sha1'
25 require 'fast_gettext'
26
27 module Coquelicot
28   class << self
29     def settings
30       (class << self; Application; end)
31     end
32     def depot
33       @depot = Depot.new(settings.depot_path) if @depot.nil? || settings.depot_path != @depot.path
34       @depot
35     end
36   end
37
38   class Application < Sinatra::Base
39     register Sinatra::ConfigFile
40     register Coquelicot::Auth::Extension
41
42     set :root, Proc.new { app_file && File.expand_path('../../..', app_file) }
43     set :depot_path, Proc.new { File.join(root, 'files') }
44     set :default_expire, 60
45     set :maximum_expire, 60 * 24 * 30 # 1 month
46     set :gone_period, 60 * 24 * 7 # 1 week
47     set :filename_length, 20
48     set :random_pass_length, 16
49     set :about_text, ''
50     set :additional_css, ''
51     set :authentication_method, :name => :simplepass,
52                                 :upload_password => 'a94a8fe5ccb19ba61c4c0873d391e987982fbbd3'
53
54     config_file File.expand_path('../../../conf/settings.yml', __FILE__)
55
56     FastGettext.add_text_domain 'coquelicot', :path => 'po', :type => 'po'
57     FastGettext.available_locales = [ 'en', 'fr', 'de' ]
58     Haml::MagicTranslations.enable(:fast_gettext)
59     before do
60       FastGettext.text_domain = 'coquelicot'
61       FastGettext.locale = params[:lang] || request.env['HTTP_ACCEPT_LANGUAGE'] || 'en'
62     end
63
64     not_found do
65       'Not found'
66     end
67
68     get '/style.css' do
69       content_type 'text/css', :charset => 'utf-8'
70       sass :style
71     end
72
73     get '/' do
74       haml :index
75     end
76
77     get '/random_pass' do
78       "#{Coquelicot.gen_random_pass}"
79     end
80
81     get '/ready/:link' do |link|
82       not_found if link.nil?
83
84       link, pass = link.split '-' if link.include? '-'
85       begin
86         file = Coquelicot.depot.get_file(link, nil)
87       rescue Errno::ENOENT => ex
88         not_found
89       end
90       @expire_at = file.expire_at
91       @name = "#{link}"
92       unless pass.nil?
93         @name << "-#{pass}"
94         @unprotected = true
95       end
96       @url = uri(@name)
97       haml :ready
98     end
99
100     post '/authenticate' do
101       pass unless request.xhr?
102       begin
103         unless authenticate(params)
104           error 403, "Forbidden"
105         end
106         'OK'
107       rescue Coquelicot::Auth::Error => ex
108         error 503, ex.message
109       end
110     end
111
112     post '/upload' do
113       begin
114         unless authenticate(params)
115           error 403, "Forbidden"
116         end
117       rescue Coquelicot::Auth::Error => ex
118         error 503, ex.message
119       end
120
121       if params[:file] then
122         tmpfile = params[:file][:tempfile]
123         name = params[:file][:filename]
124       end
125       if tmpfile.nil? || name.nil? then
126         @error = "No file selected"
127         return haml(:index)
128       end
129       if tmpfile.lstat.size == 0 then
130         @error = "#{name} is empty"
131         return haml(:index)
132       end
133       if params[:expire].nil? or params[:expire].to_i == 0 then
134         params[:expire] = settings.default_expire
135       elsif params[:expire].to_i > settings.maximum_expire then
136         error 403
137       end
138       expire_at = Time.now + 60 * params[:expire].to_i
139       one_time_only = params[:one_time] and params[:one_time] == 'true'
140       if params[:file_key].nil? or params[:file_key].empty?then
141         pass = Coquelicot.gen_random_pass
142       else
143         pass = params[:file_key]
144       end
145       src = params[:file][:tempfile]
146       src.rewind
147       link = Coquelicot.depot.add_file(
148          pass,
149          { "Expire-at" => expire_at.to_i,
150            "One-time-only" => one_time_only,
151            "Filename" => params[:file][:filename],
152            "Length" => src.stat.size,
153            "Content-Type" => params[:file][:type],
154          }) { src.eof? ? nil : src.read }
155       redirect to("/ready/#{link}-#{pass}") if params[:file_key].nil? or params[:file_key].empty?
156       redirect to("/ready/#{link}")
157     end
158
159     def expired
160       throw :halt, [410, haml(:expired)]
161     end
162
163     def send_stored_file(file)
164       last_modified file.created_at.httpdate
165       attachment file.meta['Filename']
166       response['Content-Length'] = "#{file.meta['Length']}"
167       response['Content-Type'] = file.meta['Content-Type'] || 'application/octet-stream'
168       throw :halt, [200, file]
169     end
170
171     def send_link(link, pass)
172       file = Coquelicot.depot.get_file(link, pass)
173       return false if file.nil?
174       return expired if file.expired?
175
176       if file.one_time_only?
177         begin
178           # unlocking done in file.close
179           file.lockfile.lock
180         rescue Lockfile::TimeoutLockError
181           error 409, "Download currently in progress"
182         end
183       end
184       send_stored_file(file)
185     end
186
187     get '/:link-:pass' do |link, pass|
188       not_found if link.nil? || pass.nil?
189
190       link = Coquelicot.remap_base32_extra_characters(link)
191       pass = Coquelicot.remap_base32_extra_characters(pass)
192       not_found unless send_link(link, pass)
193     end
194
195     get '/:link' do |link|
196       not_found if link.nil?
197
198       link = Coquelicot.remap_base32_extra_characters(link)
199       not_found unless Coquelicot.depot.file_exists? link
200       @link = link
201       haml :enter_file_key
202     end
203
204     post '/:link' do |link|
205       pass = params[:file_key]
206       return 403 if pass.nil? or pass.empty?
207       begin
208         # send Forbidden even if file is not found
209         return 403 unless send_link(link, pass)
210       rescue Coquelicot::BadKey => ex
211         403
212       end
213     end
214
215     helpers do
216       def clone_url
217         settings.respond_to?(:clone_url) ? settings.clone_url : uri('coquelicot.git')
218       end
219
220       def authenticate(params)
221         Coquelicot.settings.authenticator.authenticate(params)
222       end
223
224       def auth_method
225         Coquelicot.settings.authenticator.class.name.gsub(/Coquelicot::Auth::([A-z0-9]+)Authenticator$/, '\1').downcase
226       end
227     end
228   end
229 end
230
231 Coquelicot::Application.run! if __FILE__ == $0