mongoid-direct-s3-upload 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +250 -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