m365_active_storage 1.0.0 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 248f6e95435a9737f6aa06db40f6d758a59a4d6355ad4399a12d5dbc0d24efde
4
- data.tar.gz: c93ea5f7c16c419e2cfadbc9ce6a679b1a0abb533bb9863b9a75f8b7aed7d17d
3
+ metadata.gz: 1d083b38ef3eacb7fa4d749c7c8a1de3c11fd3e8e86350d11c9533f9f295034e
4
+ data.tar.gz: b8db90e6bd4d2f16c5664ae495cdf4efed60af27fe42bb6823dc4d68bd729e73
5
5
  SHA512:
6
- metadata.gz: 91d7ddd980e2959d4c834847affd9b515f1544c307fa00d562bf6c15b3754fa3c1d657ed648c03eb285eb7cd3a98daf75dc23c5c888bc88d1914fc19cd249528
7
- data.tar.gz: d8cedd464a2fb9946f778b79b3113e1176aa4d444d56b271bfda1378a386a3765c21850279325a78b3af1a3c5547b5f85a3fe42857652e9b002e3d814cace891
6
+ metadata.gz: d307d02eb5b143fd26c31cb9711d71e4d8f4668589c830334bc90158615b41138b151f1c125ce2ee8b0c5f05198c24befdcb0478cb176fd74f2935ae407cc010
7
+ data.tar.gz: 771e399e44037c9a5637d82b16f9cb0ba3d23d42ea699dfcfc70deef533e9873aeec3f3a2cf64df9727917e9164b99615e0da99c8ca8840548f0b49645484055
data/README.md CHANGED
@@ -27,6 +27,7 @@ sharepoint:
27
27
  oauth_secret:
28
28
  sharepoint_site_id:
29
29
  sharepoint_drive_id:
30
+ storage_key:
30
31
  ```
31
32
  #### -- or --
32
33
 
@@ -40,8 +41,42 @@ OAUTH_APP_ID=
40
41
  OAUTH_SECRET=
41
42
  SHAREPOINT_SITE_ID=
42
43
  SHAREPOINT_DRIVE_ID=
44
+ STORAGE_KEY=
43
45
  ```
44
46
 
47
+ ### Set storage key
48
+ The storage key set when the file is stored using the blob key or the filename.
49
+ ```ruby
50
+ storage_key: key | filename
51
+ ```
52
+ #### Using the key store:
53
+ Stores the files with the blob key in the sharepoint drive, with no extension. If
54
+ a file is uploaded more than once will be stored each time.
55
+
56
+ #### Using the filename store:
57
+ Stores the files using the filename instead of the key. In this case
58
+ a if a file is uploaded twice only the last will remain in the sharepoint drive.
59
+
60
+ With the filename storage, it's possible to manage folders to store the documents, allowing having
61
+ files with the same name into different folders.
62
+
63
+ In your app the path of the file can be set on the document attach:
64
+ ```ruby
65
+ model.attachment.attach(
66
+ io: file,
67
+ filename: file.original_filename,
68
+ metadata: { "sharepoint_folder" => "documents/#{Date.today.year}/#{Date.today.strftime('%m')}" }
69
+ )
70
+ ```
71
+ This will create or use the nested folder structure, in this example:
72
+ ```shell
73
+ documents/
74
+ 2026/
75
+ 03/
76
+ ```
77
+
78
+
79
+
45
80
  ### Set active storage to sharepoint service
46
81
  In the app config/environments/`<environment>`.rb
47
82
 
data/config/storage.yml CHANGED
@@ -7,4 +7,5 @@ sharepoint:
7
7
  app_id: <%= Rails.application.credentials.dig(:sharepoint, :oauth_app_id) || ENV["OAUTH_APP_ID"] %>
8
8
  secret: <%= Rails.application.credentials.dig(:sharepoint, :oauth_secret) || ENV["OAUTH_SECRET"] %>
9
9
  site_id: <%= Rails.application.credentials.dig(:sharepoint, :sharepoint_site_id) || ENV["SHAREPOINT_SITE_ID"] %>
10
- drive_id: <%= Rails.application.credentials.dig(:sharepoint, :sharepoint_drive_id) || ENV["SHAREPOINT_DRIVE_ID"] %>
10
+ drive_id: <%= Rails.application.credentials.dig(:sharepoint, :sharepoint_drive_id) || ENV["SHAREPOINT_DRIVE_ID"] %>
11
+ storage_key: <%= Rails.application.credentials.dig(:sharepoint, :sharepoint_storage_key) || ENV["SHAREPOINT_STORAGE_KEY"] %>
@@ -70,7 +70,7 @@ module M365ActiveStorage
70
70
  class Configuration
71
71
  attr_reader :ms_graph_url, :ms_graph_version, :ms_graph_endpoint,
72
72
  :auth_host, :tenant_id,
73
- :app_id, :secret, :site_id, :drive_id
73
+ :app_id, :secret, :site_id, :drive_id, :storage_key
74
74
 
75
75
  # Initialize Configuration with the provided parameters
76
76
  #
@@ -86,6 +86,7 @@ module M365ActiveStorage
86
86
  # @option options [String] :secret The Azure AD client secret
87
87
  # @option options [String] :site_id The SharePoint site ID
88
88
  # @option options [String] :drive_id The SharePoint drive ID
89
+ # @option options [String] :storage_key The SharePoint storage key
89
90
  #
90
91
  # @raise [KeyError] if any required parameter is missing or empty
91
92
  #
@@ -98,7 +99,7 @@ module M365ActiveStorage
98
99
  #
99
100
  # @see #validate_configuration!
100
101
  def initialize(**options)
101
- fetch_configuration_params(options)
102
+ fetch_configuration_params(options.with_indifferent_access)
102
103
  validate_configuration!
103
104
  rescue KeyError => e
104
105
  raise KeyError, "Configuration error: #{e.message}"
@@ -114,7 +115,6 @@ module M365ActiveStorage
114
115
  # @return [void]
115
116
  # @raise [KeyError] if any required parameter is missing
116
117
  def fetch_configuration_params(options)
117
- options = options.with_indifferent_access
118
118
  @auth_host = options.fetch(:auth_host)
119
119
  @tenant_id = options.fetch(:tenant_id)
120
120
  @app_id = options.fetch(:app_id)
@@ -124,6 +124,7 @@ module M365ActiveStorage
124
124
  @site_id = options.fetch(:site_id)
125
125
  @drive_id = options.fetch(:drive_id)
126
126
  @ms_graph_endpoint = "#{@ms_graph_url}/#{@ms_graph_version}"
127
+ @storage_key = options.fetch(:storage_key) || "key"
127
128
  end
128
129
 
129
130
  # Validate that all required configuration parameters are present and non-empty
@@ -142,12 +142,15 @@ module ActiveStorage
142
142
  #
143
143
  # @see #get_storage_name
144
144
  # @see #handle_upload_response
145
+ # @see #ensure_folder_path
145
146
  def upload(key, io, **)
146
147
  auth.ensure_valid_token
147
- storage_name = get_storage_name(key)
148
- upload_url = "#{drive_url}/root:/#{CGI.escape(storage_name)}:/content"
148
+ folder_path = sharepoint_folder_for(key)
149
+ ensure_folder_path(folder_path)
150
+ storage_path = build_storage_path(get_storage_name(key), folder_path)
151
+ upload_url = "#{drive_url}/root:/#{encode_storage_path(storage_path)}:/content"
149
152
  response = http.put(upload_url, io.read, { "Content-Type": "application/octet-stream" })
150
- handle_upload_response(response)
153
+ handle_upload_response(key, response)
151
154
  end
152
155
 
153
156
  # Download a file from SharePoint
@@ -185,9 +188,11 @@ module ActiveStorage
185
188
  # @see #download
186
189
  def fetch_download(key)
187
190
  auth.ensure_valid_token
188
- storage_name = get_storage_name(key)
189
- download_url = "#{drive_url}/root:/#{CGI.escape(storage_name)}:/content"
190
- http.get(download_url)
191
+ download_url = sharepoint_content_url_for(key)
192
+ response = http.get(download_url)
193
+ return response unless should_retry_with_path_url?(key, response)
194
+
195
+ http.get(legacy_content_url_for(key))
191
196
  end
192
197
 
193
198
  # Handle the HTTP response from a download request
@@ -249,20 +254,25 @@ module ActiveStorage
249
254
  #
250
255
  # @see #download_chunk
251
256
  def fetch_chunk(key, range)
252
- storage_name = get_storage_name(key)
253
- download_url = "#{drive_url}/root:/#{CGI.escape(storage_name)}:/content"
254
- http.get(download_url, { "Range": "bytes=#{range.begin}-#{range.end}" })
257
+ download_url = sharepoint_content_url_for(key)
258
+ response = http.get(download_url, { "Range": "bytes=#{range.begin}-#{range.end}" })
259
+ return response unless should_retry_with_path_url?(key, response)
260
+
261
+ http.get(legacy_content_url_for(key), { "Range": "bytes=#{range.begin}-#{range.end}" })
255
262
  end
256
263
 
257
264
  # Delete a file from SharePoint
258
265
  #
259
- # Removes a file from the SharePoint drive. Requires the filename to be available
260
- # from the PendingDelete registry (set before the blob was deleted).
266
+ # Removes a file from the SharePoint drive.
267
+ #
268
+ # It prefers deletion by SharePoint item ID and falls back to filename when
269
+ # the item ID is not available. When the blob record has already been deleted,
270
+ # it reads pending data from PendingDelete.
261
271
  #
262
272
  # @param [String] key The blob key to delete
263
273
  # @return [Boolean] true if deletion was successful (204 response)
264
274
  #
265
- # @raise [StandardError] if filename not found or deletion fails
275
+ # @raise [StandardError] if identifier not found or deletion fails
266
276
  #
267
277
  # @example
268
278
  # success = service.delete("key123") # => true
@@ -271,12 +281,10 @@ module ActiveStorage
271
281
  # @see M365ActiveStorage::Railtie
272
282
  def delete(key)
273
283
  auth.ensure_valid_token
274
- # get the filename from the pending deletes storage
275
- # because once the blob is deleted, we can no longer get the filename from the blob record
276
- storage_name = M365ActiveStorage::PendingDelete.get(key)
277
- raise "Filename not found for key #{key}. Cannot delete file from SharePoint." unless storage_name
278
284
 
279
- delete_url = "#{drive_url}/root:/#{CGI.escape(storage_name)}"
285
+ delete_url = sharepoint_delete_url_for(key)
286
+ raise "Identifier not found for key #{key}. Cannot delete file from SharePoint." unless delete_url
287
+
280
288
  response = http.delete(delete_url)
281
289
  response.code.to_i == 204
282
290
  end
@@ -301,8 +309,7 @@ module ActiveStorage
301
309
  # service.exist?("key123") # => true or false
302
310
  def exist?(key)
303
311
  auth.ensure_valid_token
304
- storage_name = get_storage_name(key)
305
- check_url = "#{drive_url}/root:/#{CGI.escape(storage_name)}"
312
+ check_url = sharepoint_item_url_for(key)
306
313
  response = http.get(check_url)
307
314
  response.code.to_i == 200
308
315
  end
@@ -353,16 +360,231 @@ module ActiveStorage
353
360
  # Validates that the upload succeeded (201 Created or 200 OK).
354
361
  # Raises an error for any other status code.
355
362
  #
363
+ # @param [String] key The Active Storage blob key associated with the upload
356
364
  # @param [Net::HTTPResponse] response The HTTP response from the upload
357
365
  # @return [void]
358
366
  #
359
367
  # @raise [StandardError] if upload failed
360
- def handle_upload_response(response)
361
- return if [201, 200].include?(response.code.to_i)
368
+ def handle_upload_response(key, response)
369
+ if [201, 200].include?(response.code.to_i)
370
+ persist_sharepoint_id(key, response)
371
+ return
372
+ end
362
373
 
363
374
  raise "Failed to upload file to SharePoint"
364
375
  end
365
376
 
377
+ # Persist the SharePoint item id in blob metadata after successful upload.
378
+ #
379
+ # Stored keys:
380
+ # * metadata["sharepoint_id"] - convenience flat key for quick access
381
+ # * metadata["sharepoint"]["id"] - namespaced SharePoint metadata
382
+ #
383
+ # @param [String] key The Active Storage blob key
384
+ # @param [Net::HTTPResponse] response The successful upload response body
385
+ # @return [void]
386
+ def persist_sharepoint_id(key, response)
387
+ return if response.body.to_s.strip.empty?
388
+
389
+ payload = JSON.parse(response.body)
390
+ sharepoint_id = payload["id"]
391
+ return if sharepoint_id.to_s.empty?
392
+
393
+ blob = ActiveStorage::Blob.find_by(key: key)
394
+ return unless blob
395
+
396
+ metadata = blob.metadata.is_a?(Hash) ? blob.metadata.dup : {}
397
+ sharepoint_metadata = metadata["sharepoint"].is_a?(Hash) ? metadata["sharepoint"].dup : {}
398
+
399
+ sharepoint_metadata["id"] = sharepoint_id
400
+ metadata["sharepoint"] = sharepoint_metadata
401
+ metadata["sharepoint_id"] = sharepoint_id
402
+
403
+ blob.update_columns(metadata: metadata)
404
+ rescue JSON::ParserError
405
+ nil
406
+ end
407
+
408
+ # Resolve the SharePoint content URL for a blob key.
409
+ #
410
+ # Preference order:
411
+ # 1) Blob metadata sharepoint_id (stable item reference)
412
+ # 2) Filename/key path fallback for backwards compatibility
413
+ #
414
+ # @param [String] key The Active Storage blob key
415
+ # @return [String] The content URL to download from SharePoint
416
+ def sharepoint_content_url_for(key)
417
+ sharepoint_id = sharepoint_item_id_for(key)
418
+ return "#{drive_url}/items/#{CGI.escape(sharepoint_id)}/content" if sharepoint_id.present?
419
+
420
+ legacy_content_url_for(key)
421
+ end
422
+
423
+ # Build the legacy path-based content URL for a blob key.
424
+ #
425
+ # Includes folder path when present in blob metadata.
426
+ #
427
+ # @param [String] key The Active Storage blob key
428
+ # @return [String] Path-based content URL
429
+ def legacy_content_url_for(key)
430
+ "#{drive_url}/root:/#{encode_storage_path(get_storage_path(key))}:/content"
431
+ end
432
+
433
+ # Retry with path URL only when ID-based lookup was attempted and failed.
434
+ #
435
+ # @param [String] key The Active Storage blob key
436
+ # @param [Net::HTTPResponse] response The response from the ID-based request
437
+ # @return [Boolean] true when a path fallback should be attempted
438
+ def should_retry_with_path_url?(key, response)
439
+ sharepoint_item_id_for(key).present? && [400, 404].include?(response.code.to_i)
440
+ end
441
+
442
+ # Resolve the SharePoint item URL for a blob key.
443
+ #
444
+ # Preference order:
445
+ # 1) Blob metadata sharepoint_id
446
+ # 2) Filename/key path fallback
447
+ #
448
+ # @param [String] key The Active Storage blob key
449
+ # @return [String] SharePoint item URL (without /content)
450
+ def sharepoint_item_url_for(key)
451
+ sharepoint_id = sharepoint_item_id_for(key)
452
+ return "#{drive_url}/items/#{CGI.escape(sharepoint_id)}" if sharepoint_id.present?
453
+
454
+ "#{drive_url}/root:/#{encode_storage_path(get_storage_path(key))}"
455
+ end
456
+
457
+ # Resolve the SharePoint delete URL for a blob key.
458
+ #
459
+ # Since blob records may already be deleted when Active Storage calls #delete,
460
+ # this method also checks PendingDelete data captured in before_destroy.
461
+ #
462
+ # @param [String] key The Active Storage blob key
463
+ # @return [String, nil] URL to delete, or nil when no identifier could be resolved
464
+ def sharepoint_delete_url_for(key)
465
+ sharepoint_id = sharepoint_item_id_for(key)
466
+ return "#{drive_url}/items/#{CGI.escape(sharepoint_id)}" if sharepoint_id.present?
467
+
468
+ pending_delete = M365ActiveStorage::PendingDelete.get(key)
469
+ if pending_delete.is_a?(Hash)
470
+ pending_sharepoint_id = pending_delete["sharepoint_id"] || pending_delete[:sharepoint_id]
471
+ return "#{drive_url}/items/#{CGI.escape(pending_sharepoint_id)}" if pending_sharepoint_id.present?
472
+
473
+ storage_name = pending_delete["filename"] || pending_delete[:filename]
474
+ folder_path = pending_delete["sharepoint_folder"] || pending_delete[:sharepoint_folder]
475
+ storage_path = build_storage_path(storage_name, folder_path)
476
+ else
477
+ storage_path = pending_delete
478
+ end
479
+
480
+ storage_path = key if storage_path.blank? && @config.storage_key.downcase == "key"
481
+ return nil if storage_path.blank?
482
+
483
+ "#{drive_url}/root:/#{encode_storage_path(storage_path)}"
484
+ end
485
+
486
+ # Read the SharePoint folder path from blob metadata.
487
+ #
488
+ # The app sets this before attaching a file:
489
+ #
490
+ # blob.metadata["sharepoint_folder"] = "documents/invoices"
491
+ # record.file.attach(io: file, filename: "doc.pdf",
492
+ # metadata: { "sharepoint_folder" => "documents/invoices" })
493
+ #
494
+ # Supports nested paths: "level1/level2/level3"
495
+ #
496
+ # @param [String] key The Active Storage blob key
497
+ # @return [String, nil] Folder path or nil when not set
498
+ def sharepoint_folder_for(key)
499
+ blob = ActiveStorage::Blob.find_by(key: key)
500
+ return nil unless blob&.metadata.is_a?(Hash)
501
+
502
+ blob.metadata["sharepoint_folder"].presence
503
+ end
504
+
505
+ # Build the full SharePoint storage path combining folder and filename.
506
+ #
507
+ # @param [String] filename The file name
508
+ # @param [String, nil] folder_path Optional folder path (e.g. "docs/invoices")
509
+ # @return [String] Combined path (e.g. "docs/invoices/document.pdf")
510
+ def build_storage_path(filename, folder_path = nil)
511
+ return filename if folder_path.blank?
512
+
513
+ "#{folder_path.to_s.chomp('/')}/#{filename}"
514
+ end
515
+
516
+ # Get the full storage path (folder + filename) for a blob key.
517
+ #
518
+ # @param [String] key The Active Storage blob key
519
+ # @return [String] Full storage path
520
+ def get_storage_path(key)
521
+ build_storage_path(get_storage_name(key), sharepoint_folder_for(key))
522
+ end
523
+
524
+ # Encode a storage path for use in a Microsoft Graph URL.
525
+ #
526
+ # Each path segment is CGI-encoded individually so that "/" separators
527
+ # are preserved and not encoded as "%2F".
528
+ #
529
+ # @param [String] path The path to encode (e.g. "my folder/sub folder/file.pdf")
530
+ # @return [String] URL-safe encoded path (e.g. "my+folder/sub+folder/file.pdf")
531
+ def encode_storage_path(path)
532
+ path.split("/").map { |s| CGI.escape(s) }.join("/")
533
+ end
534
+
535
+ # Ensure the full folder hierarchy exists in SharePoint.
536
+ #
537
+ # Walks through each segment of the path from root down, creating
538
+ # any folder that does not yet exist. Existing folders are silently skipped.
539
+ #
540
+ # @param [String, nil] folder_path The folder path to create (e.g. "docs/invoices/2024")
541
+ # @return [void]
542
+ def ensure_folder_path(folder_path)
543
+ return if folder_path.blank?
544
+
545
+ segments = folder_path.split("/").reject(&:blank?)
546
+ segments.each_with_index do |segment, index|
547
+ parent_path = segments[0...index].join("/")
548
+ create_sharepoint_folder(parent_path, segment)
549
+ end
550
+ end
551
+
552
+ # Create a single folder in SharePoint.
553
+ #
554
+ # Uses conflictBehavior "fail" and treats a 409 Conflict response as a
555
+ # no-op so the call is idempotent — safe to call even when the folder
556
+ # already exists.
557
+ #
558
+ # @param [String] parent_path Parent folder path relative to drive root
559
+ # (empty string means drive root)
560
+ # @param [String] folder_name The name of the folder to create
561
+ # @return [void]
562
+ def create_sharepoint_folder(parent_path, folder_name)
563
+ url = if parent_path.present?
564
+ "#{drive_url}/root:/#{encode_storage_path(parent_path)}:/children"
565
+ else
566
+ "#{drive_url}/root/children"
567
+ end
568
+ body = { name: folder_name, folder: {}, "@microsoft.graph.conflictBehavior": "fail" }.to_json
569
+ http.post(url, body, { "Content-Type": "application/json" })
570
+ # 201 = created, 409 = already exists — both are acceptable outcomes
571
+ end
572
+
573
+ # Extract SharePoint item id from blob metadata.
574
+ #
575
+ # Supported metadata keys:
576
+ # * metadata["sharepoint_id"]
577
+ # * metadata["sharepoint"]["id"]
578
+ #
579
+ # @param [String] key The Active Storage blob key
580
+ # @return [String, nil] SharePoint item id if present
581
+ def sharepoint_item_id_for(key)
582
+ blob = ActiveStorage::Blob.find_by(key: key)
583
+ return nil unless blob&.metadata.is_a?(Hash)
584
+
585
+ blob.metadata["sharepoint_id"].presence || blob.metadata.dig("sharepoint", "id").presence
586
+ end
587
+
366
588
  # Get the storage name for a blob key
367
589
  #
368
590
  # Tries to use the blob's filename if available, otherwise falls back to the key.
@@ -376,6 +598,8 @@ module ActiveStorage
376
598
  # # If blob exists: => "document.pdf"
377
599
  # # If blob doesn't exist: => "abc123def456"
378
600
  def get_storage_name(key)
601
+ return key if @config.storage_key.downcase == "key"
602
+
379
603
  blob = ActiveStorage::Blob.find_by(key: key)
380
604
  return key unless blob.present? && blob.filename.present?
381
605
 
@@ -31,9 +31,9 @@ module M365ActiveStorage
31
31
  # === File Deletion Flow
32
32
  #
33
33
  # When a blob is destroyed and it's using the SharePoint service:
34
- # 1. The before_destroy callback captures the blob's filename
34
+ # 1. The before_destroy callback captures blob metadata needed for deletion
35
35
  # 2. Stores it in PendingDelete for later retrieval
36
- # 3. The filename can be used by the async deletion worker to clean up SharePoint
36
+ # 3. The async deletion worker can delete by SharePoint ID or filename fallback
37
37
  #
38
38
  # @see M365ActiveStorage::Files
39
39
  # @see M365ActiveStorage::PendingDelete
@@ -41,22 +41,30 @@ module M365ActiveStorage
41
41
  class Railtie < ::Rails::Railtie
42
42
  # Hook into Rails initialization to set up gem components
43
43
  config.after_initialize do
44
- # Add before_destroy callback to ActiveStorage::Blob to capture filename
44
+ # Add before_destroy callback to ActiveStorage::Blob to capture deletion identifiers
45
45
  ::ActiveStorage::Blob.class_eval do
46
46
  before_destroy :store_filename_for_deletion, if: proc { |blob| blob.service.is_a?(::ActiveStorage::Service::SharepointService) }
47
47
 
48
48
  private
49
49
 
50
- # Store the filename in the pending deletes storage before the blob is destroyed
50
+ # Store deletion identifiers in the pending deletes storage before the blob is destroyed
51
51
  #
52
52
  # This callback is triggered before a blob is destroyed from the database.
53
- # It captures the blob's filename and stores it in PendingDelete so that
53
+ # It captures the blob's filename and SharePoint item ID (if present)
54
+ # and stores them in PendingDelete so that
54
55
  # asynchronous deletion processes can move the file to the recycle bin
55
56
  # in SharePoint.
56
57
  #
57
58
  # @return [void]
58
59
  def store_filename_for_deletion
59
- M365ActiveStorage::PendingDelete.store(key, filename.to_s)
60
+ blob_metadata = metadata.is_a?(Hash) ? metadata : {}
61
+ sharepoint_id = blob_metadata["sharepoint_id"].presence || blob_metadata.dig("sharepoint", "id").presence
62
+ pending_delete_data = {
63
+ "filename" => filename.to_s,
64
+ "sharepoint_id" => sharepoint_id,
65
+ "sharepoint_folder" => blob_metadata["sharepoint_folder"].presence
66
+ }
67
+ M365ActiveStorage::PendingDelete.store(key, pending_delete_data)
60
68
  end
61
69
  end
62
70
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module M365ActiveStorage
4
4
  # The version of the m365_active_storage gem
5
- VERSION = "1.0.0"
5
+ VERSION = "1.1.0"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: m365_active_storage
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Óscar León