Fix name-typo in NEWS
[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 'tilt/haml'
22 require 'tilt/sass'
23 require 'sass'
24 require 'digest/sha1'
25 require 'fast_gettext'
26 require 'upr'
27 require 'moneta'
28 require 'unicorn/launcher'
29 require 'rainbows'
30 require 'optparse'
31 require 'rubygems/package'
32
33 module Coquelicot
34   class << self
35     def settings
36       (class << self; Application; end)
37     end
38     def depot
39       @depot = Depot.new(settings.depot_path) if @depot.nil? || settings.depot_path != @depot.path
40       @depot
41     end
42     def sass_cache
43       if @sass_cache.nil?
44         @sass_cache = File.join(settings.cache_path, 'sass-cache')
45         FileUtils.mkdir_p(@sass_cache)
46       end
47       @sass_cache
48     end
49
50     # Called by the +coquelicot+ script.
51     def run!(args = [])
52       parser = OptionParser.new do |opts|
53         opts.banner = "Usage: #{opts.program_name} [options] COMMAND [command options]"
54
55         opts.separator ""
56         opts.separator "Common options:"
57
58         opts.on "-c", "--config FILE", "read settings from FILE" do |file|
59           if File.readable? file
60             settings.config_file File.expand_path(file)
61           else
62             $stderr.puts "#{opts.program_name}: cannot access configuration file '#{file}'."
63             exit 1
64           end
65         end
66         opts.on("-h", "--help", "show this message") do
67           $stderr.puts opts.to_s
68           exit
69         end
70         opts.separator ""
71         opts.separator "Available commands:"
72         opts.separator "    start             Start web server"
73         opts.separator "    stop              Stop web server"
74         opts.separator "    gc                Run garbage collection"
75         opts.separator "    migrate-jyraphe   Migrate a Jyraphe repository"
76         opts.separator ""
77         opts.separator "See '#{opts.program_name} COMMAND --help' for more information on a specific command."
78       end
79       begin
80         parser.order!(args) do |command|
81           if %w{start stop gc migrate-jyraphe}.include? command
82             return self.send("#{command.gsub(/-/, '_')}!", args)
83           else
84             $stderr.puts("#{parser.program_name}: '#{command}' is not a valid command. " +
85                          "See '#{parser.program_name} --help'.")
86             exit 1
87           end
88         end
89       rescue OptionParser::InvalidOption => ex
90         $stderr.puts("#{parser.program_name}: '#{ex.args[0]}' is not a valid option. " +
91                      "See '#{parser.program_name} --help'.")
92         exit 1
93       end
94       # if we reach here, no command was given
95       $stderr.puts parser.to_s
96       exit
97     end
98     def monkeypatch_half_close
99       # This implements the behaviour outlined in Section 8 of
100       # <http://ftp.ics.uci.edu/pub/ietf/http/draft-ietf-http-connection-00.txt>.
101       #
102       # Half-closing the write part first and draining our input makes sure the
103       # client will properly receive an error message instead of TCP RST (a.k.a.
104       # "Connection reset by peer") when we interrupt it in the middle of a POST
105       # request.
106       #
107       # Thanks Eric Wong for these few lines. See
108       # <http://rubyforge.org/pipermail/rainbows-talk/2012-February/000328.html> for
109       # the discussion that lead him to propose what follows.
110       Rainbows::Client.class_eval <<-END_OF_METHOD
111         def close
112           close_write
113           buf = ""
114           loop do
115             kgio_wait_readable(2)
116             break unless kgio_tryread(512, buf)
117           end
118         ensure
119           super
120         end
121       END_OF_METHOD
122     end
123     def start!(args)
124       options = {}
125       parser = OptionParser.new do |opts|
126         opts.banner = "Usage: #{opts.program_name} [options] start [command options]"
127         opts.separator ""
128         opts.separator "'#{opts.program_name} start' will start the web server in background."
129         opts.separator "Use '#{opts.program_name} stop' to stop it when done serving."
130         opts.separator ""
131         opts.separator "Command options:"
132         opts.on_tail("-n", "--no-daemon", "do not daemonize (stay in foreground)") do
133           options[:no_daemon] = true
134         end
135         opts.on_tail("-h", "--help", "show this message") do
136           $stderr.puts opts.to_s
137           exit
138         end
139       end
140       parser.parse!(args)
141
142       Unicorn::Configurator::DEFAULTS.merge!({
143         :pid => settings.pid,
144         :listeners => settings.listen,
145         :use => :ThreadSpawn,
146         :rewindable_input => false,
147         :client_max_body_size => nil
148       })
149       unless options[:no_daemon]
150         if settings.log
151           Unicorn::Configurator::DEFAULTS.merge!({
152             :stdout_path => settings.log,
153             :stderr_path => settings.log
154           })
155         end
156       end
157
158       # daemonize! and start pass data around through rainbows_opts
159       rainbows_opts = {}
160       ::Unicorn::Launcher.daemonize!(rainbows_opts) unless options[:no_daemon]
161
162       path = settings.path
163       app = lambda do
164               ::Rack::Builder.new do
165                 Coquelicot.monkeypatch_half_close
166                 use ::Rack::ContentLength
167                 use ::Rack::Chunked
168                 use ::Rack::CommonLogger, $stderr
169                 map path do
170                   run Application
171                 end
172               end.to_app
173             end
174
175       server = ::Rainbows::HttpServer.new(app, rainbows_opts)
176       server.start.join
177     end
178     def stop!(args)
179       parser = OptionParser.new do |opts|
180         opts.banner = "Usage: #{opts.program_name} [options] stop [command options]"
181         opts.separator ""
182         opts.separator "'#{opts.program_name} stop' will stop the web server."
183         opts.separator ""
184         opts.separator "Command options:"
185         opts.on_tail("-h", "--help", "show this message") do
186           $stderr.puts opts.to_s
187           exit
188         end
189       end
190       parser.parse!(args)
191
192       unless File.readable? settings.pid
193         $stderr.puts "Unable to read #{settings.pid}. Are you sure Coquelicot is started?"
194         exit 1
195       end
196
197       pid = File.read(settings.pid).to_i
198       if pid == 0
199         $stderr.puts "Bad PID file #{settings.pid}."
200         exit 1
201       end
202
203       Process.kill(:TERM, pid)
204     end
205     def gc!(args)
206       parser = OptionParser.new do |opts|
207         opts.banner = "Usage: #{opts.program_name} [options] gc [command options]"
208         opts.separator ""
209         opts.separator "'#{opts.program_name} gc' will clean up expired files from the current depot."
210         opts.separator "Depot is currently set to '#{Coquelicot.depot.path}'"
211         opts.separator ""
212         opts.separator "Command options:"
213         opts.on_tail("-h", "--help", "show this message") do
214           $stderr.puts opts.to_s
215           exit
216         end
217       end
218       parser.parse!(args)
219       depot.gc!
220     end
221     def migrate_jyraphe!(args = [])
222       require 'coquelicot/jyraphe_migrator'
223       Coquelicot::JyrapheMigrator.run! args
224     end
225   end
226
227   class Application < Coquelicot::BaseApp
228     register Sinatra::ConfigFile
229     register Coquelicot::Auth::Extension
230
231     enable :sessions
232     # When sessions are enabled, Rack::Protection (added by Sinatra)
233     # will choke on our lack of rewind method on our input. Let's
234     # deactivate the protections which needs to parse parameters, then.
235     set :protection, :except => [:session_hijacking, :remote_token]
236
237     set :root, Proc.new { app_file && File.expand_path('../../..', app_file) }
238     set :depot_path, Proc.new { File.join(root, 'files') }
239     set :cache_path, Proc.new { File.join(root, 'tmp/cache') }
240     set :max_file_size, 5 * 1024 * 1024 # 5 MiB
241     set :default_expire, 60 * 24 # 1 day
242     set :maximum_expire, 60 * 24 * 30 # 1 month
243     set :gone_period, 60 * 24 * 7 # 1 week
244     set :filename_length, 20
245     set :random_pass_length, 16
246     set :about_text, 'en' => ''
247     set :additional_css, ''
248     set :pid, Proc.new { File.join(root, 'tmp/coquelicot.pid') }
249     set :log, Proc.new { File.join(root, 'tmp/coquelicot.log') }
250     set :listen, [ "127.0.0.1:51161" ]
251     set :path, '/'
252     set :show_exceptions, false
253     set :authentication_method, :name => :simplepass,
254                                 :upload_password => 'a94a8fe5ccb19ba61c4c0873d391e987982fbbd3'
255
256     config_file File.expand_path('../../../conf/settings.yml', __FILE__)
257
258     set :upr_backend, Upr::Monitor.new(Moneta.new(:Memory))
259     use Upr, :backend => upr_backend, :path_info => %q{/upload}
260     use Coquelicot::Rack::Upload
261     # limit requests other than upload to an input body of 5 kiB max
262     use Rainbows::MaxBody, 5 * 1024
263
264     not_found do
265       @uri = env['REQUEST_URI']
266       haml :not_found
267     end
268
269     error 403 do
270       haml :forbidden
271     end
272
273     error 409 do
274       haml :download_in_progress
275     end
276
277     error 500..510 do
278       @error = env['sinatra.error'] || response.body.join
279       if request.xhr?
280         "#{response.body.join}"
281       else
282         haml :error
283       end
284     end
285
286     get '/style.css' do
287       content_type 'text/css', :charset => 'utf-8'
288       sass :style, :cache_location => Coquelicot.sass_cache
289     end
290
291     get '/' do
292       haml :index
293     end
294
295     get '/README' do
296       haml(":markdown\n" +
297            File.read(File.join(settings.root, 'README')).gsub(/^/, '  '))
298     end
299
300     get '/about-your-data' do
301       haml :about_your_data
302     end
303
304     if defined? Gem::Package.build
305       get '/source' do
306         Gem::DefaultUserInteraction.ui = Gem::SilentUI.new
307
308         spec = Gem::loaded_specs['coquelicot'].clone
309         spec.version = gem_version
310         Tempfile.open('coquelicot-gem', :encoding => 'binary') do |gem_file|
311           Dir.mktmpdir('coquelicot-gen-gem') do |tmpdir|
312             Dir.chdir(spec.full_gem_path) do
313               spec.files.each do |file|
314                 dest = "#{tmpdir}/#{file}"
315                 FileUtils.mkdir_p(File.dirname(dest))
316                 FileUtils.cp(file, dest)
317               end
318             end
319             Dir.chdir("#{tmpdir}") do
320               filename = Gem::Package.build(spec)
321               gem_file.write(File.read(filename))
322             end
323           end
324           send_file gem_file.path, :filename => spec.file_name
325           gem_file.unlink
326         end
327       end
328     else
329       get '/source' do
330         Gem::DefaultUserInteraction.ui = Gem::SilentUI.new
331
332         spec = Gem::loaded_specs['coquelicot'].clone
333         Dir.chdir(spec.full_gem_path) do
334           spec.version = gem_version
335           spec.mark_version
336           spec.validate
337           Tempfile.open('coquelicot-gem', :encoding => 'binary') do |gem_file|
338             Gem::Package.open(gem_file, 'w', nil) do |pkg|
339               pkg.metadata = spec.to_yaml
340               spec.files.each do |file|
341                 next if File.directory?(file)
342                 stat = File.stat(file)
343                 mode = stat.mode & 0777
344                 size = stat.size
345                 pkg.add_file_simple(file, mode, size) do |tar_io|
346                   tar_io.write(open(file, "rb") { |f| f.read })
347                 end
348               end
349             end
350             send_file gem_file.path, :filename => spec.file_name
351             gem_file.unlink
352           end
353         end
354       end
355     end
356
357     get '/random_pass' do
358       "#{Coquelicot.gen_random_pass}"
359     end
360
361     get '/ready/:link' do |link|
362       not_found if link.nil?
363
364       link, pass = link.split '-' if link.include? '-'
365       file = Coquelicot.depot.get_file(link, nil)
366
367       not_found if file.nil?
368
369       @expire_at = file.expire_at
370       @name = "#{link}"
371       unless pass.nil?
372         @name << "-#{pass}"
373         @unprotected = true
374       end
375       @url = uri(@name)
376       haml :ready
377     end
378
379     post '/authenticate' do
380       pass unless request.xhr?
381       begin
382         unless authenticate(params)
383           error 403, "Forbidden"
384         end
385         'OK'
386       rescue Coquelicot::Auth::Error => ex
387         error 503, ex.message
388       rescue => ex
389         dump_errors! ex
390         error 500, "Issue has been logged."
391       end
392     end
393
394     get '/progress' do
395       response.headers.update(Upr::JSON::RESPONSE_HEADERS)
396       data = Upr::JSON.new(:env => request.env,
397                            :backend => settings.upr_backend,
398                            :upload_id => params['X-Progress-ID'])._once
399       halt 200, { 'Content-Type' => 'application/json' }, data
400     end
401
402     post '/upload' do
403       # Normally handled by Coquelicot::Rack::Upload, only failures
404       # will arrive here.
405       error 500, 'Rack::Coquelicot::Upload failed' if @env['X_COQUELICOT_FORWARD'].nil?
406
407       if params[:file].nil? then
408         @error = "No file selected"
409         return haml(:index)
410       end
411
412       error 500, 'Something went wrong: this code should never be executed'
413     end
414
415     def expired
416       throw :halt, [410, haml(:expired)]
417     end
418
419     def send_stored_file(file)
420       response['Content-Length'] = "#{file.meta['Length']}"
421       response['Content-Type'] = file.meta['Content-Type'] || 'application/octet-stream'
422       last_modified file.created_at.httpdate
423       attachment file.meta['Filename']
424       throw :halt, [200, file]
425     end
426
427     def send_link(link, pass)
428       file = Coquelicot.depot.get_file(link, pass)
429       return false if file.nil?
430       return expired if file.expired?
431
432       if file.one_time_only?
433         begin
434           # unlocking done in file.close
435           file.lockfile.lock
436         rescue Lockfile::TimeoutLockError
437           error 409, "Download currently in progress"
438         end
439       end
440       send_stored_file(file)
441     end
442
443     get '/:link-:pass' do |link, pass|
444       not_found if link.nil? || pass.nil?
445
446       link = Coquelicot.remap_base32_extra_characters(link)
447       pass = Coquelicot.remap_base32_extra_characters(pass)
448       begin
449         not_found unless send_link(link, pass)
450       rescue Coquelicot::BadKey
451         not_found
452       end
453     end
454
455     get '/:link' do |link|
456       not_found if link.nil?
457
458       link = Coquelicot.remap_base32_extra_characters(link)
459       not_found unless Coquelicot.depot.file_exists? link
460       @link = link
461       haml :enter_file_key
462     end
463
464     post '/:link' do |link|
465       pass = params[:file_key]
466       return 403 if pass.nil? or pass.empty?
467       begin
468         # send Forbidden even if file is not found
469         return 403 unless send_link(link, pass)
470       rescue Coquelicot::BadKey => ex
471         403
472       end
473     end
474   end
475 end