mongoid_direct_s3_upload 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (88) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +246 -0
  4. data/Rakefile +31 -0
  5. data/app/assets/javascripts/s3_relay.coffee +118 -0
  6. data/app/assets/stylesheets/s3_relay.css +31 -0
  7. data/app/controllers/s3_relay/uploads_controller.rb +71 -0
  8. data/app/helpers/s3_relay/uploads_helper.rb +24 -0
  9. data/app/models/s3_relay/upload.rb +73 -0
  10. data/config/routes.rb +5 -0
  11. data/lib/s3_relay.rb +4 -0
  12. data/lib/s3_relay/base.rb +35 -0
  13. data/lib/s3_relay/engine.rb +16 -0
  14. data/lib/s3_relay/model.rb +51 -0
  15. data/lib/s3_relay/private_url.rb +37 -0
  16. data/lib/s3_relay/s3_relay.rb +4 -0
  17. data/lib/s3_relay/upload_presigner.rb +61 -0
  18. data/lib/s3_relay/version.rb +3 -0
  19. data/test/controllers/s3_relay/uploads_controller_test.rb +144 -0
  20. data/test/dummy/README.md +24 -0
  21. data/test/dummy/Rakefile +6 -0
  22. data/test/dummy/app/assets/config/manifest.js +3 -0
  23. data/test/dummy/app/assets/javascripts/application.js +15 -0
  24. data/test/dummy/app/assets/javascripts/cable.js +13 -0
  25. data/test/dummy/app/assets/stylesheets/application.css +15 -0
  26. data/test/dummy/app/channels/application_cable/channel.rb +4 -0
  27. data/test/dummy/app/channels/application_cable/connection.rb +4 -0
  28. data/test/dummy/app/controllers/application_controller.rb +3 -0
  29. data/test/dummy/app/helpers/application_helper.rb +2 -0
  30. data/test/dummy/app/jobs/application_job.rb +2 -0
  31. data/test/dummy/app/mailers/application_mailer.rb +4 -0
  32. data/test/dummy/app/models/application_record.rb +3 -0
  33. data/test/dummy/app/models/product.rb +6 -0
  34. data/test/dummy/app/views/layouts/application.html.erb +14 -0
  35. data/test/dummy/app/views/layouts/mailer.html.erb +13 -0
  36. data/test/dummy/app/views/layouts/mailer.text.erb +1 -0
  37. data/test/dummy/bin/bundle +3 -0
  38. data/test/dummy/bin/rails +9 -0
  39. data/test/dummy/bin/rake +9 -0
  40. data/test/dummy/bin/setup +38 -0
  41. data/test/dummy/bin/spring +17 -0
  42. data/test/dummy/bin/update +29 -0
  43. data/test/dummy/bin/yarn +11 -0
  44. data/test/dummy/config.ru +5 -0
  45. data/test/dummy/config/application.rb +19 -0
  46. data/test/dummy/config/boot.rb +3 -0
  47. data/test/dummy/config/cable.yml +10 -0
  48. data/test/dummy/config/database.yml +22 -0
  49. data/test/dummy/config/environment.rb +5 -0
  50. data/test/dummy/config/environments/development.rb +54 -0
  51. data/test/dummy/config/environments/production.rb +91 -0
  52. data/test/dummy/config/environments/test.rb +42 -0
  53. data/test/dummy/config/initializers/application_controller_renderer.rb +6 -0
  54. data/test/dummy/config/initializers/assets.rb +14 -0
  55. data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
  56. data/test/dummy/config/initializers/cookies_serializer.rb +5 -0
  57. data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  58. data/test/dummy/config/initializers/inflections.rb +16 -0
  59. data/test/dummy/config/initializers/mime_types.rb +4 -0
  60. data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
  61. data/test/dummy/config/locales/en.yml +33 -0
  62. data/test/dummy/config/puma.rb +56 -0
  63. data/test/dummy/config/routes.rb +7 -0
  64. data/test/dummy/config/secrets.yml +32 -0
  65. data/test/dummy/config/spring.rb +6 -0
  66. data/test/dummy/db/migrate/20141021002149_create_products.rb +9 -0
  67. data/test/dummy/db/schema.rb +41 -0
  68. data/test/dummy/db/seeds.rb +7 -0
  69. data/test/dummy/package.json +5 -0
  70. data/test/dummy/public/404.html +67 -0
  71. data/test/dummy/public/422.html +67 -0
  72. data/test/dummy/public/500.html +66 -0
  73. data/test/dummy/public/apple-touch-icon-precomposed.png +0 -0
  74. data/test/dummy/public/apple-touch-icon.png +0 -0
  75. data/test/dummy/public/favicon.ico +0 -0
  76. data/test/dummy/public/robots.txt +1 -0
  77. data/test/dummy/test/application_system_test_case.rb +5 -0
  78. data/test/dummy/test/test_helper.rb +9 -0
  79. data/test/factories/products.rb +5 -0
  80. data/test/factories/uploads.rb +23 -0
  81. data/test/helpers/s3_relay/uploads_helper_test.rb +29 -0
  82. data/test/lib/s3_relay/model_test.rb +66 -0
  83. data/test/lib/s3_relay/private_url_test.rb +28 -0
  84. data/test/lib/s3_relay/upload_presigner_test.rb +38 -0
  85. data/test/models/s3_relay/upload_test.rb +142 -0
  86. data/test/support/database_cleaner.rb +14 -0
  87. data/test/test_helper.rb +29 -0
  88. metadata +356 -0
@@ -0,0 +1,71 @@
1
+ class S3Relay::UploadsController < ApplicationController
2
+
3
+ before_action :authenticate
4
+ skip_before_action :verify_authenticity_token
5
+
6
+ def new
7
+ render json: S3Relay::UploadPresigner.new.form_data
8
+ end
9
+
10
+ def create
11
+ @upload = S3Relay::Upload.new(upload_attrs)
12
+
13
+ if @upload.save
14
+ data = {
15
+ private_url: @upload.private_url,
16
+ parent_type: @upload.parent_type,
17
+ parent_id: @upload.parent_id,
18
+ user_id: user_attrs[:user_id]
19
+ }
20
+ render json: data, status: 201
21
+ else
22
+ render json: { errors: @upload.errors }, status: 422
23
+ end
24
+ end
25
+
26
+ protected
27
+
28
+ def authenticate
29
+ if respond_to?(:authenticate_for_s3_relay)
30
+ authenticate_for_s3_relay
31
+ end
32
+ end
33
+
34
+ def parent_attrs
35
+ type = params[:parent_type].try(:classify)
36
+ id = params[:parent_id]
37
+
38
+ return {} unless type.present? && id.present? &&
39
+ parent = type.constantize.find(id)
40
+
41
+ begin
42
+ public_send(
43
+ "#{type.underscore.downcase}_#{params[:association]}_params",
44
+ parent
45
+ )
46
+ rescue NoMethodError
47
+ { parent: parent }
48
+ end
49
+ end
50
+
51
+ def upload_attrs
52
+ attrs = {
53
+ upload_type: params[:association].try(:classify),
54
+ uuid: params[:uuid],
55
+ filename: params[:filename],
56
+ content_type: params[:content_type]
57
+ }
58
+
59
+ attrs.merge!(parent_attrs)
60
+ attrs.merge!(user_attrs)
61
+ end
62
+
63
+ def user_attrs
64
+ if respond_to?(:current_user) && (id = current_user&.id)
65
+ { user_id: id }
66
+ else
67
+ {}
68
+ end
69
+ end
70
+
71
+ end
@@ -0,0 +1,24 @@
1
+ module S3Relay
2
+ module UploadsHelper
3
+
4
+ def s3_relay_field(parent, association, opts={})
5
+ file_field = file_field_tag(:file, opts.merge(class: "s3r-field"))
6
+ progress_table = content_tag(:table, "", class: "s3r-upload-list")
7
+ content = [file_field, progress_table].join
8
+ parent_type = parent.class.to_s.underscore.downcase
9
+
10
+ content_tag(:div, raw(content),
11
+ {
12
+ class: "s3r-container",
13
+ data: {
14
+ parent_type: parent_type,
15
+ parent_id: parent.id.to_s,
16
+ association: association.to_s,
17
+ disposition: opts.fetch(:disposition, "inline")
18
+ }
19
+ }
20
+ )
21
+ end
22
+
23
+ end
24
+ end
@@ -0,0 +1,73 @@
1
+ module S3Relay
2
+ class Upload
3
+
4
+ include Mongoid::Document
5
+
6
+ field :uuid
7
+ field :user_id, type: Integer
8
+ field :parent_type, type: String
9
+ field :parent_id, type: Integer
10
+ field :upload_type, type: String
11
+ field :filename, type: String
12
+ field :content_type, type: String
13
+ field :state, type: String
14
+ field :data, type: Hash, default: Hash.new
15
+ field :pending_at, type: DateTime
16
+ field :imported_at, type: DateTime
17
+
18
+ belongs_to :parent, polymorphic: true, optional: true
19
+
20
+ validates :uuid, presence: true, uniqueness: true
21
+ validates :upload_type, presence: true
22
+ validates :filename, presence: true
23
+ validates :content_type, presence: true
24
+ validates :pending_at, presence: true
25
+
26
+ after_initialize :finalize
27
+ after_create :notify_parent
28
+
29
+ def self.pending
30
+ where(state: "pending")
31
+ end
32
+
33
+ def self.imported
34
+ where(state: "imported")
35
+ end
36
+
37
+ def pending?
38
+ state == "pending"
39
+ end
40
+
41
+ def imported?
42
+ state == "imported"
43
+ end
44
+
45
+ def mark_imported!
46
+ update_attributes(state: "imported", imported_at: Time.now)
47
+ end
48
+
49
+ def notify_parent
50
+ return unless parent.present?
51
+
52
+ if parent.respond_to?(:import_upload)
53
+ parent.import_upload(id)
54
+ end
55
+ end
56
+
57
+ def public_url
58
+ S3Relay::PrivateUrl.new(uuid, filename).public_url
59
+ end
60
+
61
+ def private_url
62
+ S3Relay::PrivateUrl.new(uuid, filename).generate
63
+ end
64
+
65
+ private
66
+
67
+ def finalize
68
+ self.state ||= "pending"
69
+ self.pending_at ||= Time.now
70
+ end
71
+
72
+ end
73
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ S3Relay::Engine.routes.draw do
2
+
3
+ resources :uploads, only: [:new, :create]
4
+
5
+ end
data/lib/s3_relay.rb ADDED
@@ -0,0 +1,4 @@
1
+ require "s3_relay/engine"
2
+
3
+ module S3Relay
4
+ end
@@ -0,0 +1,35 @@
1
+ module S3Relay
2
+ class Base
3
+
4
+ private
5
+
6
+ def access_key_id
7
+ ENV["S3_RELAY_ACCESS_KEY_ID"]
8
+ end
9
+
10
+ def secret_access_key
11
+ ENV["S3_RELAY_SECRET_ACCESS_KEY"]
12
+ end
13
+
14
+ def region
15
+ ENV["S3_RELAY_REGION"]
16
+ end
17
+
18
+ def bucket
19
+ ENV["S3_RELAY_BUCKET"]
20
+ end
21
+
22
+ def acl
23
+ ENV["S3_RELAY_ACL"]
24
+ end
25
+
26
+ def endpoint
27
+ "https://#{bucket}.s3-#{region}.amazonaws.com"
28
+ end
29
+
30
+ def digest
31
+ OpenSSL::Digest.new("sha1")
32
+ end
33
+
34
+ end
35
+ end
@@ -0,0 +1,16 @@
1
+ module S3Relay
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace S3Relay
4
+
5
+ initializer "s3_relay.action_controller" do |app|
6
+ ActiveSupport.on_load :action_controller do
7
+ helper S3Relay::UploadsHelper
8
+ end
9
+ end
10
+ end
11
+ end
12
+
13
+ require "s3_relay/base"
14
+ require "s3_relay/model"
15
+ require "s3_relay/private_url"
16
+ require "s3_relay/upload_presigner"
@@ -0,0 +1,51 @@
1
+ module S3Relay
2
+ module Model
3
+
4
+ def s3_relay(attribute, has_many=false)
5
+ upload_type = attribute.to_s.classify
6
+
7
+ if has_many
8
+ has_many attribute, as: :parent, class_name: "S3Relay::Upload"
9
+
10
+ define_method attribute do
11
+ S3Relay::Upload
12
+ .where(
13
+ parent_type: self.class.to_s,
14
+ parent_id: self.id,
15
+ upload_type: upload_type
16
+ )
17
+ end
18
+ else
19
+ has_one attribute, as: :parent, class_name: "S3Relay::Upload"
20
+
21
+ define_method attribute do
22
+ S3Relay::Upload
23
+ .where(
24
+ parent_type: self.class.to_s,
25
+ parent_id: self.id,
26
+ upload_type: upload_type
27
+ )
28
+ .order(:pending_at => 'desc').last
29
+ end
30
+ end
31
+
32
+ virtual_attribute = "new_#{attribute}_uuids"
33
+ attr_accessor virtual_attribute
34
+
35
+ association_method = "associate_#{attribute}"
36
+
37
+ after_save association_method.to_sym
38
+
39
+ define_method association_method do
40
+ new_uuids = send(virtual_attribute)
41
+ return if new_uuids.blank?
42
+
43
+ S3Relay::Upload.where(uuid: new_uuids, upload_type: upload_type)
44
+ .update_all(parent_type: self.class.to_s, parent_id: self.id)
45
+ end
46
+
47
+ end
48
+
49
+ end
50
+
51
+ end
@@ -0,0 +1,37 @@
1
+ module S3Relay
2
+ class PrivateUrl < S3Relay::Base
3
+
4
+ attr_reader :expires, :path
5
+
6
+ def initialize(uuid, file, options={})
7
+ filename = Addressable::URI.escape(file).gsub("+", "%2B")
8
+ @path = [uuid, filename].join("/")
9
+ @expires = (options[:expires] || 10.minutes.from_now).to_i
10
+ end
11
+
12
+ def generate
13
+ "#{public_url}?#{params}"
14
+ end
15
+
16
+ def public_url
17
+ "#{endpoint}/#{path}"
18
+ end
19
+
20
+ private
21
+
22
+ def params
23
+ [
24
+ "AWSAccessKeyId=#{access_key_id}",
25
+ "Expires=#{expires}",
26
+ "Signature=#{signature}"
27
+ ].join("&")
28
+ end
29
+
30
+ def signature
31
+ string = "GET\n\n\n#{expires}\n/#{bucket}/#{path}"
32
+ hmac = OpenSSL::HMAC.digest(digest, secret_access_key, string)
33
+ CGI.escape(Base64.encode64(hmac).strip)
34
+ end
35
+
36
+ end
37
+ end
@@ -0,0 +1,4 @@
1
+ require "s3_relay/engine"
2
+
3
+ module S3Relay
4
+ end
@@ -0,0 +1,61 @@
1
+ module S3Relay
2
+ class UploadPresigner < S3Relay::Base
3
+
4
+ attr_reader :expires, :uuid
5
+
6
+ def initialize(options={})
7
+ @expires = (options[:expires] || 1.minute.from_now).utc.xmlschema
8
+ @uuid = SecureRandom.uuid
9
+ end
10
+
11
+ def form_data
12
+ fields.keys.inject({}) { |h,k| h[k.downcase.underscore] = fields[k]; h }
13
+ .merge(
14
+ "endpoint" => endpoint,
15
+ "policy" => encoded_policy,
16
+ "signature" => signature,
17
+ "uuid" => uuid
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ def fields
24
+ {
25
+ "AWSAccessKeyID" => access_key_id,
26
+ "x-amz-server-side-encryption" => "AES256",
27
+ "key" => "#{uuid}/${filename}",
28
+ "success_action_status" => "201",
29
+ "acl" => acl
30
+ }
31
+ end
32
+
33
+ def hmac
34
+ lambda { |data| OpenSSL::HMAC.digest(digest, secret_access_key, data) }
35
+ end
36
+
37
+ def policy_document
38
+ {
39
+ "expiration" => expires,
40
+ "conditions" => [
41
+ { "bucket" => bucket },
42
+ { "acl" => acl },
43
+ { "x-amz-server-side-encryption" => "AES256" },
44
+ { "success_action_status" => "201" },
45
+ ["starts-with", "$content-type", ""],
46
+ ["starts-with", "$content-disposition", ""],
47
+ ["starts-with", "$key", "#{uuid}/"]
48
+ ]
49
+ }
50
+ end
51
+
52
+ def encoded_policy
53
+ Base64.strict_encode64(policy_document.to_json)
54
+ end
55
+
56
+ def signature
57
+ Base64.strict_encode64(hmac[encoded_policy])
58
+ end
59
+
60
+ end
61
+ end
@@ -0,0 +1,3 @@
1
+ module S3Relay
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,144 @@
1
+ require "test_helper"
2
+
3
+ module S3Relay
4
+ describe UploadsController do
5
+ before do
6
+ S3Relay::Base.any_instance.stubs(:access_key_id)
7
+ .returns("access-key-id")
8
+ S3Relay::Base.any_instance.stubs(:secret_access_key)
9
+ .returns("secret-access-key")
10
+ S3Relay::Base.any_instance.stubs(:region)
11
+ .returns("region")
12
+ S3Relay::Base.any_instance.stubs(:bucket)
13
+ .returns("bucket")
14
+ S3Relay::Base.any_instance.stubs(:acl)
15
+ .returns("acl")
16
+ end
17
+
18
+ describe "GET new" do
19
+ it do
20
+ uuid = "123-456-789"
21
+ time = Time.parse("2014-12-01 12:00am")
22
+ S3Relay::UploadPresigner.any_instance.stubs(:uuid).returns(uuid)
23
+ S3Relay::UploadPresigner.any_instance.stubs(:expires).returns(time)
24
+
25
+ get new_s3_relay_upload_url
26
+ assert_response 200
27
+
28
+ data = JSON.parse(response.body)
29
+
30
+ data["awsaccesskeyid"].must_equal "access-key-id"
31
+ data["x_amz_server_side_encryption"].must_equal "AES256"
32
+ data["key"].must_equal "#{uuid}/${filename}"
33
+ data["success_action_status"].must_equal "201"
34
+ data["acl"].must_equal "acl"
35
+ data["endpoint"].must_equal "https://bucket.s3-region.amazonaws.com"
36
+ data["policy"].length.must_equal 380 # TODO: Improve this
37
+ data["signature"].length.must_equal 28 # TODO: Improve this
38
+ data["uuid"].must_equal uuid
39
+ end
40
+ end
41
+
42
+ describe "POST create" do
43
+
44
+ describe "success" do
45
+ it do
46
+ assert_difference "S3Relay::Upload.count", 1 do
47
+ post s3_relay_uploads_url, params: {
48
+ association: "photo_uploads",
49
+ uuid: SecureRandom.uuid,
50
+ filename: "cat.png",
51
+ content_type: "image/png"
52
+ }
53
+ end
54
+
55
+ assert_response 201
56
+ end
57
+
58
+ describe "with parent attributes" do
59
+ describe "matching an object" do
60
+ before { @product = FactoryGirl.create(:product) }
61
+
62
+ it do
63
+ assert_difference "@product.photo_uploads.count", 1 do
64
+ post s3_relay_uploads_url, params: {
65
+ association: "photo_uploads",
66
+ uuid: SecureRandom.uuid,
67
+ filename: "cat.png",
68
+ content_type: "image/png",
69
+ parent_type: @product.class.to_s,
70
+ parent_id: @product.id.to_s
71
+ }
72
+ end
73
+
74
+ assert_response 201
75
+ end
76
+ end
77
+
78
+ describe "not matching an object" do
79
+ it do
80
+ assert_difference "S3Relay::Upload.count" do
81
+ post s3_relay_uploads_url, params: {
82
+ association: "photo_uploads",
83
+ uuid: SecureRandom.uuid,
84
+ filename: "cat.png",
85
+ content_type: "image/png",
86
+ parent_type: "Product",
87
+ parent_id: "10000000"
88
+ }
89
+ end
90
+
91
+ assert_response 201
92
+ body = JSON.parse(response.body)
93
+
94
+ assert body["parent_type"] == nil
95
+ assert body["parent_id"] == nil
96
+ end
97
+ end
98
+
99
+ describe "with a current_user" do
100
+ before do
101
+ @user = OpenStruct.new(id: 123)
102
+ UploadsController.any_instance.stubs(:current_user).returns(@user)
103
+ end
104
+
105
+ it "associates the upload with the user" do
106
+ assert_difference "S3Relay::Upload.count", 1 do
107
+ post s3_relay_uploads_url, params: {
108
+ association: "photo_uploads",
109
+ uuid: SecureRandom.uuid,
110
+ filename: "cat.png",
111
+ content_type: "image/png"
112
+ }
113
+ end
114
+
115
+ assert_response 201
116
+ body = JSON.parse(response.body)
117
+ body["user_id"].must_equal @user.id
118
+ end
119
+ end
120
+
121
+ end
122
+ end
123
+
124
+ describe "error" do
125
+ it do
126
+ assert_no_difference "S3Relay::Upload.count" do
127
+ post s3_relay_uploads_url, params: {
128
+ uuid: SecureRandom.uuid,
129
+ filename: "cat.png",
130
+ content_type: "image/png"
131
+ }
132
+ end
133
+
134
+ assert_response 422
135
+
136
+ JSON.parse(response.body)["errors"]["upload_type"]
137
+ .must_include "can't be blank"
138
+ end
139
+ end
140
+
141
+ end
142
+
143
+ end
144
+ end