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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require_relative "rails/attachable"
5
+ require_relative "rails/engine"
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FileHutch
4
+ VERSION = "0.1.0"
5
+ 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: []