volt-s3_uploader 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: fd5126bdbee2912320391bb5e8be4e58bd48ba64
4
+ data.tar.gz: b98b96ebdb6be1589bab842cbf2759bdbb01a384
5
+ SHA512:
6
+ metadata.gz: 993db98ac17a24f17f1488256decaa23f5d92d703498bd06dffdf0c975e0ef7e58616c00c86d4f23a8d2bcf617d1a5c05c9a14cf2710e1c2037fd8275ead7b9d
7
+ data.tar.gz: 92d501495cb8d6c9fc20b0ead27a406b6012f8a1397d1a82012560a53595d8c3dfe5b147cfb27cf1dd95933833e23738c93894e49fa9a6afe7c31047901b982a
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
@@ -0,0 +1,13 @@
1
+ # Contributor Code of Conduct
2
+
3
+ As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.
4
+
5
+ We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion.
6
+
7
+ Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct.
8
+
9
+ Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team.
10
+
11
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers.
12
+
13
+ This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/)
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in volt-s3_uploader.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2015 Ryan Stout
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # S3 Uploader
2
+
3
+ This gem provides a simple way to upload files directly from the browser to s3. Going straight to s3 simplifies serving and makes it easier to deploy to read only PaaS's like heroku. While s3 upload is usually more compilcated, this gem tries to make it simple. It only uploads files and does not do resizing. You can handle resizing with the volt-s3_image_resizer gem.
4
+
5
+ ## How it works
6
+
7
+ When an app with this gem boots, it checks the s3 api and if needed configures the buckets to support CORS. Before a file upload starts, this gem hits a task that returns a signed upload url. The url allows the browser to upload the file directly to s3.
8
+
9
+
10
+ TODO:
11
+ When the model does save on the server, the gem will add a meta-data to the file to mark it as a permenant file. The gem includes a task to periodically clean uploaded files that never had their associated models saved.
12
+
13
+ ## Setup
14
+
15
+ Signup for an S3 account, and generate a aws key and secret.
16
+
17
+ In ```config/app.rb``` add:
18
+
19
+ ```ruby
20
+ Volt.config.s3.key = ''
21
+ Volt.config.s3.secret = ''
22
+ ```
23
+
24
+ In the component you want to use the uploader from, add:
25
+
26
+ ```ruby
27
+ component 's3_uploader'
28
+ ```
29
+
30
+ In the model you wish to attach the file to, add:
31
+
32
+ ```ruby
33
+ attachment :file, 'bucket_name_' + Volt.env.to_s
34
+ ```
35
+
36
+ Make sure the bucket name is unique, otherwise it will be taken. Adding the ENV helps make dev/testing work easier.
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1 @@
1
+ # Component dependencies
@@ -0,0 +1,12 @@
1
+ # Place any code you want to run when the component is included on the client
2
+ # or server.
3
+
4
+ # To include code only on the client use:
5
+ # if RUBY_PLATFORM == 'opal'
6
+ #
7
+ # To include code only on the server, use:
8
+ # unless RUBY_PLATFORM == 'opal'
9
+ # ^^ this will not send compile in code in the conditional to the client.
10
+ # ^^ this include code required in the conditional.
11
+
12
+ require 's3_uploader/lib/s3_attachment'
@@ -0,0 +1 @@
1
+ # Component routes
@@ -0,0 +1,103 @@
1
+ module S3Uploader
2
+ class MainController < Volt::ModelController
3
+ reactive_accessor :progress
4
+ reactive_accessor :error_message
5
+ reactive_accessor :s3_url
6
+ reactive_accessor :bucket
7
+ attr_accessor :uploading_promise
8
+
9
+ if RUBY_PLATFORM == 'opal'
10
+
11
+ def bucket_name
12
+ file_field_name = attrs.value_last_method
13
+ attrs.value_parent.send(file_field_name + "_bucket")
14
+ end
15
+
16
+ def upload(event)
17
+ target = event.target
18
+ set_progress(0)
19
+
20
+ file = nil
21
+ `
22
+ var files = target.files;
23
+
24
+ var output = [];
25
+ for (var i = 0, f; file = files[i]; i++) {
26
+ #{upload_file(file)}
27
+ }
28
+ `
29
+ end
30
+
31
+ def upload_file(file)
32
+ self.uploading_promise = Promise.new
33
+ puts "UP FILE: #{uploading_promise.inspect}"
34
+
35
+ S3UploadTasks.sign(bucket_name, `file.name`, `file.type`).then do |private_and_public|
36
+ self.s3_url = private_and_public[1]
37
+ upload_with_url(file, private_and_public[0])
38
+ end.fail do |err|
39
+ set_progress(0, 'Could not contact signing script. Status = ' + err.to_s)
40
+ end
41
+ end
42
+
43
+ def upload_with_url(file, signed_url)
44
+ # create PUT request to S3
45
+ `
46
+ var xhr = new XMLHttpRequest();
47
+ xhr.open('PUT', signed_url);
48
+ xhr.setRequestHeader('Content-Type', file.type);
49
+
50
+ xhr.onload = function() {
51
+ if (xhr.status == 200) {
52
+ #{uploaded}
53
+ }
54
+ };
55
+
56
+ xhr.onerror = function(e) {
57
+ self.$error(e.error || 'Upload Error');
58
+ };
59
+
60
+ xhr.upload.onprogress = function(e) {
61
+ console.log('onprogress');
62
+
63
+ if (e.lengthComputable) {
64
+ #{percentLoaded = `Math.round((e.loaded / e.total) * 100)`};
65
+ #{set_progress(percentLoaded, nil)}
66
+ }
67
+ };
68
+
69
+ xhr.send(file);
70
+ `
71
+
72
+ nil
73
+ end
74
+
75
+ # Resolve the uploading promise
76
+ def uploaded
77
+ promise = self.uploading_promise
78
+ self.uploading_promise = nil
79
+
80
+ set_progress(100, nil, true)
81
+ promise.resolve(nil)
82
+ end
83
+
84
+ def error(msg)
85
+ msg = msg.to_s
86
+ promsise = self.uploading_promise
87
+ self.uploading_promise = nil
88
+
89
+ set_progress(0, msg, true)
90
+ promsise.reject(msg)
91
+ end
92
+
93
+ def set_progress(progress, error=nil, done=false)
94
+ self.progress = progress
95
+ self.error_message = error
96
+
97
+ if attrs.respond_to?(:value)
98
+ attrs.value = [progress, done ? s3_url : nil, @uploading_promise]
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,78 @@
1
+ # Included into Volt::Model to provide ```attachment```
2
+ require 's3_uploader/lib/s3_bucket_manager' unless RUBY_PLATFORM == 'opal'
3
+
4
+ module S3Uploader
5
+ module S3Attachment
6
+ module ClassMethods
7
+ def attachment(name, bucket)
8
+ unless RUBY_PLATFORM == 'opal'
9
+ # Create the bucket and set the CORS policy if needed
10
+ Volt.current_app.once('post_boot') do
11
+ S3BucketManager.ensure_bucket(bucket)
12
+ end
13
+ end
14
+
15
+ url_field_name = :"#{name}_url"
16
+ field(url_field_name, String)
17
+
18
+ upload_percent_field_name = :"#{name}_upload_percent"
19
+ reactive_accessor(upload_percent_field_name)
20
+
21
+ promise_field_name = :"#{name}_uploading_promise"
22
+ attr_accessor(promise_field_name)
23
+
24
+ # Create a method that returns the bucket passed in
25
+ define_method(:"#{name}_bucket") do
26
+ bucket
27
+ end
28
+
29
+ unless RUBY_PLATFORM == 'opal'
30
+ s3 = Volt.config.s3
31
+ current_buckets = (s3 ? s3.to_h[:buckets] : nil) || []
32
+ current_buckets << bucket
33
+
34
+ Volt.configure do |config|
35
+ config.s3.buckets = current_buckets
36
+ end
37
+ end
38
+
39
+ # An aggrate method is created for assigning to the value="{{ name }}"
40
+ # property of the tag.
41
+ define_method(name) do
42
+ url = send(url_field_name)
43
+ upload_percent = send(upload_percent_field_name)
44
+ [url, upload_percent]
45
+ end
46
+
47
+ define_method(:"#{name}=") do |val|
48
+ send(:"#{upload_percent_field_name}=", val[0])
49
+ send(:"#{url_field_name}=", val[1])
50
+ send(:"#{promise_field_name}=", val[2])
51
+ end
52
+
53
+ validate do
54
+ # MainController (linked via the value attribute on the s3-upload tag)
55
+ # will set a promise, which it resolves when the file is upload (if it
56
+ # is uploading).
57
+ promise = send(promise_field_name)
58
+
59
+ if promise.is_a?(Promise) && !promise.realized?
60
+ promise = promise.then do
61
+ puts "Uploaded"
62
+ trigger!("uploaded_#{name}")
63
+ end
64
+ end
65
+
66
+ # return the promise
67
+ promise
68
+ end
69
+ end
70
+ end
71
+
72
+ def self.included(base)
73
+ base.send(:extend, ClassMethods)
74
+ end
75
+ end
76
+ end
77
+
78
+ Volt::Model.include(S3Uploader::S3Attachment)
@@ -0,0 +1,47 @@
1
+ module S3Uploader
2
+ class S3BucketManager
3
+ def self.ensure_bucket(bucket_name)
4
+ prop_key = "s3_bucket_#{bucket_name}"
5
+ unless Volt.current_app.properties[prop_key]
6
+ Volt.logger.info("Creating/Setting up S3 Bucket: #{bucket_name}")
7
+
8
+ creds = Aws::Credentials.new(Volt.config.s3.key, Volt.config.s3.secret)
9
+ s3 = Aws::S3::Resource.new(region: 'us-east-1', credentials: creds)
10
+
11
+ s3.create_bucket(
12
+ {
13
+ acl: "private", # accepts private, public-read, public-read-write, authenticated-read
14
+ bucket: bucket_name, # required
15
+ # create_bucket_configuration: {
16
+ # location_constraint: "us-east-1", # accepts EU, eu-west-1, us-west-1, us-west-2, ap-southeast-1, ap-southeast-2, ap-northeast-1, sa-east-1, cn-north-1, eu-central-1
17
+ # },
18
+ # grant_full_control: "GrantFullControl",
19
+ # grant_read: "GrantRead",
20
+ # grant_read_acp: "GrantReadACP",
21
+ # grant_write: "GrantWrite",
22
+ # grant_write_acp: "GrantWriteACP",
23
+ }
24
+ )
25
+
26
+ bucket = s3.bucket(bucket_name)
27
+
28
+ bucket.cors.put(
29
+ {
30
+ bucket: bucket_name,
31
+ cors_configuration: {
32
+ cors_rules: [
33
+ {
34
+ allowed_origins: ["*"],
35
+ allowed_methods: ["GET", "POST", "PUT"], # required
36
+ allowed_headers: ["*"]
37
+ },
38
+ ],
39
+ }
40
+ }
41
+ )
42
+
43
+ Volt.current_app.properties[prop_key] = '1'
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,43 @@
1
+ require 'cgi'
2
+ require 'securerandom'
3
+
4
+ class S3UploadTasks < Volt::Task
5
+ EXPIRE_TIME = (60 * 5) # 5 minutes
6
+ S3_URL = 'http://s3.amazonaws.com'
7
+ LIMIT = 100 * 1024 * 1024 # 100 MB
8
+
9
+ BUCKETS = Volt.config.s3.buckets
10
+
11
+ def sign(bucket_name, filename, mime_type)
12
+ s3 = Volt.config.s3.to_h
13
+
14
+ buckets = s3[:buckets]
15
+ key = s3[:key]
16
+ secret = s3[:secret]
17
+
18
+ [:buckets, :key, :secret].each do |prop|
19
+ unless s3[prop]
20
+ raise "s3_upload configure issue: Please configure Volt.config.s3.#{prop.to_s}"
21
+ end
22
+ end
23
+
24
+ unless buckets.include?(bucket_name)
25
+ raise "The bucket passed in (#{bucket_name.inspect}) does not match the list of supported buckets"
26
+ end
27
+
28
+ extname = File.extname(filename)
29
+ filename = "#{SecureRandom.uuid}#{extname}"
30
+ upload_key = Pathname.new(filename).to_s
31
+
32
+ creds = Aws::Credentials.new(Volt.config.s3.key, Volt.config.s3.secret)
33
+ s3 = Aws::S3::Resource.new(region: 'us-east-1', credentials: creds)
34
+ bucket = s3.bucket(bucket_name)
35
+
36
+ obj = bucket.object(upload_key)
37
+
38
+ params = { acl: 'public-read' }
39
+ # params[:content_length] = LIMIT if LIMIT
40
+
41
+ [obj.presigned_url(:put, params), obj.public_url]
42
+ end
43
+ end
@@ -0,0 +1,19 @@
1
+ <div class="form-group">
2
+ {{ if attrs.label.present? }}
3
+ <label class="control-label">{{ attrs.label }}</label>
4
+ {{ end }}
5
+ <input type="file" id="files" e-change="upload" name="files" />
6
+ {{ if progress.present? }}
7
+ <div class="progress">
8
+ <div class="progress-bar" role="progressbar" aria-valuenow="{{ progress }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ progress }}%;">
9
+ {{ if progress > 0 }}
10
+ {{ progress }}% Uploaded
11
+ {{ end }}
12
+ </div>
13
+ </div>
14
+ {{ end }}
15
+ {{ if error_message.present? }}
16
+ <span class="control-label errors">{{ error_message }}</span>
17
+ {{ end }}
18
+ </div>
19
+
@@ -0,0 +1,19 @@
1
+ # If you need to require in code in the gem's app folder, keep in mind that
2
+ # the app is not on the load path when the gem is required. Use
3
+ # app/{gemname}/config/initializers/boot.rb to require in client or server
4
+ # code.
5
+ #
6
+ # Also, in volt apps, you typically use the lib folder in the
7
+ # app/{componentname} folder instead of this lib folder. This lib folder is
8
+ # for setting up gem code when Bundler.require is called. (or the gem is
9
+ # required.)
10
+ #
11
+ # If you need to configure volt in some way, you can add a Volt.configure block
12
+ # in this file.
13
+
14
+ require 'aws-sdk'
15
+
16
+ module Volt
17
+ module S3Uploader
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ module Volt
2
+ module S3Uploader
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,14 @@
1
+ # Volt sets up rspec and capybara for testing.
2
+ require 'volt/spec/setup'
3
+ Volt.spec_setup
4
+
5
+ RSpec.configure do |config|
6
+ config.run_all_when_everything_filtered = true
7
+ config.filter_run :focus
8
+
9
+ # Run specs in random order to surface order dependencies. If you find an
10
+ # order dependency and want to debug it, you can fix the order by providing
11
+ # the seed, which is printed after each run.
12
+ # --seed 1234
13
+ config.order = 'random'
14
+ end
@@ -0,0 +1,11 @@
1
+ require 'spec_helper'
2
+
3
+ describe Volt::S3Uploader do
4
+ it 'should have a version number' do
5
+ expect(Volt::S3Uploader::VERSION).not_to be nil
6
+ end
7
+
8
+ it 'should do something useful' do
9
+ expect(false).to be true
10
+ end
11
+ end
@@ -0,0 +1,24 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'volt/s3_uploader/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "volt-s3_uploader"
8
+ spec.version = Volt::S3Uploader::VERSION
9
+ spec.authors = ["Ryan Stout"]
10
+ spec.email = ["ryanstout@gmail.com"]
11
+ spec.summary = %q{A direct to s3 file uploader}
12
+ spec.homepage = ""
13
+ spec.license = "MIT"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0")
16
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
+ spec.require_paths = ["lib"]
19
+
20
+ spec.add_dependency 'aws-sdk', '~> 2.1.15'
21
+ spec.add_dependency 'volt', '~> 0.9.6'
22
+ spec.add_development_dependency 'rspec', '~> 3.2.0'
23
+ spec.add_development_dependency "rake"
24
+ end
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: volt-s3_uploader
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ryan Stout
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-11-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: aws-sdk
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 2.1.15
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 2.1.15
27
+ - !ruby/object:Gem::Dependency
28
+ name: volt
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.9.6
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.9.6
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 3.2.0
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 3.2.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ description:
70
+ email:
71
+ - ryanstout@gmail.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - ".gitignore"
77
+ - ".rspec"
78
+ - CODE_OF_CONDUCT.md
79
+ - Gemfile
80
+ - LICENSE.txt
81
+ - README.md
82
+ - Rakefile
83
+ - app/s3_uploader/config/dependencies.rb
84
+ - app/s3_uploader/config/initializers/boot.rb
85
+ - app/s3_uploader/config/routes.rb
86
+ - app/s3_uploader/controllers/main_controller.rb
87
+ - app/s3_uploader/lib/s3_attachment.rb
88
+ - app/s3_uploader/lib/s3_bucket_manager.rb
89
+ - app/s3_uploader/tasks/s3_upload_tasks.rb
90
+ - app/s3_uploader/views/main/index.html
91
+ - lib/volt/s3_uploader.rb
92
+ - lib/volt/s3_uploader/version.rb
93
+ - spec/spec_helper.rb
94
+ - spec/volt/s3_uploader_spec.rb
95
+ - volt-s3_uploader.gemspec
96
+ homepage: ''
97
+ licenses:
98
+ - MIT
99
+ metadata: {}
100
+ post_install_message:
101
+ rdoc_options: []
102
+ require_paths:
103
+ - lib
104
+ required_ruby_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
109
+ required_rubygems_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ requirements: []
115
+ rubyforge_project:
116
+ rubygems_version: 2.4.5
117
+ signing_key:
118
+ specification_version: 4
119
+ summary: A direct to s3 file uploader
120
+ test_files:
121
+ - spec/spec_helper.rb
122
+ - spec/volt/s3_uploader_spec.rb