Merge remote branch 'sarava/master'
[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 = "#{base_href}/#{@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       link = Coquelicot.depot.add_file(
147          src, pass,
148          { "Expire-at" => expire_at.to_i,
149            "One-time-only" => one_time_only,
150            "Filename" => params[:file][:filename],
151            "Length" => src.stat.size,
152            "Content-Type" => params[:file][:type],
153          })
154       redirect "ready/#{link}-#{pass}" if params[:file_key].nil? or params[:file_key].empty?
155       redirect "ready/#{link}"
156     end
157
158     def expired
159       throw :halt, [410, haml(:expired)]
160     end
161
162     def send_stored_file(file)
163       last_modified file.created_at.httpdate
164       attachment file.meta['Filename']
165       response['Content-Length'] = "#{file.meta['Length']}"
166       response['Content-Type'] = file.meta['Content-Type'] || 'application/octet-stream'
167       throw :halt, [200, file]
168     end
169
170     def send_link(link, pass)
171       file = Coquelicot.depot.get_file(link, pass)
172       return false if file.nil?
173       return expired if file.expired?
174
175       if file.one_time_only?
176         begin
177           # unlocking done in file.close
178           file.lockfile.lock
179         rescue Lockfile::TimeoutLockError
180           error 409, "Download currently in progress"
181         end
182       end
183       send_stored_file(file)
184     end
185
186     get '/:link-:pass' do |link, pass|
187       not_found if link.nil? || pass.nil?
188
189       link = Coquelicot.remap_base32_extra_characters(link)
190       pass = Coquelicot.remap_base32_extra_characters(pass)
191       not_found unless send_link(link, pass)
192     end
193
194     get '/:link' do |link|
195       not_found if link.nil?
196
197       link = Coquelicot.remap_base32_extra_characters(link)
198       not_found unless Coquelicot.depot.file_exists? link
199       @link = link
200       haml :enter_file_key
201     end
202
203     post '/:link' do |link|
204       pass = params[:file_key]
205       return 403 if pass.nil? or pass.empty?
206       begin
207         # send Forbidden even if file is not found
208         return 403 unless send_link(link, pass)
209       rescue Coquelicot::BadKey => ex
210         403
211       end
212     end
213
214     helpers do
215       def base_href
216         if settings.respond_to?(:url)
217           return settings.url
218         end
219         url = request.scheme + "://"
220         url << request.host
221         if request.scheme == "https" && request.port != 443 ||
222             request.scheme == "http" && request.port != 80
223           url << ":#{request.port}"
224         end
225         url << request.script_name
226         "#{url}/"
227       end
228
229       def clone_url
230         settings.respond_to?(:clone_url) ? settings.clone_url : "#{base_href}coquelicot.git"
231       end
232
233       def authenticate(params)
234         Coquelicot.settings.authenticator.authenticate(params)
235       end
236
237       def auth_method
238         Coquelicot.settings.authenticator.class.name.gsub(/Coquelicot::Auth::([A-z0-9]+)Authenticator$/, '\1').downcase
239       end
240     end
241   end
242 end
243
244 Coquelicot::Application.run! if __FILE__ == $0