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