s3ftp 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source :gemcutter
2
+
3
+ gemspec
@@ -0,0 +1,21 @@
1
+ Copyright (c) 2009 Peter Jones
2
+ Copyright (c) 2009 James Healy
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining
5
+ a copy of this software and associated documentation files (the
6
+ "Software"), to deal in the Software without restriction, including
7
+ without limitation the rights to use, copy, modify, merge, publish,
8
+ distribute, sublicense, and/or sell copies of the Software, and to
9
+ permit persons to whom the Software is furnished to do so, subject to
10
+ the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be
13
+ included in all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
19
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
20
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
21
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,36 @@
1
+ # s3ftp
2
+
3
+ A mini-FTP server that persists all data to Amazon S3.
4
+
5
+ ## Installation
6
+
7
+ gem install s3ftp
8
+
9
+ ## Configuration
10
+
11
+ 1. Upload a passwd file to your S3 bucket. It should contain a single line
12
+ per user and be a CSV. It should look something like this
13
+
14
+ user1,password,y
15
+ user2,password,n
16
+
17
+ the third column indicates the users administrator status. Administrators can
18
+ see all files. Regular users are sandboxed to their own directory.
19
+
20
+ 2. Create a config.rb file that looks something like this
21
+
22
+ require 's3ftp'
23
+
24
+ AWS_KEY = 'foo'
25
+ AWS_SECRET = 'bar'
26
+ AWS_BUCKET = 'my-ftp-bucket'
27
+
28
+ driver S3FTP::Driver
29
+ driver_args AWS_KEY, AWS_SECRET, AWS_BUCKET
30
+
31
+ 3. As root, run 'em-ftpd config.rb'
32
+
33
+ ## License
34
+
35
+ This library is distributed under the terms of the MIT License. See the included file for
36
+ more detail.
@@ -0,0 +1,116 @@
1
+ require 'uri'
2
+ require 'cgi'
3
+
4
+ module Happening
5
+ module S3
6
+ class Bucket
7
+ include Utils
8
+
9
+ REQUIRED_FIELDS = [:server]
10
+ VALID_HEADERS = ['Cache-Control', 'Content-Disposition', 'Content-Encoding', 'Content-Length', 'Content-MD5', 'Content-Type', 'Expect', 'Expires']
11
+
12
+ attr_accessor :bucket, :options
13
+
14
+ def initialize(bucket, options = {})
15
+ @options = {
16
+ :timeout => 10,
17
+ :server => 's3.amazonaws.com',
18
+ :protocol => 'https',
19
+ :aws_access_key_id => nil,
20
+ :aws_secret_access_key => nil,
21
+ :retry_count => 4,
22
+ :permissions => 'private',
23
+ :ssl => Happening::S3.ssl_options,
24
+ :prefix => nil,
25
+ :delimiter => nil
26
+ }.update(symbolize_keys(options))
27
+ assert_valid_keys(options, :timeout, :server, :protocol, :aws_access_key_id, :aws_secret_access_key, :retry_count, :permissions, :ssl, :prefix, :delimiter)
28
+ @bucket = bucket.to_s
29
+
30
+ validate
31
+ end
32
+
33
+ def get(request_options = {}, &blk)
34
+ headers = needs_to_sign? ? aws.sign("GET", path_with_query) : {}
35
+ request_options[:on_success] = blk if blk
36
+ request_options.update(:headers => headers)
37
+ Happening::S3::Request.new(:get, url, {:ssl => options[:ssl]}.update(request_options)).execute
38
+ end
39
+
40
+ def url
41
+ URI::Generic.new(options[:protocol], nil, server, port, nil, path(!dns_bucket?), nil, query_string, nil).to_s
42
+ end
43
+
44
+ def server
45
+ dns_bucket? ? "#{bucket}.#{options[:server]}" : options[:server]
46
+ end
47
+
48
+ def path(with_bucket=true)
49
+ with_bucket ? "/#{bucket}/" : "/"
50
+ end
51
+
52
+ def path_with_query(with_bucket=true)
53
+ base = path(with_bucket)
54
+ query = query_string
55
+ query ? "#{base}?#{query}" : base
56
+ end
57
+
58
+ protected
59
+
60
+ def query_string
61
+ if @options[:prefix] || @options[:delimiter]
62
+ str = ""
63
+ str += "prefix=#{CGI::escape(@options[:prefix])}&" if @options[:prefix]
64
+ str += "delimiter=#{CGI::escape(@options[:delimiter])}&" if @options[:delimiter]
65
+ str.gsub(/&\Z/,"")
66
+ else
67
+ nil
68
+ end
69
+ end
70
+
71
+ def needs_to_sign?
72
+ present?(options[:aws_access_key_id])
73
+ end
74
+
75
+ def dns_bucket?
76
+ # http://docs.amazonwebservices.com/AmazonS3/2006-03-01/index.html?BucketRestrictions.html
77
+ return false unless (3..63) === bucket.size
78
+ bucket.split('.').each do |component|
79
+ return false unless component[/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/]
80
+ end
81
+ true
82
+ end
83
+
84
+ def port
85
+ (options[:protocol].to_s == 'https') ? 443 : 80
86
+ end
87
+
88
+ def validate
89
+ raise ArgumentError, "need a bucket name" unless present?(bucket)
90
+
91
+ REQUIRED_FIELDS.each do |field|
92
+ raise ArgumentError, "need field #{field}" unless present?(options[field])
93
+ end
94
+
95
+ raise ArgumentError, "unknown protocol #{options[:protocol]}" unless ['http', 'https'].include?(options[:protocol])
96
+ end
97
+
98
+ def aws
99
+ @aws ||= Happening::AWS.new(options[:aws_access_key_id], options[:aws_secret_access_key])
100
+ end
101
+
102
+ def construct_aws_headers(http_method, headers = {})
103
+ unless headers.keys.all?{|header| VALID_HEADERS.include?(header) || header.to_s.match(/\Ax-amz-/) }
104
+ raise ArgumentError, "invalid headers. All headers must either one of #{VALID_HEADERS} or start with 'x-amz-'"
105
+ end
106
+
107
+ permissions = options[:permissions] != 'private' ? {'x-amz-acl' => options[:permissions] } : {}
108
+ headers.update(permissions)
109
+ headers.update({'url' => path})
110
+
111
+ headers = needs_to_sign? ? aws.sign(http_method, path, headers) : headers
112
+ end
113
+
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,11 @@
1
+ require 'em-ftpd'
2
+ require 'happening'
3
+ require 'csv'
4
+ require 'nokogiri'
5
+
6
+ # load our happening extension for interacting with buckets. Hopefully
7
+ # it will be accepted upstream soon and can be dropped
8
+ require 'happening/s3/bucket'
9
+
10
+ # and now the secret sauce
11
+ require 's3ftp/driver'
@@ -0,0 +1,245 @@
1
+ # coding: utf-8
2
+
3
+ require 'tempfile'
4
+
5
+ module S3FTP
6
+ class Driver
7
+
8
+ USER = 0
9
+ PASS = 1
10
+ ADMIN = 2
11
+
12
+ def initialize(key, secret, bucket)
13
+ @aws_key, @aws_secret, @aws_bucket = key, secret, bucket
14
+ end
15
+
16
+ def change_dir(path, &block)
17
+ prefix = scoped_path(path)
18
+
19
+ item = Happening::S3::Bucket.new(@aws_bucket, :aws_access_key_id => @aws_key, :aws_secret_access_key => @aws_secret, :prefix => prefix, :delimiter => "/")
20
+ item.get do |response|
21
+ yield contains_directory?(response.response, prefix)
22
+ end
23
+ end
24
+
25
+ def dir_contents(path, &block)
26
+ prefix = scoped_path_with_trailing_slash(path)
27
+
28
+ on_error = Proc.new {|response| yield false }
29
+ on_success = Proc.new {|response| yield response.response_header["CONTENT_LENGTH"].to_i }
30
+
31
+ item = Happening::S3::Bucket.new(@aws_bucket, :aws_access_key_id => @aws_key, :aws_secret_access_key => @aws_secret, :prefix => prefix, :delimiter => "/")
32
+ item.get do |response|
33
+ yield parse_bucket_list(response.response)
34
+ end
35
+ end
36
+
37
+ def authenticate(user, pass, &block)
38
+ download_passwd_file do |passwd|
39
+ @users = extract_users(passwd)
40
+
41
+ if @users[user] && @users[user][:pass] == pass
42
+ @user = user
43
+ yield true
44
+ else
45
+ yield false
46
+ end
47
+ end
48
+ end
49
+
50
+ def bytes(path, &block)
51
+ key = scoped_path(path)
52
+
53
+ on_error = Proc.new {|response| yield false }
54
+ on_success = Proc.new {|response| yield response.response_header["CONTENT_LENGTH"].to_i }
55
+
56
+ item = Happening::S3::Item.new(@aws_bucket, key, :aws_access_key_id => @aws_key, :aws_secret_access_key => @aws_secret)
57
+ item.head(:retry_count => 0, :on_success => on_success, :on_error => on_error)
58
+ end
59
+
60
+ def get_file(path, &block)
61
+ key = scoped_path(path)
62
+
63
+ # open a tempfile to store the file as it's downloaded from S3.
64
+ # em-ftpd will close it for us
65
+ tmpfile = Tempfile.new("s3ftp")
66
+
67
+ on_error = Proc.new {|response| yield false }
68
+ on_success = Proc.new {|response|
69
+ tmpfile.flush
70
+ tmpfile.seek(0)
71
+ yield tmpfile
72
+ }
73
+
74
+ item = Happening::S3::Item.new(@aws_bucket, key, :aws_access_key_id => @aws_key, :aws_secret_access_key => @aws_secret)
75
+ item.get(:retry_count => 1, :on_success => on_success, :on_error => on_error).stream do |chunk|
76
+ tmpfile.write chunk
77
+ end
78
+ end
79
+
80
+ def put_file(path, tmp_path, &block)
81
+ key = scoped_path(path)
82
+
83
+ bytes = File.size(tmp_path)
84
+ on_error = Proc.new {|response| yield false }
85
+ on_success = Proc.new {|response| yield bytes }
86
+
87
+ item = Happening::S3::Item.new(@aws_bucket, key, :aws_access_key_id => @aws_key, :aws_secret_access_key => @aws_secret)
88
+ item.put(File.binread(tmp_path), :retry_count => 0, :on_success => on_success, :on_error => on_error)
89
+ end
90
+
91
+ def delete_file(path, &block)
92
+ key = scoped_path(path)
93
+
94
+ on_error = Proc.new {|response| yield false }
95
+ on_success = Proc.new {|response| yield true }
96
+
97
+ item = Happening::S3::Item.new(@aws_bucket, key, :aws_access_key_id => @aws_key, :aws_secret_access_key => @aws_secret)
98
+ item.delete(:retry_count => 1, :on_success => on_success, :on_error => on_error)
99
+ end
100
+
101
+ def delete_dir(path, &block)
102
+ prefix = scoped_path(path)
103
+
104
+ on_error = Proc.new {|response| yield false }
105
+
106
+ item = Happening::S3::Bucket.new(@aws_bucket, :aws_access_key_id => @aws_key, :aws_secret_access_key => @aws_secret, :prefix => prefix)
107
+ item.get(:on_error => on_error) do |response|
108
+ keys = bucket_list_to_full_keys(response.response)
109
+ delete_object = Proc.new { |key, iter|
110
+ item = Happening::S3::Item.new(@aws_bucket, key, :aws_access_key_id => @aws_key, :aws_secret_access_key => @aws_secret)
111
+ item.delete(:retry_count => 1, :on_error => on_error) do |response|
112
+ iter.next
113
+ end
114
+ }
115
+ on_complete = Proc.new { yield true }
116
+
117
+ EM::Iterator.new(keys, 5).each(delete_object, on_complete)
118
+ end
119
+ end
120
+
121
+ def rename(from, to, &block)
122
+ source_key = scoped_path(from)
123
+ source_obj = @aws_bucket + "/" + source_key
124
+ dest_key = scoped_path(to)
125
+
126
+ on_error = Proc.new {|response| yield false }
127
+ on_success = Proc.new {|response| yield true }
128
+
129
+ item = Happening::S3::Item.new(@aws_bucket, dest_key, :aws_access_key_id => @aws_key, :aws_secret_access_key => @aws_secret)
130
+ item.put(nil, :retry_count => 1, :on_error => on_error, :headers => {"x-amz-copy-source" => source_obj}) do |response|
131
+ item = Happening::S3::Item.new(@aws_bucket, source_key, :aws_access_key_id => @aws_key, :aws_secret_access_key => @aws_secret)
132
+ item.delete(:retry_count => 1, :on_success => on_success, :on_error => on_error)
133
+ end
134
+ end
135
+
136
+ def make_dir(path, &block)
137
+ key = scoped_path(path) + "/.dir"
138
+
139
+ on_error = Proc.new {|response| yield false }
140
+ on_success = Proc.new {|response| yield true }
141
+
142
+ item = Happening::S3::Item.new(@aws_bucket, key, :aws_access_key_id => @aws_key, :aws_secret_access_key => @aws_secret)
143
+ item.put("", :retry_count => 0, :on_success => on_success, :on_error => on_error)
144
+ end
145
+
146
+ private
147
+
148
+ def extract_users(passwd)
149
+ users = {}
150
+ CSV.parse(passwd).each { |row|
151
+ users[row[USER]] = {
152
+ :pass => row[PASS],
153
+ :admin => row[ADMIN].to_s.upcase == "Y"
154
+ }
155
+ }
156
+ users
157
+ end
158
+
159
+ def download_passwd_file(&block)
160
+ on_error = Proc.new { |response|
161
+ yield false
162
+ }
163
+ on_success = Proc.new { |response|
164
+ yield response.response
165
+ }
166
+ item = Happening::S3::Item.new(@aws_bucket, 'passwd', :aws_access_key_id => @aws_key, :aws_secret_access_key => @aws_secret)
167
+ item.get(:on_success => on_success, :on_error => on_error)
168
+ end
169
+
170
+ def admin?
171
+ @users[@user] && @users[@user][:admin]
172
+ end
173
+
174
+ def scoped_path_with_trailing_slash(path)
175
+ path = scoped_path(path)
176
+ path += "/" if path[-1,1] != "/"
177
+ path == "/" ? nil : path
178
+ end
179
+
180
+ def scoped_path(path)
181
+ path = "" if path == "/"
182
+
183
+ if admin?
184
+ File.join("/", path)[1,1024]
185
+ else
186
+ File.join("/", @user, path)[1,1024]
187
+ end
188
+ end
189
+
190
+ def bucket_list_to_full_keys(xml)
191
+ doc = Nokogiri::XML(xml)
192
+ doc.remove_namespaces!
193
+ doc.xpath('//Contents').map { |node|
194
+ node.xpath('./Key').first.content
195
+ }
196
+ end
197
+
198
+ def contains_directory?(xml, path)
199
+ doc = Nokogiri::XML(xml)
200
+ doc.remove_namespaces!
201
+ prefix = doc.xpath('/ListBucketResult/Prefix').first.content
202
+
203
+ doc.xpath('//CommonPrefixes').any? { |node|
204
+ name = node.xpath('./Prefix').first.content
205
+
206
+ name.to_s.start_with?(prefix)
207
+ }
208
+ end
209
+
210
+ def parse_bucket_list(xml)
211
+ doc = Nokogiri::XML(xml)
212
+ doc.remove_namespaces!
213
+ prefix = doc.xpath('/ListBucketResult/Prefix').first.content
214
+ files = doc.xpath('//Contents').select { |node|
215
+ name = node.xpath('./Key').first.content
216
+ bytes = node.xpath('./Size').first.content.to_i
217
+ name != prefix && bytes > 0
218
+ }.map { |node|
219
+ name = node.xpath('./Key').first.content
220
+ bytes = node.xpath('./Size').first.content
221
+ file_item(name[prefix.size, 1024], bytes)
222
+ }
223
+ dirs = doc.xpath('//CommonPrefixes').select { |node|
224
+ node.xpath('./Prefix').first.content != prefix + "/"
225
+ }.map { |node|
226
+ name = node.xpath('./Prefix').first.content
227
+ dir_item(name[prefix.size, 1024].tr("/",""))
228
+ }
229
+ default_dirs + dirs + files
230
+ end
231
+
232
+ def default_dirs
233
+ [dir_item("."), dir_item("..")]
234
+ end
235
+
236
+ def dir_item(name)
237
+ EM::FTPD::DirectoryItem.new(:name => name, :directory => true, :size => 0)
238
+ end
239
+
240
+ def file_item(name, bytes)
241
+ EM::FTPD::DirectoryItem.new(:name => name, :directory => false, :size => bytes)
242
+ end
243
+
244
+ end
245
+ end
metadata ADDED
@@ -0,0 +1,113 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: s3ftp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - James Healy
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2011-11-18 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rake
16
+ requirement: &21376320 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: *21376320
25
+ - !ruby/object:Gem::Dependency
26
+ name: rspec
27
+ requirement: &21375560 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ~>
31
+ - !ruby/object:Gem::Version
32
+ version: '2.6'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: *21375560
36
+ - !ruby/object:Gem::Dependency
37
+ name: em-ftpd
38
+ requirement: &21374880 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ! '>='
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ type: :runtime
45
+ prerelease: false
46
+ version_requirements: *21374880
47
+ - !ruby/object:Gem::Dependency
48
+ name: happening
49
+ requirement: &21374100 !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ! '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ type: :runtime
56
+ prerelease: false
57
+ version_requirements: *21374100
58
+ - !ruby/object:Gem::Dependency
59
+ name: nokogiri
60
+ requirement: &21373140 !ruby/object:Gem::Requirement
61
+ none: false
62
+ requirements:
63
+ - - ! '>='
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ type: :runtime
67
+ prerelease: false
68
+ version_requirements: *21373140
69
+ description: Run an FTP server that persists all data to an Amazon S3 bucket
70
+ email:
71
+ - jimmy@deefa.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files:
75
+ - README.markdown
76
+ - MIT-LICENSE
77
+ files:
78
+ - lib/happening/s3/bucket.rb
79
+ - lib/s3ftp.rb
80
+ - lib/s3ftp/driver.rb
81
+ - Gemfile
82
+ - README.markdown
83
+ - MIT-LICENSE
84
+ homepage: http://github.com/yob/s3ftp
85
+ licenses: []
86
+ post_install_message:
87
+ rdoc_options:
88
+ - --title
89
+ - S3-FTP Documentation
90
+ - --main
91
+ - README.markdown
92
+ - -q
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ none: false
97
+ requirements:
98
+ - - ! '>='
99
+ - !ruby/object:Gem::Version
100
+ version: 1.9.2
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ none: false
103
+ requirements:
104
+ - - ! '>='
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ requirements: []
108
+ rubyforge_project:
109
+ rubygems_version: 1.8.11
110
+ signing_key:
111
+ specification_version: 3
112
+ summary: An FTP proxy in front of an Amazon S3 bucket
113
+ test_files: []