use File::EXCL|File::CREAT instead of locking when adding a new file to the depot
[coquelicot.git] / lib / coquelicot / depot.rb
1 # Coquelicot: "one-click" file sharing with a focus on users' privacy.
2 # Copyright © 2010-2012 potager.org <jardiniers@potager.org>
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as
6 # published by the Free Software Foundation, either version 3 of the
7 # License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17 require 'lockfile'
18 require 'openssl'
19
20 module Coquelicot
21   class Depot
22     attr_reader :path
23
24     def initialize(path)
25       @path = path
26     end
27
28     def add_file(pass, options, &block)
29       dst = nil
30
31       begin
32         # Ensure that the generated name is not already used
33         loop do
34           dst = gen_random_file_name
35           begin
36             StoredFile.create(full_path(dst), pass, options, &block)
37             break
38           rescue Errno::EEXIST => e
39             raise unless e.message =~ /(?:^|\s)#{Regexp.escape(full_path(dst))}(?:\s|$)/
40             next # let's try again
41           end
42         end
43       rescue
44         File.unlink full_path(dst)
45         raise
46       end
47       link = gen_random_file_name
48       add_link(link, dst)
49       link
50     end
51
52     def get_file(link, pass=nil)
53       name = read_link(link)
54       return nil if name.nil?
55       StoredFile::open(full_path(name), pass)
56     end
57
58     def file_exists?(link)
59       name = read_link(link)
60       return !name.nil?
61     end
62
63     def gc!
64       files.each do |name|
65         path = full_path(name)
66         if File.lstat(path).size > 0
67           file = StoredFile::open path
68           file.empty! if file.expired?
69         elsif Time.now - File.lstat(path).mtime > (Coquelicot.settings.gone_period * 60)
70           remove_from_links { |l| l.strip.end_with? " #{name}" }
71           File.unlink path
72         end
73       end
74     end
75
76   private
77
78     LOCKFILE_OPTIONS = { :timeout => 60,
79                          :max_age => 8,
80                          :refresh => 2,
81                          :debug   => false }
82
83     def lockfile
84       Lockfile.new "#{@path}/.lock", LOCKFILE_OPTIONS
85     end
86
87     def links_path
88       "#{@path}/.links"
89     end
90
91     def add_link(src, dst)
92       lockfile.lock do
93         File.open(links_path, 'a') do |f|
94           f.write("#{src} #{dst}\n")
95         end
96       end
97     end
98
99     def remove_from_links(&block)
100       lockfile.lock do
101         links = []
102         File.open(links_path, 'r+') do |f|
103           f.readlines.each do |l|
104             links << l unless yield l
105           end
106           f.rewind
107           f.truncate(0)
108           f.write links.join
109         end
110       end
111     end
112
113     def remove_link(src)
114       remove_from_links { |l| l.start_with? "#{src} " }
115     end
116
117     def read_link(src)
118       dst = nil
119       lockfile.lock do
120         File.open(links_path) do |f|
121           begin
122             line = f.readline rescue break
123             if line.start_with? "#{src} " then
124               dst = line.split[1]
125               break
126             end
127           end until line.empty?
128         end if File.exists?(links_path)
129       end
130       dst
131     end
132
133     def files
134       lockfile.lock do
135         File.open(links_path) do |f|
136           f.readlines.collect { |l| l.split[1] }
137         end
138       end
139     end
140
141     def gen_random_file_name
142       begin
143         name = Coquelicot.gen_random_base32(Coquelicot.settings.filename_length)
144       end while File.exists?(full_path(name))
145       name
146     end
147
148     def full_path(name)
149       raise "Wrong name" unless name.each_char.collect { |c| Coquelicot::FILENAME_CHARS.include? c }.all?
150       "#{@path}/#{name}"
151     end
152   end
153
154   # Like RFC 4648 (Base32)
155   FILENAME_CHARS = %w(a b c d e f g h i j k l m n o p q r s t u v w x y z 2 3 4 5 6 7)
156
157   class << self
158     def gen_random_base32(length)
159       name = ''
160       OpenSSL::Random::random_bytes(length).each_byte do |i|
161         name << FILENAME_CHARS[i % FILENAME_CHARS.length]
162       end
163       name
164     end
165     def gen_random_pass
166       gen_random_base32(settings.random_pass_length)
167     end
168     def remap_base32_extra_characters(str)
169       map = {}
170       FILENAME_CHARS.each { |c| map[c] = c; map[c.upcase] = c }
171       map.merge!({ '1' => 'l', '0' => 'o' })
172       result = ''
173       str.each_char { |c| result << map[c] if map[c] }
174       result
175     end
176   end
177 end