support multiple languages for about_text
[coquelicot.git] / lib / coquelicot / app.rb
1 # -*- coding: UTF-8 -*-
2 # Coquelicot: "one-click" file sharing with a focus on users' privacy.
3 # Copyright © 2010-2013 potager.org <jardiniers@potager.org>
4 #           © 2011 mh / immerda.ch <mh+coquelicot@immerda.ch>
5 #
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as
8 # published by the Free Software Foundation, either version 3 of the
9 # License, or (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 # GNU Affero General Public License for more details.
15 #
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
19 require 'lockfile'
20 require 'sinatra/config_file'
21 require 'sass'
22 require 'digest/sha1'
23 require 'fast_gettext'
24 require 'upr'
25 require 'moneta'
26 require 'unicorn/launcher'
27 require 'rainbows'
28 require 'optparse'
29
30 module Coquelicot
31   class << self
32     def settings
33       (class << self; Application; end)
34     end
35     def depot
36       @depot = Depot.new(settings.depot_path) if @depot.nil? || settings.depot_path != @depot.path
37       @depot
38     end
39     # Called by the +coquelicot+ script.
40     def run!(args = [])
41       parser = OptionParser.new do |opts|
42         opts.banner = "Usage: #{opts.program_name} [options] COMMAND [command options]"
43
44         opts.separator ""
45         opts.separator "Common options:"
46
47         opts.on "-c", "--config FILE", "read settings from FILE" do |file|
48           if File.readable? file
49             settings.config_file file
50           else
51             $stderr.puts "#{opts.program_name}: cannot access configuration file '#{file}'."
52             exit 1
53           end
54         end
55         opts.on("-h", "--help", "show this message") do
56           $stderr.puts opts.to_s
57           exit
58         end
59         opts.separator ""
60         opts.separator "Available commands:"
61         opts.separator "    start             Start web server"
62         opts.separator "    stop              Stop web server"
63         opts.separator "    gc                Run garbage collection"
64         opts.separator "    migrate-jyraphe   Migrate a Jyraphe repository"
65         opts.separator ""
66         opts.separator "See '#{opts.program_name} COMMAND --help' for more information on a specific command."
67       end
68       begin
69         parser.order!(args) do |command|
70           if %w{start stop gc migrate-jyraphe}.include? command
71             return self.send("#{command.gsub(/-/, '_')}!", args)
72           else
73             $stderr.puts("#{parser.program_name}: '#{command}' is not a valid command. " +
74                          "See '#{parser.program_name} --help'.")
75             exit 1
76           end
77         end
78       rescue OptionParser::InvalidOption => ex
79         $stderr.puts("#{parser.program_name}: '#{ex.args[0]}' is not a valid option. " +
80                      "See '#{parser.program_name} --help'.")
81         exit 1
82       end
83       # if we reach here, no command was given
84       $stderr.puts parser.to_s
85       exit
86     end
87     def start!(args)
88       options = {}
89       parser = OptionParser.new do |opts|
90         opts.banner = "Usage: #{opts.program_name} [options] start [command options]"
91         opts.separator ""
92         opts.separator "'#{opts.program_name} start' will start the web server in background."
93         opts.separator "Use '#{opts.program_name} stop' to stop it when done serving."
94         opts.separator ""
95         opts.separator "Command options:"
96         opts.on_tail("-n", "--no-daemon", "do not daemonize (stay in foreground)") do
97           options[:no_daemon] = true
98         end
99         opts.on_tail("-h", "--help", "show this message") do
100           $stderr.puts opts.to_s
101           exit
102         end
103       end
104       parser.parse!(args)
105
106       Unicorn::Configurator::DEFAULTS.merge!({
107         :pid => settings.pid,
108         :listeners => settings.listen,
109         :use => :ThreadSpawn,
110         :rewindable_input => false,
111         :client_max_body_size => nil
112       })
113       unless options[:no_daemon]
114         if settings.log
115           Unicorn::Configurator::DEFAULTS.merge!({
116             :stdout_path => settings.log,
117             :stderr_path => settings.log
118           })
119         end
120       end
121
122       # daemonize! and start pass data around through rainbows_opts
123       rainbows_opts = {}
124       ::Unicorn::Launcher.daemonize!(rainbows_opts) unless options[:no_daemon]
125
126       app = lambda do
127           ::Rack::Builder.new do
128             # This implements the behaviour outlined in Section 8 of
129             # <http://ftp.ics.uci.edu/pub/ietf/http/draft-ietf-http-connection-00.txt>.
130             #
131             # Half-closing the write part first and draining our input makes sure the
132             # client will properly receive an error message instead of TCP RST (a.k.a.
133             # "Connection reset by peer") when we interrupt it in the middle of a POST
134             # request.
135             #
136             # Thanks Eric Wong for these few lines. See
137             # <http://rubyforge.org/pipermail/rainbows-talk/2012-February/000328.html> for
138             # the discussion that lead him to propose what follows.
139             Rainbows::Client.class_eval <<-END_OF_METHOD
140               def close
141                 close_write
142                 buf = ""
143                 loop do
144                   kgio_wait_readable(2)
145                   break unless kgio_tryread(512, buf)
146                 end
147               ensure
148                 super
149               end
150             END_OF_METHOD
151
152             use ::Rack::ContentLength
153             use ::Rack::Chunked
154             use ::Rack::CommonLogger, $stderr
155             run Application
156           end.to_app
157         end
158
159       server = ::Rainbows::HttpServer.new(app, rainbows_opts)
160       server.start.join
161     end
162     def stop!(args)
163       parser = OptionParser.new do |opts|
164         opts.banner = "Usage: #{opts.program_name} [options] stop [command options]"
165         opts.separator ""
166         opts.separator "'#{opts.program_name} stop' will stop the web server."
167         opts.separator ""
168         opts.separator "Command options:"
169         opts.on_tail("-h", "--help", "show this message") do
170           $stderr.puts opts.to_s
171           exit
172         end
173       end
174       parser.parse!(args)
175
176       unless File.readable? settings.pid
177         $stderr.puts "Unable to read #{settings.pid}. Are you sure Coquelicot is started?"
178         exit 1
179       end
180
181       pid = File.read(settings.pid).to_i
182       if pid == 0
183         $stderr.puts "Bad PID file #{settings.pid}."
184         exit 1
185       end
186
187       Process.kill(:TERM, pid)
188     end
189     def gc!(args)
190       parser = OptionParser.new do |opts|
191         opts.banner = "Usage: #{opts.program_name} [options] gc [command options]"
192         opts.separator ""
193         opts.separator "'#{opts.program_name} gc' will clean up expired files from the current depot."
194         opts.separator "Depot is currently set to '#{Coquelicot.depot.path}'"
195         opts.separator ""
196         opts.separator "Command options:"
197         opts.on_tail("-h", "--help", "show this message") do
198           $stderr.puts opts.to_s
199           exit
200         end
201       end
202       parser.parse!(args)
203       depot.gc!
204     end
205     def migrate_jyraphe!(args = [])
206       require 'coquelicot/jyraphe_migrator'
207       Coquelicot::JyrapheMigrator.run! args
208     end
209   end
210
211   class Application < Coquelicot::BaseApp
212     register Sinatra::ConfigFile
213     register Coquelicot::Auth::Extension
214
215     enable :sessions
216     # When sessions are enabled, Rack::Protection (added by Sinatra)
217     # will choke on our lack of rewind method on our input. Let's
218     # deactivate the protections which needs to parse parameters, then.
219     set :protection, :except => [:session_hijacking, :remote_token]
220
221     set :root, Proc.new { app_file && File.expand_path('../../..', app_file) }
222     set :depot_path, Proc.new { File.join(root, 'files') }
223     set :max_file_size, 5 * 1024 * 1024 # 5 MiB
224     set :default_expire, 60
225     set :maximum_expire, 60 * 24 * 30 # 1 month
226     set :gone_period, 60 * 24 * 7 # 1 week
227     set :filename_length, 20
228     set :random_pass_length, 16
229     set :about_text, 'en' => ''
230     set :additional_css, ''
231     set :pid, Proc.new { File.join(root, 'tmp/coquelicot.pid') }
232     set :log, Proc.new { File.join(root, 'tmp/coquelicot.log') }
233     set :listen, [ "127.0.0.1:51161" ]
234     set :show_exceptions, false
235     set :authentication_method, :name => :simplepass,
236                                 :upload_password => 'a94a8fe5ccb19ba61c4c0873d391e987982fbbd3'
237
238     config_file File.expand_path('../../../conf/settings.yml', __FILE__)
239
240     set :upr_backend, Upr::Monitor.new(Moneta.new(:Memory))
241     if defined?(Rainbows) && !Rainbows.server.nil? && !Rainbows.server.rewindable_input
242       use Upr, :backend => upr_backend, :path_info => %q{/upload}
243     end
244     use Coquelicot::Rack::Upload
245     # limit requests other than upload to an input body of 5 kiB max
246     use Rainbows::MaxBody, 5 * 1024
247
248     not_found do
249       @uri = env['REQUEST_URI']
250       haml :not_found
251     end
252
253     error 403 do
254       haml :forbidden
255     end
256
257     error 409 do
258       haml :download_in_progress
259     end
260
261     error 500..510 do
262       @error = env['sinatra.error'] || response.body.join
263       haml :error
264     end
265
266     get '/style.css' do
267       content_type 'text/css', :charset => 'utf-8'
268       sass :style
269     end
270
271     get '/' do
272       haml :index
273     end
274
275     get '/README' do
276       haml(":markdown\n" +
277            File.read(File.join(settings.root, 'README')).gsub(/^/, '  '))
278     end
279
280     get '/about-your-data' do
281       haml :about_your_data
282     end
283
284     get '/random_pass' do
285       "#{Coquelicot.gen_random_pass}"
286     end
287
288     get '/ready/:link' do |link|
289       not_found if link.nil?
290
291       link, pass = link.split '-' if link.include? '-'
292       file = Coquelicot.depot.get_file(link, nil)
293
294       not_found if file.nil?
295
296       @expire_at = file.expire_at
297       @name = "#{link}"
298       unless pass.nil?
299         @name << "-#{pass}"
300         @unprotected = true
301       end
302       @url = uri(@name)
303       haml :ready
304     end
305
306     post '/authenticate' do
307       pass unless request.xhr?
308       begin
309         unless authenticate(params)
310           error 403, "Forbidden"
311         end
312         'OK'
313       rescue Coquelicot::Auth::Error => ex
314         error 503, ex.message
315       end
316     end
317
318     get '/progress' do
319       response.headers.update(Upr::JSON::RESPONSE_HEADERS)
320       data = Upr::JSON.new(:env => request.env,
321                            :backend => settings.upr_backend,
322                            :upload_id => params['X-Progress-ID'])._once
323       halt 200, { 'Content-Type' => 'application/json' }, data
324     end
325
326     post '/upload' do
327       # Normally handled by Coquelicot::Rack::Upload, only failures
328       # will arrive here.
329       error 500, 'Rack::Coquelicot::Upload failed' if @env['X_COQUELICOT_FORWARD'].nil?
330
331       if params[:file].nil? then
332         @error = "No file selected"
333         return haml(:index)
334       end
335
336       error 500, 'Something went wrong: this code should never be executed'
337     end
338
339     def expired
340       throw :halt, [410, haml(:expired)]
341     end
342
343     def send_stored_file(file)
344       last_modified file.created_at.httpdate
345       attachment file.meta['Filename']
346       response['Content-Length'] = "#{file.meta['Length']}"
347       response['Content-Type'] = file.meta['Content-Type'] || 'application/octet-stream'
348       throw :halt, [200, file]
349     end
350
351     def send_link(link, pass)
352       file = Coquelicot.depot.get_file(link, pass)
353       return false if file.nil?
354       return expired if file.expired?
355
356       if file.one_time_only?
357         begin
358           # unlocking done in file.close
359           file.lockfile.lock
360         rescue Lockfile::TimeoutLockError
361           error 409, "Download currently in progress"
362         end
363       end
364       send_stored_file(file)
365     end
366
367     get '/:link-:pass' do |link, pass|
368       not_found if link.nil? || pass.nil?
369
370       link = Coquelicot.remap_base32_extra_characters(link)
371       pass = Coquelicot.remap_base32_extra_characters(pass)
372       begin
373         not_found unless send_link(link, pass)
374       rescue Coquelicot::BadKey
375         not_found
376       end
377     end
378
379     get '/:link' do |link|
380       not_found if link.nil?
381
382       link = Coquelicot.remap_base32_extra_characters(link)
383       not_found unless Coquelicot.depot.file_exists? link
384       @link = link
385       haml :enter_file_key
386     end
387
388     post '/:link' do |link|
389       pass = params[:file_key]
390       return 403 if pass.nil? or pass.empty?
391       begin
392         # send Forbidden even if file is not found
393         return 403 unless send_link(link, pass)
394       rescue Coquelicot::BadKey => ex
395         403
396       end
397     end
398   end
399 end
400
401 Coquelicot::Application.run! if __FILE__ == $0