file_hutch 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE.txt +21 -0
- data/README.md +288 -0
- data/app/assets/javascripts/file_hutch/direct_upload_controller.js +129 -0
- data/app/controllers/file_hutch/direct_uploads_controller.rb +60 -0
- data/config/routes.rb +7 -0
- data/exe/file_hutch +6 -0
- data/lib/file_hutch/cli.rb +232 -0
- data/lib/file_hutch/client.rb +255 -0
- data/lib/file_hutch/configuration.rb +33 -0
- data/lib/file_hutch/errors.rb +96 -0
- data/lib/file_hutch/file.rb +57 -0
- data/lib/file_hutch/project.rb +49 -0
- data/lib/file_hutch/rails/attachable.rb +184 -0
- data/lib/file_hutch/rails/engine.rb +28 -0
- data/lib/file_hutch/rails.rb +5 -0
- data/lib/file_hutch/resource.rb +44 -0
- data/lib/file_hutch/upload.rb +28 -0
- data/lib/file_hutch/version.rb +5 -0
- data/lib/file_hutch/webhook.rb +52 -0
- data/lib/file_hutch.rb +63 -0
- data/lib/generators/file_hutch/attachment/attachment_generator.rb +30 -0
- data/lib/generators/file_hutch/attachment/templates/migration.rb.tt +6 -0
- data/lib/generators/file_hutch/install/install_generator.rb +34 -0
- data/lib/generators/file_hutch/install/templates/initializer.rb +18 -0
- metadata +73 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FileHutch
|
|
4
|
+
class Project < Resource
|
|
5
|
+
attribute :name, :team_id, :storage_ready
|
|
6
|
+
time_attribute :created_at
|
|
7
|
+
|
|
8
|
+
def storage_ready? = storage_ready == true
|
|
9
|
+
def storage_connection = self["active_storage_connection"]
|
|
10
|
+
def storage_mode = storage_connection && storage_connection["mode"]
|
|
11
|
+
def storage_provider = storage_connection && storage_connection["provider"]
|
|
12
|
+
|
|
13
|
+
def upload_policies
|
|
14
|
+
(self["upload_policies"] || []).map { |p| UploadPolicy.new(p, client: client) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def upload_policy(name) = upload_policies.find { |p| p.name == name.to_s || p.id == name.to_s }
|
|
18
|
+
|
|
19
|
+
def transforms
|
|
20
|
+
(self["transforms"] || []).map { |t| Transform.new(t, client: client) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def transform(name) = transforms.find { |t| t.name == name.to_s || t.id == name.to_s }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A named image size defined in the project. Applications reference the name;
|
|
27
|
+
# nothing here is provider-specific.
|
|
28
|
+
class Transform < Resource
|
|
29
|
+
attribute :name, :width, :height, :fit, :quality, :format
|
|
30
|
+
time_attribute :created_at
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class UploadPolicy < Resource
|
|
34
|
+
attribute :name, :allowed_content_types, :maximum_size, :visibility
|
|
35
|
+
time_attribute :created_at
|
|
36
|
+
|
|
37
|
+
def public? = visibility == "public"
|
|
38
|
+
def private? = visibility == "private"
|
|
39
|
+
def allowed_content_types = self["allowed_content_types"] || []
|
|
40
|
+
|
|
41
|
+
def allows_content_type?(content_type)
|
|
42
|
+
return true if allowed_content_types.empty?
|
|
43
|
+
ct = content_type.to_s.downcase
|
|
44
|
+
allowed_content_types.any? { |p| p == ct || (p.end_with?("/*") && ct.start_with?(p.delete_suffix("*"))) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def allows_byte_size?(size) = size.to_i.positive? && size.to_i <= maximum_size.to_i
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module FileHutch
|
|
6
|
+
# `has_file_hutch_file` for Active Record. The model stores one string column,
|
|
7
|
+
# `<name>_file_id`, holding an FileHutch file id. Nothing about storage leaks in.
|
|
8
|
+
#
|
|
9
|
+
# class User < ApplicationRecord
|
|
10
|
+
# has_file_hutch_file :avatar, policy: "avatars"
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# user.avatar = params[:avatar] # uploaded IO: uploaded to storage on save
|
|
14
|
+
# user.avatar = "file_…" # id from a browser direct upload: verified on save
|
|
15
|
+
# user.avatar # => FileHutch::File or nil
|
|
16
|
+
# user.avatar_url # public URL (public policies)
|
|
17
|
+
# user.avatar_signed_url(expires_in: 600) # works for private files
|
|
18
|
+
# user.avatar_transform_url("thumb") # a named transform from the dashboard
|
|
19
|
+
# user.purge_avatar # deletes remotely, clears the column
|
|
20
|
+
#
|
|
21
|
+
# Options: column: (default "<name>_file_id"), dependent: :delete (default; delete the file when the
|
|
22
|
+
# record is destroyed or the file is replaced) or false, verify: true (default; a raw id assigned
|
|
23
|
+
# from a form is fetched on validation and must be a ready file uploaded under this policy).
|
|
24
|
+
module Attachable
|
|
25
|
+
extend ActiveSupport::Concern
|
|
26
|
+
|
|
27
|
+
class_methods do
|
|
28
|
+
def has_file_hutch_file(name, policy:, column: "#{name}_file_id", dependent: :delete, verify: true)
|
|
29
|
+
include Attachable unless include?(Attachable)
|
|
30
|
+
name = name.to_sym
|
|
31
|
+
column = column.to_s
|
|
32
|
+
file_hutch_files[name] = { policy: policy.to_s, column: column, dependent: dependent, verify: verify }
|
|
33
|
+
|
|
34
|
+
validate { file_hutch_validate(name) }
|
|
35
|
+
before_save { file_hutch_upload_staged(name) }
|
|
36
|
+
after_save { file_hutch_delete_replaced(name) }
|
|
37
|
+
after_destroy { file_hutch_delete_on_destroy(name) } if dependent == :delete
|
|
38
|
+
|
|
39
|
+
define_method(name) { file_hutch_file(name) }
|
|
40
|
+
define_method(:"#{name}=") { |value| file_hutch_assign(name, value) }
|
|
41
|
+
define_method(:"#{name}?") { self[column].present? }
|
|
42
|
+
define_method(:"#{name}_url") { file_hutch_file(name)&.url }
|
|
43
|
+
define_method(:"#{name}_signed_url") { |expires_in: nil, disposition: nil| file_hutch_file(name)&.signed_url(expires_in: expires_in, disposition: disposition) }
|
|
44
|
+
define_method(:"#{name}_transform_url") { |transform, expires_in: nil| file_hutch_file(name)&.transform_url(transform, expires_in: expires_in) }
|
|
45
|
+
define_method(:"purge_#{name}") { file_hutch_purge(name) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def file_hutch_files
|
|
49
|
+
@file_hutch_files ||= superclass.respond_to?(:file_hutch_files) ? superclass.file_hutch_files.dup : {}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reload(*)
|
|
54
|
+
@file_hutch_cache = nil
|
|
55
|
+
super
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def file_hutch_option(name, key) = self.class.file_hutch_files.fetch(name).fetch(key)
|
|
61
|
+
def file_hutch_cache = @file_hutch_cache ||= {}
|
|
62
|
+
def file_hutch_staged = @file_hutch_staged ||= {}
|
|
63
|
+
def file_hutch_replaced = @file_hutch_replaced ||= {}
|
|
64
|
+
|
|
65
|
+
def file_hutch_file(name)
|
|
66
|
+
id = self[file_hutch_option(name, :column)]
|
|
67
|
+
return nil if id.blank?
|
|
68
|
+
cached = file_hutch_cache[name]
|
|
69
|
+
return cached if cached && cached.id == id
|
|
70
|
+
file_hutch_cache[name] = FileHutch.client.file(id)
|
|
71
|
+
rescue FileHutch::NotFoundError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def file_hutch_assign(name, value)
|
|
76
|
+
column = file_hutch_option(name, :column)
|
|
77
|
+
previous = self[column]
|
|
78
|
+
file_hutch_staged.delete(name)
|
|
79
|
+
file_hutch_cache.delete(name)
|
|
80
|
+
|
|
81
|
+
case value
|
|
82
|
+
when nil, ""
|
|
83
|
+
self[column] = nil
|
|
84
|
+
when FileHutch::File
|
|
85
|
+
file_hutch_cache[name] = value
|
|
86
|
+
self[column] = value.id
|
|
87
|
+
when String
|
|
88
|
+
raise ArgumentError, "#{value.inspect} is not an FileHutch file id" unless FileHutch::File.id?(value)
|
|
89
|
+
self[column] = value
|
|
90
|
+
else
|
|
91
|
+
raise ArgumentError, "cannot attach #{value.class} to #{name}" unless value.respond_to?(:read) || value.respond_to?(:tempfile) || value.is_a?(Pathname)
|
|
92
|
+
file_hutch_staged[name] = value
|
|
93
|
+
self[column] = nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
file_hutch_remember_replaced(name, previous) if previous.present? && previous != self[column]
|
|
97
|
+
value
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def file_hutch_remember_replaced(name, previous_id)
|
|
101
|
+
file_hutch_replaced[name] = previous_id if file_hutch_option(name, :dependent) == :delete
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def file_hutch_validate(name)
|
|
105
|
+
column = file_hutch_option(name, :column)
|
|
106
|
+
staged = file_hutch_staged[name]
|
|
107
|
+
if staged
|
|
108
|
+
policy = file_hutch_policy(name)
|
|
109
|
+
if policy
|
|
110
|
+
type = staged.respond_to?(:content_type) ? staged.content_type : nil
|
|
111
|
+
size = staged.respond_to?(:size) ? staged.size : nil
|
|
112
|
+
errors.add(name, :file_hutch_content_type, message: "type #{type} is not allowed") if type && !policy.allows_content_type?(type)
|
|
113
|
+
errors.add(name, :file_hutch_too_large, message: "is larger than #{policy.maximum_size} bytes") if size && !policy.allows_byte_size?(size)
|
|
114
|
+
end
|
|
115
|
+
return
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
id = self[column]
|
|
119
|
+
return unless file_hutch_option(name, :verify) && id.present? && attribute_changed?(column) && file_hutch_cache[name].nil?
|
|
120
|
+
|
|
121
|
+
file = FileHutch.client.file(id)
|
|
122
|
+
if !file.ready?
|
|
123
|
+
errors.add(name, :file_hutch_not_ready, message: "upload is #{file.status}")
|
|
124
|
+
elsif file.policy != file_hutch_option(name, :policy)
|
|
125
|
+
errors.add(name, :file_hutch_wrong_policy, message: "was uploaded under the #{file.policy} policy")
|
|
126
|
+
else
|
|
127
|
+
file_hutch_cache[name] = file
|
|
128
|
+
end
|
|
129
|
+
rescue FileHutch::NotFoundError
|
|
130
|
+
errors.add(name, :file_hutch_not_found, message: "does not exist")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Policies are fetched once per process; a miss (or an outage) skips local checks. The server still enforces them.
|
|
134
|
+
def file_hutch_policy(name)
|
|
135
|
+
Attachable.policy(file_hutch_option(name, :policy))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def file_hutch_upload_staged(name)
|
|
139
|
+
source = file_hutch_staged.delete(name) or return
|
|
140
|
+
file = FileHutch.client.upload(source, policy: file_hutch_option(name, :policy))
|
|
141
|
+
file_hutch_cache[name] = file
|
|
142
|
+
self[file_hutch_option(name, :column)] = file.id
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def file_hutch_delete_replaced(name)
|
|
146
|
+
id = file_hutch_replaced.delete(name) or return
|
|
147
|
+
return if id == self[file_hutch_option(name, :column)]
|
|
148
|
+
Attachable.delete_quietly(id)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def file_hutch_delete_on_destroy(name)
|
|
152
|
+
id = self[file_hutch_option(name, :column)]
|
|
153
|
+
Attachable.delete_quietly(id) if id.present?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def file_hutch_purge(name)
|
|
157
|
+
column = file_hutch_option(name, :column)
|
|
158
|
+
id = self[column]
|
|
159
|
+
Attachable.delete_quietly(id) if id.present?
|
|
160
|
+
file_hutch_cache.delete(name)
|
|
161
|
+
update_column(column, nil) if persisted?
|
|
162
|
+
self[column] = nil
|
|
163
|
+
true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
class << self
|
|
167
|
+
def delete_quietly(id)
|
|
168
|
+
FileHutch.client.delete_file(id)
|
|
169
|
+
rescue FileHutch::NotFoundError, FileHutch::InvalidStateError
|
|
170
|
+
true
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def policy(name)
|
|
174
|
+
policies[name] ||= FileHutch.client.project.upload_policy(name)
|
|
175
|
+
rescue FileHutch::Error => e
|
|
176
|
+
FileHutch.config.logger&.warn { "[file_hutch] could not load upload policies: #{e.message}" }
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def policies = @policies ||= {}
|
|
181
|
+
def reset_policies! = @policies = {}
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FileHutch
|
|
4
|
+
# Mount at /file_hutch to give browsers a direct-upload endpoint that never
|
|
5
|
+
# exposes the API key:
|
|
6
|
+
#
|
|
7
|
+
# mount FileHutch::Engine => "/file_hutch"
|
|
8
|
+
#
|
|
9
|
+
# POST /file_hutch/uploads {policy, filename, content_type, byte_size}
|
|
10
|
+
# POST /file_hutch/uploads/:id/complete
|
|
11
|
+
#
|
|
12
|
+
# Both require FileHutch.config.authorize_direct_upload to return true.
|
|
13
|
+
class Engine < ::Rails::Engine
|
|
14
|
+
isolate_namespace FileHutch
|
|
15
|
+
|
|
16
|
+
initializer "file_hutch.active_record" do
|
|
17
|
+
ActiveSupport.on_load(:active_record) { extend FileHutch::Attachable::ClassMethods }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
initializer "file_hutch.assets" do |app|
|
|
21
|
+
app.config.assets.precompile += %w[file_hutch/direct_upload_controller.js] if app.config.respond_to?(:assets) && app.config.assets.respond_to?(:precompile)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
initializer "file_hutch.logger" do
|
|
25
|
+
FileHutch.config.logger ||= ::Rails.logger
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FileHutch
|
|
4
|
+
# Thin, immutable wrapper over an API JSON object.
|
|
5
|
+
class Resource
|
|
6
|
+
attr_reader :attributes, :client
|
|
7
|
+
|
|
8
|
+
def initialize(attributes, client: nil)
|
|
9
|
+
@attributes = (attributes || {}).transform_keys(&:to_s).freeze
|
|
10
|
+
@client = client
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def [](key) = attributes[key.to_s]
|
|
14
|
+
def id = self["id"]
|
|
15
|
+
def to_h = attributes.dup
|
|
16
|
+
def to_param = id
|
|
17
|
+
def to_s = id.to_s
|
|
18
|
+
def as_json(*) = to_h
|
|
19
|
+
def to_json(*args) = to_h.to_json(*args)
|
|
20
|
+
def ==(other) = other.class == self.class && other.attributes == attributes
|
|
21
|
+
alias eql? ==
|
|
22
|
+
def hash = [ self.class, attributes ].hash
|
|
23
|
+
def inspect = "#<#{self.class.name} #{attributes.map { |k, v| "#{k}=#{v.inspect}" }.join(' ')}>"
|
|
24
|
+
|
|
25
|
+
def self.attribute(*names)
|
|
26
|
+
names.each { |name| define_method(name) { self[name] } }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.time_attribute(*names)
|
|
30
|
+
names.each do |name|
|
|
31
|
+
define_method(name) do
|
|
32
|
+
value = self[name]
|
|
33
|
+
value && Time.iso8601(value)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def client!
|
|
41
|
+
client || FileHutch.client
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FileHutch
|
|
4
|
+
# Direct-upload instructions from POST /api/v1/uploads plus the pending file.
|
|
5
|
+
class Upload < Resource
|
|
6
|
+
attribute :file_id, :method, :url, :headers
|
|
7
|
+
time_attribute :expires_at
|
|
8
|
+
|
|
9
|
+
def initialize(attributes, file: nil, client: nil)
|
|
10
|
+
super(attributes, client: client)
|
|
11
|
+
@file = file
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_reader :file
|
|
15
|
+
|
|
16
|
+
def headers = self["headers"] || {}
|
|
17
|
+
def expired? = expires_at && expires_at <= Time.now
|
|
18
|
+
|
|
19
|
+
# PUTs the bytes straight to storage. `source` is an IO or a String of bytes.
|
|
20
|
+
def put(source)
|
|
21
|
+
client!.put_to_storage(self, source)
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Tells FileHutch the bytes are in place; returns the ready file.
|
|
26
|
+
def complete = client!.complete_upload(id)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FileHutch
|
|
4
|
+
# Verifies the signature FileHutch puts on every webhook delivery:
|
|
5
|
+
#
|
|
6
|
+
# FileHutch-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>
|
|
7
|
+
#
|
|
8
|
+
# event = FileHutch::Webhook.construct_event(request.raw_post, request.headers["FileHutch-Signature"], secret)
|
|
9
|
+
# case event["type"]
|
|
10
|
+
# when "file.created" then Document.find_by(file_hutch_file_id: event["data"]["file"]["id"])&.ready!
|
|
11
|
+
# end
|
|
12
|
+
module Webhook
|
|
13
|
+
HEADER = "FileHutch-Signature"
|
|
14
|
+
DEFAULT_TOLERANCE = 300
|
|
15
|
+
|
|
16
|
+
# Returns the parsed event. Raises SignatureVerificationError if the body was
|
|
17
|
+
# not signed with `secret` in the last `tolerance` seconds.
|
|
18
|
+
def self.construct_event(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE, now: Time.now)
|
|
19
|
+
verify!(payload, signature_header, secret, tolerance: tolerance, now: now)
|
|
20
|
+
JSON.parse(payload)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.verify!(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE, now: Time.now)
|
|
24
|
+
raise SignatureVerificationError, "webhook secret is missing" if secret.to_s.empty?
|
|
25
|
+
|
|
26
|
+
parts = signature_header.to_s.split(",").filter_map do |part|
|
|
27
|
+
key, value = part.split("=", 2)
|
|
28
|
+
[ key, value ] if value
|
|
29
|
+
end.to_h
|
|
30
|
+
timestamp = parts["t"].to_i
|
|
31
|
+
given = parts["v1"].to_s
|
|
32
|
+
raise SignatureVerificationError, "missing or malformed #{HEADER} header" if timestamp.zero? || given.empty?
|
|
33
|
+
raise SignatureVerificationError, "signature timestamp is outside the #{tolerance}s tolerance" if (now.to_i - timestamp).abs > tolerance
|
|
34
|
+
|
|
35
|
+
expected = compute_signature(timestamp, payload, secret)
|
|
36
|
+
raise SignatureVerificationError, "signature does not match" unless secure_compare(expected, given)
|
|
37
|
+
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.compute_signature(timestamp, payload, secret)
|
|
42
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp.to_i}.#{payload}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.secure_compare(a, b)
|
|
46
|
+
return false unless a.bytesize == b.bytesize
|
|
47
|
+
|
|
48
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
49
|
+
end
|
|
50
|
+
private_class_method :secure_compare
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/file_hutch.rb
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "digest"
|
|
7
|
+
require "net/http"
|
|
8
|
+
require "uri"
|
|
9
|
+
require "time"
|
|
10
|
+
|
|
11
|
+
require_relative "file_hutch/version"
|
|
12
|
+
require_relative "file_hutch/errors"
|
|
13
|
+
require_relative "file_hutch/configuration"
|
|
14
|
+
require_relative "file_hutch/resource"
|
|
15
|
+
require_relative "file_hutch/file"
|
|
16
|
+
require_relative "file_hutch/upload"
|
|
17
|
+
require_relative "file_hutch/project"
|
|
18
|
+
require_relative "file_hutch/client"
|
|
19
|
+
require_relative "file_hutch/webhook"
|
|
20
|
+
require_relative "file_hutch/cli"
|
|
21
|
+
|
|
22
|
+
# FileHutch: file infrastructure for apps that aren't Netflix.
|
|
23
|
+
#
|
|
24
|
+
# FileHutch.configure { |c| c.api_key = ENV["FILE_HUTCH_API_KEY"] }
|
|
25
|
+
# file = FileHutch.upload("report.pdf", policy: "documents") # => FileHutch::File (ready)
|
|
26
|
+
# file.signed_url(expires_in: 3600)
|
|
27
|
+
# FileHutch::File.find(file.id).delete
|
|
28
|
+
#
|
|
29
|
+
# Your application persists `file.id` ("file_…") and nothing else about storage.
|
|
30
|
+
module FileHutch
|
|
31
|
+
class << self
|
|
32
|
+
def configuration
|
|
33
|
+
@configuration ||= Configuration.new
|
|
34
|
+
end
|
|
35
|
+
alias config configuration
|
|
36
|
+
|
|
37
|
+
def configure
|
|
38
|
+
yield configuration
|
|
39
|
+
reset_client!
|
|
40
|
+
configuration
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The shared client built from FileHutch.configuration.
|
|
44
|
+
def client
|
|
45
|
+
@client ||= Client.new(configuration)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def client=(client)
|
|
49
|
+
@client = client
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def reset_client!
|
|
53
|
+
@client = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Convenience delegators for one-project apps.
|
|
57
|
+
def upload(source, **options) = client.upload(source, **options)
|
|
58
|
+
def file(id) = client.file(id)
|
|
59
|
+
def project = client.project
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
require_relative "file_hutch/rails" if defined?(::Rails::Railtie)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module FileHutch
|
|
7
|
+
module Generators
|
|
8
|
+
# bin/rails generate file_hutch:attachment User avatar
|
|
9
|
+
# Adds users.avatar_file_id (string). Pair with `has_file_hutch_file :avatar, policy: "…"`.
|
|
10
|
+
class AttachmentGenerator < ::Rails::Generators::NamedBase
|
|
11
|
+
include ::ActiveRecord::Generators::Migration
|
|
12
|
+
|
|
13
|
+
source_root ::File.expand_path("templates", __dir__)
|
|
14
|
+
argument :attachment, type: :string, banner: "attachment_name"
|
|
15
|
+
|
|
16
|
+
def create_migration_file
|
|
17
|
+
migration_template "migration.rb.tt", "db/migrate/add_#{column_name}_to_#{table_name}.rb"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def show_macro
|
|
21
|
+
say %(Add to #{class_name}: has_file_hutch_file :#{attachment}, policy: "#{attachment.pluralize}"), :green
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def column_name = "#{attachment.underscore}_file_id"
|
|
27
|
+
def migration_class_name = "Add#{column_name.camelize}To#{table_name.camelize}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
# Holds an FileHutch file id ("file_…"). Never a URL, never a storage key.
|
|
4
|
+
add_column :<%= table_name %>, :<%= column_name %>, :string
|
|
5
|
+
end
|
|
6
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module FileHutch
|
|
6
|
+
module Generators
|
|
7
|
+
# bin/rails generate file_hutch:install
|
|
8
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
9
|
+
source_root ::File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
def create_initializer
|
|
12
|
+
template "initializer.rb", "config/initializers/file_hutch.rb"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def mount_engine
|
|
16
|
+
route 'mount FileHutch::Engine => "/file_hutch"'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def pin_javascript
|
|
20
|
+
return unless ::File.exist?(::File.join(destination_root, "config/importmap.rb"))
|
|
21
|
+
append_to_file "config/importmap.rb", %(pin "file_hutch/direct_upload_controller", to: "file_hutch/direct_upload_controller.js"\n)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def show_next_steps
|
|
25
|
+
say ""
|
|
26
|
+
say "FileHutch installed.", :green
|
|
27
|
+
say " 1. Set FILE_HUTCH_API_KEY (Dashboard → API keys) and, if not production, FILE_HUTCH_URL."
|
|
28
|
+
say " 2. Add a column and macro: bin/rails g file_hutch:attachment User avatar then has_file_hutch_file :avatar, policy: \"avatars\""
|
|
29
|
+
say " 3. For browser uploads, set FileHutch.config.authorize_direct_upload in the initializer and register the"
|
|
30
|
+
say " Stimulus controller: application.register(\"filehutch-direct-upload\", DirectUploadController)"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
FileHutch.configure do |config|
|
|
4
|
+
# Project-scoped API key from the FileHutch dashboard. Keep it out of the repo.
|
|
5
|
+
config.api_key = ENV["FILE_HUTCH_API_KEY"]
|
|
6
|
+
|
|
7
|
+
# Leave the default for FileHutch cloud; point at your own instance otherwise.
|
|
8
|
+
config.url = ENV.fetch("FILE_HUTCH_URL", FileHutch::Configuration::DEFAULT_URL)
|
|
9
|
+
|
|
10
|
+
# Browser-direct uploads (POST /file_hutch/uploads) are off until you decide who may
|
|
11
|
+
# upload, and against which policies. `controller` is the request's controller.
|
|
12
|
+
# config.authorize_direct_upload = ->(controller, policy) do
|
|
13
|
+
# controller.current_user.present? && %w[avatars documents].include?(policy)
|
|
14
|
+
# end
|
|
15
|
+
|
|
16
|
+
# The direct-upload controller inherits from this so your auth helpers are available.
|
|
17
|
+
# config.direct_upload_parent_controller = "ApplicationController"
|
|
18
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: file_hutch
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Andy Leverenz
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Talk to the FileHutch file control plane from Ruby. Server-side and browser-direct
|
|
13
|
+
uploads, signed URLs, and a has_file_hutch_file macro for Active Record that stores
|
|
14
|
+
only opaque file ids.
|
|
15
|
+
email:
|
|
16
|
+
- andy@justalever.com
|
|
17
|
+
executables:
|
|
18
|
+
- file_hutch
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- CHANGELOG.md
|
|
23
|
+
- LICENSE.txt
|
|
24
|
+
- README.md
|
|
25
|
+
- app/assets/javascripts/file_hutch/direct_upload_controller.js
|
|
26
|
+
- app/controllers/file_hutch/direct_uploads_controller.rb
|
|
27
|
+
- config/routes.rb
|
|
28
|
+
- exe/file_hutch
|
|
29
|
+
- lib/file_hutch.rb
|
|
30
|
+
- lib/file_hutch/cli.rb
|
|
31
|
+
- lib/file_hutch/client.rb
|
|
32
|
+
- lib/file_hutch/configuration.rb
|
|
33
|
+
- lib/file_hutch/errors.rb
|
|
34
|
+
- lib/file_hutch/file.rb
|
|
35
|
+
- lib/file_hutch/project.rb
|
|
36
|
+
- lib/file_hutch/rails.rb
|
|
37
|
+
- lib/file_hutch/rails/attachable.rb
|
|
38
|
+
- lib/file_hutch/rails/engine.rb
|
|
39
|
+
- lib/file_hutch/resource.rb
|
|
40
|
+
- lib/file_hutch/upload.rb
|
|
41
|
+
- lib/file_hutch/version.rb
|
|
42
|
+
- lib/file_hutch/webhook.rb
|
|
43
|
+
- lib/generators/file_hutch/attachment/attachment_generator.rb
|
|
44
|
+
- lib/generators/file_hutch/attachment/templates/migration.rb.tt
|
|
45
|
+
- lib/generators/file_hutch/install/install_generator.rb
|
|
46
|
+
- lib/generators/file_hutch/install/templates/initializer.rb
|
|
47
|
+
homepage: https://github.com/filehutch/filehutch-ruby
|
|
48
|
+
licenses:
|
|
49
|
+
- MIT
|
|
50
|
+
metadata:
|
|
51
|
+
homepage_uri: https://github.com/filehutch/filehutch-ruby
|
|
52
|
+
source_code_uri: https://github.com/filehutch/filehutch-ruby
|
|
53
|
+
changelog_uri: https://github.com/filehutch/filehutch-ruby/blob/main/CHANGELOG.md
|
|
54
|
+
rubygems_mfa_required: 'true'
|
|
55
|
+
rdoc_options: []
|
|
56
|
+
require_paths:
|
|
57
|
+
- lib
|
|
58
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '3.1'
|
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
requirements: []
|
|
69
|
+
rubygems_version: 3.6.9
|
|
70
|
+
specification_version: 4
|
|
71
|
+
summary: 'Ruby and Rails client for FileHutch: uploads, private files, and delivery
|
|
72
|
+
without file plumbing.'
|
|
73
|
+
test_files: []
|