d2068a922b00884d380d3d250728364751a5348d
[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       # Ensure that the generated name is not already used
32       loop do
33         dst = gen_random_file_name
34         begin
35           StoredFile.create(full_path(dst), pass, options, &block)
36           break
37         rescue Errno::EEXIST => e
38           raise unless e.message =~ /(?:^|\s)#{Regexp.escape(full_path(dst))}(?:\s|$)/
39           next # let's try again
40         end
41       end
42
43       # retry to add the link until a free name is generated
44       loop do
45         link = gen_random_file_name
46         return link if add_link(link, dst)
47       end
48     end
49
50     def get_file(link, pass=nil)
51       name = nil
52       lockfile.lock do
53         name = read_link(link)
54       end
55       return nil if name.nil?
56       begin
57         StoredFile::open(full_path(name), pass)
58       rescue Errno::ENOENT
59         nil
60       end
61     end
62
63     def file_exists?(link)
64       lockfile.lock do
65         name = read_link(link)
66         return name && File.exists?(full_path(name))
67       end
68     end
69
70     def gc!
71       files.each do |name|
72         path = full_path(name)
73         if File.lstat(path).size > 0
74           file = StoredFile::open path
75           file.empty! if file.expired?
76         end
77         if Time.now - File.lstat(path).mtime > (Coquelicot.settings.gone_period * 60)
78           remove_from_links { |l| l.strip.end_with? " #{name}" }
79           File.unlink path
80         end
81       end
82     end
83
84     def size
85       files.count
86     end
87
88   private
89
90     LOCKFILE_OPTIONS = { :timeout => 60,
91                          :max_age => 8,
92                          :refresh => 2,
93                          :debug   => false }
94
95     def lockfile
96       Lockfile.new "#{@path}/.lock", LOCKFILE_OPTIONS
97     end
98
99     def links_path
100       "#{@path}/.links"
101     end
102
103     def add_link(src, dst)
104       lockfile.lock do
105         return false unless read_link(src).nil?
106
107         File.open(links_path, 'a') do |f|
108           f.write("#{src} #{dst}\n")
109         end
110       end
111       true
112     end
113
114     def remove_from_links(&block)
115       lockfile.lock do
116         links = []
117         File.open(links_path, 'r+') do |f|
118           f.readlines.each do |l|
119             links << l unless yield l
120           end
121           f.rewind
122           f.truncate(0)
123           f.write links.join
124         end
125       end
126     end
127
128     def remove_link(src)
129       remove_from_links { |l| l.start_with? "#{src} " }
130     end
131
132     def read_link(src)
133       dst = nil
134       File.open(links_path) do |f|
135         begin
136           line = f.readline rescue break
137           if line.start_with? "#{src} " then
138             dst = line.split[1]
139             break
140           end
141         end until line.empty?
142       end if File.exists?(links_path)
143       dst
144     end
145
146     def files
147       lockfile.lock do
148         begin
149           File.open(links_path) do |f|
150             f.readlines.collect { |l| l.split[1] }
151           end
152         rescue Errno::ENOENT # if links file has not been created yet
153           []
154         end
155       end
156     end
157
158     def gen_random_file_name
159       begin
160         name = Coquelicot.gen_random_base32(Coquelicot.settings.filename_length)
161       end while File.exists?(full_path(name))
162       name
163     end
164
165     def full_path(name)
166       raise "Wrong name" unless name.each_char.collect { |c| Coquelicot::FILENAME_CHARS.include? c }.all?
167       "#{@path}/#{name}"
168     end
169   end
170
171   # Like RFC 4648 (Base32)
172   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)
173
174   class << self
175     def gen_random_base32(length)
176       name = ''
177       OpenSSL::Random::random_bytes(length).each_byte do |i|
178         name << FILENAME_CHARS[i % FILENAME_CHARS.length]
179       end
180       name
181     end
182     def gen_random_pass
183       gen_random_base32(settings.random_pass_length)
184     end
185     def remap_base32_extra_characters(str)
186       map = {}
187       FILENAME_CHARS.each { |c| map[c] = c; map[c.upcase] = c }
188       map.merge!({ '1' => 'l', '0' => 'o' })
189       result = ''
190       str.each_char { |c| result << map[c] if map[c] }
191       result
192     end
193   end
194 end