imagekitio-rails 1.0.0.beta.1

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,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_storage/service'
4
+ require 'tempfile'
5
+
6
+ module Imagekit
7
+ module Rails
8
+ module ActiveStorage
9
+ # Active Storage service for ImageKit
10
+ #
11
+ # Stores files in ImageKit and provides URL generation with transformations
12
+ #
13
+ # Configuration in config/storage.yml:
14
+ #
15
+ # imagekit:
16
+ # service: ImageKit
17
+ #
18
+ # Note: url_endpoint, public_key, and private_key are read from
19
+ # the global Imagekit::Rails.configuration (config/initializers/imagekit.rb)
20
+ #
21
+ # The Active Storage 'key' is used as the complete file path in ImageKit.
22
+ # Active Storage generates unique keys that include any necessary folder structure.
23
+ #
24
+ # All files are uploaded as public files in ImageKit.
25
+ class Service < ::ActiveStorage::Service
26
+ attr_reader :client, :url_endpoint, :public_key, :private_key
27
+
28
+ def initialize(url_endpoint: nil, public_key: nil, private_key: nil, **)
29
+ super()
30
+
31
+ # Use provided values or fall back to global configuration
32
+ config = Imagekit::Rails.configuration
33
+ @url_endpoint = url_endpoint || config.url_endpoint
34
+ @public_key = public_key || config.public_key
35
+ @private_key = private_key || config.private_key
36
+
37
+ @client = Imagekitio::Client.new(
38
+ private_key: @private_key
39
+ )
40
+ end
41
+
42
+ # Upload a file to ImageKit
43
+ #
44
+ # @param key [String] The complete path for the file in ImageKit (e.g., "uploads/abc123xyz")
45
+ # @param io [IO] The file content to upload
46
+ # @param checksum [String, nil] Optional MD5 checksum for integrity verification
47
+ # @param filename [String, nil] Optional filename to use
48
+ # @return [void]
49
+ # @raise [ActiveStorage::IntegrityError] If upload fails
50
+ # @note The ImageKit file_id is automatically stored in the blob's metadata after successful upload.
51
+ # This enables automatic deletion when the blob is purged.
52
+ def upload(key, io, checksum: nil, filename: nil, **)
53
+ instrument :upload, key: key, checksum: checksum do
54
+ # Read the file content
55
+ content = io.read
56
+ io.rewind if io.respond_to?(:rewind)
57
+
58
+ # Extract folder and filename from the key
59
+ # The key is the complete path: "folder/subfolder/filename"
60
+ folder_path = ::File.dirname(key)
61
+ file_name = filename || ::File.basename(key)
62
+
63
+ # Build upload parameters
64
+ upload_params = {
65
+ file: content,
66
+ file_name: file_name,
67
+ use_unique_file_name: false
68
+ }
69
+
70
+ # Only include folder if there is one (don't pass nil or '.')
71
+ upload_params[:folder] = folder_path unless folder_path == '.'
72
+
73
+ # Upload to ImageKit - the key determines the full path
74
+ response = @client.files.upload(**upload_params)
75
+
76
+ # Store the file_id in blob metadata for future deletion
77
+ store_file_id_in_blob_metadata(key, response.file_id) if response.respond_to?(:file_id) && response.file_id
78
+ end
79
+ rescue Imagekitio::Errors::Error => e
80
+ raise ::ActiveStorage::IntegrityError, "Upload failed: #{e.message}"
81
+ end
82
+
83
+ # Download file content from ImageKit
84
+ #
85
+ # @param key [String] The unique identifier for the file
86
+ # @yield [chunk] Streams file content in chunks if block given
87
+ # @yieldparam chunk [String] A chunk of the file content
88
+ # @return [String, void] The complete file content if no block given, void if block given
89
+ def download(key, &block)
90
+ if block_given?
91
+ instrument :streaming_download, key: key do
92
+ stream(key, &block)
93
+ end
94
+ else
95
+ instrument :download, key: key do
96
+ url = url_for_key(key)
97
+ response = Net::HTTP.get_response(URI(url))
98
+ response.body
99
+ end
100
+ end
101
+ end
102
+
103
+ # Override open to skip checksum verification for ImageKit
104
+ #
105
+ # ImageKit may serve optimized versions of files (format conversion, compression, etc.),
106
+ # which causes the checksum to differ from the originally uploaded file.
107
+ # This is safe because:
108
+ # 1. ImageKit is a trusted CDN service
109
+ # 2. Files are served over HTTPS
110
+ # 3. The optimization is intentional behavior
111
+ #
112
+ # Note: This method is called by ActiveStorage when processing variants or
113
+ # when blob.open is called (e.g., for image processing).
114
+ #
115
+ # @param key [String] The unique identifier for the file
116
+ # @param checksum [String] The expected checksum (ignored for ImageKit)
117
+ # @param name [String, Array] Basename for the temporary file (can be string or [basename, extension])
118
+ # @param tmpdir [String, nil] Directory for the temporary file
119
+ # @yield [tempfile] Provides access to the downloaded file
120
+ # @yieldparam tempfile [Tempfile] The temporary file containing downloaded content
121
+ # @return [void]
122
+ def open(key, checksum:, name: 'ActiveStorage-', tmpdir: nil, **)
123
+ instrument :open, key: key, checksum: checksum do
124
+ # Create a temporary file to download into
125
+ # ActiveStorage may pass name as a string or array [basename, extension]
126
+ tempfile = Tempfile.open(name, tmpdir || Dir.tmpdir, binmode: true)
127
+
128
+ begin
129
+ # Download the file without checksum verification
130
+ download(key) do |chunk|
131
+ tempfile.write(chunk)
132
+ end
133
+
134
+ tempfile.rewind
135
+
136
+ # Yield the tempfile to the caller
137
+ yield tempfile
138
+ ensure
139
+ # Always clean up the tempfile
140
+ tempfile.close!
141
+ end
142
+ end
143
+ end
144
+
145
+ # Download a byte range from the file
146
+ #
147
+ # @param key [String] The unique identifier for the file
148
+ # @param range [Range] The byte range to download
149
+ # @return [String] The requested chunk of file content
150
+ def download_chunk(key, range)
151
+ instrument :download_chunk, key: key, range: range do
152
+ url = url_for_key(key)
153
+ uri = URI(url)
154
+
155
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
156
+ request = Net::HTTP::Get.new(uri)
157
+ request['Range'] = "bytes=#{range.begin}-#{range.end}"
158
+ response = http.request(request)
159
+ response.body
160
+ end
161
+ end
162
+ end
163
+
164
+ # Delete a file from ImageKit
165
+ #
166
+ # @param key [String] The unique identifier for the file
167
+ # @return [void]
168
+ # @note This method is called by Active Storage after the blob record is already deleted
169
+ # from the database, making it impossible to retrieve the file_id from metadata.
170
+ # Actual deletion is handled automatically by a before_destroy callback on the blob,
171
+ # which runs before the blob is destroyed and has access to the file_id in metadata.
172
+ # This method is a no-op that exists only to satisfy the Active Storage service interface.
173
+ def delete(key)
174
+ # No-op: Deletion is handled by BlobDeletionCallback before the blob is destroyed
175
+ # This method is called after blob deletion, so we can't access metadata here
176
+ ::Rails.logger.debug { "ImageKit delete called for key: #{key} (handled by before_destroy callback)" } if defined?(::Rails)
177
+ end
178
+
179
+ # Delete multiple files from ImageKit by prefix
180
+ #
181
+ # @param prefix [String] The prefix path to delete (e.g., "uploads/2024/")
182
+ # @return [void]
183
+ # @note This method is rarely used by Active Storage. Individual file deletions
184
+ # are handled automatically by a before_destroy callback on each blob.
185
+ # This method is called after blobs are already deleted from the database,
186
+ # so it cannot access blob metadata. This is a no-op that exists only to satisfy
187
+ # the Active Storage service interface.
188
+ def delete_prefixed(prefix)
189
+ # No-op: Deletion is handled by BlobDeletionCallback on individual blobs
190
+ # This method is called after blobs are deleted, so we can't access metadata
191
+ return unless defined?(::Rails)
192
+
193
+ ::Rails.logger.debug do
194
+ "ImageKit delete_prefixed called for prefix: #{prefix} (handled by before_destroy callback on individual blobs)"
195
+ end
196
+ end
197
+
198
+ # Check if a file exists in ImageKit
199
+ #
200
+ # @param key [String] The unique identifier for the file
201
+ # @return [Boolean]
202
+ # @note Makes an API call to ImageKit to verify the file exists.
203
+ # Requires the imagekit_file_id to be stored in blob metadata.
204
+ def exist?(key)
205
+ instrument :exist, key: key do |payload|
206
+ blob = find_blob_by_key(key)
207
+
208
+ # If blob doesn't exist or has no file_id, file doesn't exist
209
+ if blob.nil? || !blob.metadata.key?('imagekit_file_id')
210
+ payload[:exist] = false
211
+ payload[:reason] = blob.nil? ? 'blob_not_found' : 'file_id_missing'
212
+ next false
213
+ end
214
+
215
+ file_id = blob.metadata['imagekit_file_id']
216
+
217
+ begin
218
+ # Try to get file details from ImageKit
219
+ @client.files.get(file_id)
220
+ payload[:exist] = true
221
+ true
222
+ rescue Imagekitio::Errors::Error => e
223
+ # File not found or other error
224
+ payload[:exist] = false
225
+ payload[:error] = e.message
226
+ false
227
+ end
228
+ end
229
+ end
230
+
231
+ # Generate a URL for the file
232
+ #
233
+ # @param key [String] The unique identifier for the file
234
+ # @param transformation [Array<Hash>, nil] ImageKit transformations
235
+ # @return [String] The generated URL for the file
236
+ # @see https://www.gemdocs.org/gems/imagekitio/4.0.0/Imagekitio/Models/Transformation.html Transformation options
237
+ def url(key, transformation: nil, **)
238
+ instrument :url, key: key do |payload|
239
+ generated_url = url_for_key(key, transformation: transformation)
240
+ payload[:url] = generated_url
241
+ generated_url
242
+ end
243
+ end
244
+
245
+ private
246
+
247
+ # Build a URL for a file key with optional transformations
248
+ #
249
+ # @param key [String] The file path in ImageKit
250
+ # @param transformation [Array<Hash>, nil] Optional transformations
251
+ # @return [String] The complete ImageKit URL
252
+ # @private
253
+ def url_for_key(key, transformation: nil)
254
+ # The key is the complete file path in ImageKit
255
+ src_options = Imagekitio::Models::SrcOptions.new(
256
+ src: key,
257
+ url_endpoint: @url_endpoint,
258
+ transformation: transformation || []
259
+ )
260
+
261
+ @client.helper.build_url(src_options)
262
+ end
263
+
264
+ # Stream file content in chunks
265
+ #
266
+ # @param key [String] The file path in ImageKit
267
+ # @yield [chunk] Yields each chunk of the file content
268
+ # @yieldparam chunk [String] A chunk of file content
269
+ # @return [void]
270
+ # @private
271
+ def stream(key, &block)
272
+ url = url_for_key(key)
273
+ uri = URI(url)
274
+
275
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
276
+ request = Net::HTTP::Get.new(uri)
277
+ http.request(request) do |response|
278
+ response.read_body(&block)
279
+ end
280
+ end
281
+ end
282
+
283
+ # Instrument an operation for Active Support notifications
284
+ #
285
+ # @param operation [Symbol] The operation name (e.g., :upload, :download)
286
+ # @param payload [Hash] Additional data to include in the notification
287
+ # @yield Block to execute with instrumentation
288
+ # @return [Object] The result of the yielded block
289
+ # @private
290
+ def instrument(operation, payload = {}, &)
291
+ ActiveSupport::Notifications.instrument(
292
+ "service_#{operation}.active_storage",
293
+ payload.merge(service: service_name),
294
+ &
295
+ )
296
+ end
297
+
298
+ # Return the service name for Active Storage instrumentation
299
+ #
300
+ # @return [Symbol] The service name (:imagekit)
301
+ # @private
302
+ def service_name
303
+ :imagekit
304
+ end
305
+
306
+ # Find a blob by its key
307
+ #
308
+ # @param key [String] The blob key
309
+ # @return [ActiveStorage::Blob, nil] The blob or nil if not found
310
+ # @private
311
+ def find_blob_by_key(key)
312
+ return nil unless defined?(::ActiveStorage::Blob)
313
+
314
+ ::ActiveStorage::Blob.find_by(key: key, service_name: service_name.to_s)
315
+ end
316
+
317
+ # Find all blobs with keys starting with the given prefix
318
+ #
319
+ # @param prefix [String] The key prefix
320
+ # @return [ActiveRecord::Relation<ActiveStorage::Blob>] The matching blobs
321
+ # @private
322
+ def find_blobs_by_prefix(prefix)
323
+ return [] unless defined?(::ActiveStorage::Blob)
324
+
325
+ ::ActiveStorage::Blob.where(service_name: service_name.to_s)
326
+ .where('key LIKE ?', "#{sanitize_sql_like(prefix)}%")
327
+ end
328
+
329
+ # Sanitize string for use in SQL LIKE pattern
330
+ #
331
+ # @param string [String] The string to sanitize
332
+ # @return [String] The sanitized string
333
+ # @private
334
+ def sanitize_sql_like(string)
335
+ string.gsub(/[\\_%]/) { |match| "\\#{match}" }
336
+ end
337
+
338
+ # Store the ImageKit file_id in the blob's metadata
339
+ #
340
+ # @param key [String] The blob key
341
+ # @param file_id [String] The ImageKit file_id
342
+ # @return [void]
343
+ # @private
344
+ def store_file_id_in_blob_metadata(key, file_id)
345
+ return unless defined?(::ActiveStorage::Blob)
346
+
347
+ # Find the blob by key - it should exist since upload is called after blob creation
348
+ blob = ::ActiveStorage::Blob.find_by(key: key, service_name: service_name.to_s)
349
+
350
+ # Update the metadata column to include the file_id
351
+ # Use update_column to skip callbacks and validations
352
+ blob&.update_column(:metadata, blob.metadata.merge('imagekit_file_id' => file_id))
353
+ rescue StandardError => e
354
+ # Log the error but don't fail the upload
355
+ ::Rails.logger.warn("Failed to store ImageKit file_id for key #{key}: #{e.message}") if defined?(::Rails)
356
+ end
357
+ end
358
+ end
359
+ end
360
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Imagekit
4
+ module Rails
5
+ # Active Storage integration for ImageKit
6
+ module ActiveStorage
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Imagekit
4
+ module Rails
5
+ # Configuration class for ImageKit Rails integration
6
+ #
7
+ # This class stores all the configuration settings needed for ImageKit to work.
8
+ # By default, it reads from environment variables, but can be customized via the configure block.
9
+ #
10
+ # @example Basic configuration
11
+ # Imagekit::Rails.configure do |config|
12
+ # config.private_key = 'your_private_key'
13
+ # config.public_key = 'your_public_key'
14
+ # config.url_endpoint = 'https://ik.imagekit.io/your_imagekit_id'
15
+ # end
16
+ #
17
+ # @example Using environment variables (default)
18
+ # # .env file
19
+ # IMAGEKIT_PRIVATE_KEY=private_xxx
20
+ # IMAGEKIT_PUBLIC_KEY=public_xxx
21
+ # IMAGEKIT_URL_ENDPOINT=https://ik.imagekit.io/your_imagekit_id
22
+ #
23
+ # @see https://github.com/imagekit-developer/imagekit-ruby ImageKit Ruby SDK
24
+ class Configuration
25
+ # @!attribute [rw] private_key
26
+ # @return [String, nil] ImageKit private key for authentication (default: ENV['IMAGEKIT_PRIVATE_KEY'])
27
+ # @!attribute [rw] public_key
28
+ # @return [String, nil] ImageKit public key for client-side operations (default: ENV['IMAGEKIT_PUBLIC_KEY'])
29
+ # @!attribute [rw] url_endpoint
30
+ # @return [String, nil] ImageKit URL endpoint (default: ENV['IMAGEKIT_URL_ENDPOINT'])
31
+ # @!attribute [rw] transformation_position
32
+ # @return [Symbol] Position of transformation params in URL (:query or :path, default: :query)
33
+ # @!attribute [rw] responsive
34
+ # @return [Boolean] Enable responsive image generation (default: true)
35
+ # @!attribute [rw] device_breakpoints
36
+ # @return [Array<Integer>] Breakpoints for device-based responsive images (default: [640, 750, 828, 1080, 1200, 1920, 2048, 3840])
37
+ # @!attribute [rw] image_breakpoints
38
+ # @return [Array<Integer>] Breakpoints for content-based responsive images (default: [16, 32, 48, 64, 96, 128, 256, 384])
39
+ attr_accessor :private_key, :public_key, :url_endpoint, :transformation_position,
40
+ :responsive, :device_breakpoints, :image_breakpoints
41
+
42
+ def initialize
43
+ @private_key = ENV['IMAGEKIT_PRIVATE_KEY']
44
+ @public_key = ENV['IMAGEKIT_PUBLIC_KEY']
45
+ @url_endpoint = ENV['IMAGEKIT_URL_ENDPOINT']
46
+ @transformation_position = :query
47
+ @responsive = true
48
+ @device_breakpoints = [640, 750, 828, 1080, 1200, 1920, 2048, 3840]
49
+ @image_breakpoints = [16, 32, 48, 64, 96, 128, 256, 384]
50
+ end
51
+
52
+ # Returns the ImageKit client instance
53
+ #
54
+ # The client is initialized lazily and cached for reuse.
55
+ # It uses the configured private_key.
56
+ #
57
+ # @return [Imagekitio::Client] The ImageKit SDK client
58
+ # @see https://www.gemdocs.org/gems/imagekitio/4.0.0/Imagekitio/Client.html ImageKit Client docs
59
+ def client
60
+ @client ||= Imagekitio::Client.new(
61
+ private_key: private_key
62
+ )
63
+ end
64
+ end
65
+
66
+ class << self
67
+ attr_writer :configuration
68
+
69
+ # Returns the current configuration instance
70
+ #
71
+ # @return [Configuration] The current configuration
72
+ def configuration
73
+ @configuration ||= Configuration.new
74
+ end
75
+
76
+ # Configure ImageKit Rails settings
77
+ #
78
+ # @example
79
+ # Imagekit::Rails.configure do |config|
80
+ # config.private_key = 'your_private_key'
81
+ # config.public_key = 'your_public_key'
82
+ # config.url_endpoint = 'https://ik.imagekit.io/your_imagekit_id'
83
+ # config.transformation_position = :path
84
+ # config.responsive = false
85
+ # end
86
+ #
87
+ # @yield [Configuration] The configuration instance to modify
88
+ # @return [void]
89
+ def configure
90
+ yield(configuration)
91
+ end
92
+
93
+ # Reset configuration to default values
94
+ #
95
+ # This creates a new Configuration instance with default values from environment variables.
96
+ #
97
+ # @return [Configuration] The new configuration instance
98
+ def reset_configuration!
99
+ @configuration = Configuration.new
100
+ end
101
+ end
102
+ end
103
+ end