m365_active_storage 1.0.0 → 1.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 248f6e95435a9737f6aa06db40f6d758a59a4d6355ad4399a12d5dbc0d24efde
4
- data.tar.gz: c93ea5f7c16c419e2cfadbc9ce6a679b1a0abb533bb9863b9a75f8b7aed7d17d
3
+ metadata.gz: 72492311b32ca0b097c13fdec2e254d43006191dfeaa4a6d2245640ac7cd0b97
4
+ data.tar.gz: 281c7e93c835fb9c8bab29d29f0fc9efdb066c44457327ddd4be5eb71592cf63
5
5
  SHA512:
6
- metadata.gz: 91d7ddd980e2959d4c834847affd9b515f1544c307fa00d562bf6c15b3754fa3c1d657ed648c03eb285eb7cd3a98daf75dc23c5c888bc88d1914fc19cd249528
7
- data.tar.gz: d8cedd464a2fb9946f778b79b3113e1176aa4d444d56b271bfda1378a386a3765c21850279325a78b3af1a3c5547b5f85a3fe42857652e9b002e3d814cace891
6
+ metadata.gz: 0ecc16539d70ec002cb69f6aa1dde824e52ad6e51ec2ba2ea76ff0e9cc54de1aa714098f3ac301784489db932d3390cd93e158f6c5f38898592877a3350f1c55
7
+ data.tar.gz: 8d73cd54cec9435e1db2b3d7ac7583430d3fe450c83856dccf67fef018d91fcb162516f60cb15071f462855918d86c28c6d891d26e8ef46f564107908d46655b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
- ## [Unreleased]
1
+ ## [1.1.1] - 2026-04-10
2
2
 
3
- ## [0.1.0] - 2026-01-13
3
+ ### Bug Fixes
4
+
5
+ - Fix controller class discovery in `M365ActiveStorage::Files` so only controllers from the active gem installation are loaded, avoiding invalid constantization and inconsistent behavior when multiple gem copies or versions are present on the load path.
6
+ - Fix credential-gated test skipping in `test_helper.rb` to avoid constant lookup errors by comparing class names instead of class constants, making skip behavior stable regardless of test load order.
7
+ - Fix SharePoint path encoding to produce valid Graph URLs by encoding spaces as `%20` (not `+`), ensuring file and folder paths with spaces are resolved correctly.
8
+ - Fix SharePoint ID not being persisted when blob is created with custom metadata (e.g., `sharepoint_folder`). ID persistence is now deferred to an `after_commit` hook to prevent being overwritten by Active Storage's blob save.
9
+
10
+ ### Security
11
+
12
+ - Add explicit CSRF protection (`protect_from_forgery`) to `M365ActiveStorage::BlobsController` to address Brakeman security warning and enforce forgery protection posture.
13
+
14
+ ## [1.1.0] - 2026-03-25
15
+
16
+ ### Features
17
+
18
+ - Add support for organizing files in nested SharePoint folders via `sharepoint_folder` metadata on blob attachments.
19
+ - Allow configurable storage key to use either blob key or filename for file storage organization in SharePoint.
20
+
21
+ ### Security
22
+
23
+ - Patch Stored XSS vulnerability in `action_text-trix` dependency.
24
+
25
+ ## [1.0.0] - 2026-03-16
4
26
 
5
27
  - Initial release
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
@@ -129,6 +129,8 @@ module ActiveStorage
129
129
  #
130
130
  # Uploads file content to the configured SharePoint drive.
131
131
  # The file is stored with the blob's filename for better organization in SharePoint.
132
+ # The SharePoint item ID is persisted asynchronously in an after_commit hook
133
+ # to avoid being overwritten by Active Storage's blob.save.
132
134
  #
133
135
  # @param [String] key The Active Storage blob key (ignored, filename used instead)
134
136
  # @param [IO] io The file content as an IO object
@@ -142,12 +144,18 @@ module ActiveStorage
142
144
  #
143
145
  # @see #get_storage_name
144
146
  # @see #handle_upload_response
147
+ # @see #ensure_folder_path
145
148
  def upload(key, io, **)
146
149
  auth.ensure_valid_token
147
- storage_name = get_storage_name(key)
148
- upload_url = "#{drive_url}/root:/#{CGI.escape(storage_name)}:/content"
150
+ folder_path = sharepoint_folder_for(key)
151
+ ensure_folder_path(folder_path)
152
+ storage_path = build_storage_path(get_storage_name(key), folder_path)
153
+ upload_url = "#{drive_url}/root:/#{encode_storage_path(storage_path)}:/content"
149
154
  response = http.put(upload_url, io.read, { "Content-Type": "application/octet-stream" })
150
- handle_upload_response(response)
155
+ raise "Failed to upload file to SharePoint" unless [201, 200].include?(response.code.to_i)
156
+
157
+ # Store response body payload in thread-local storage for later retrieval in after_commit hook
158
+ Thread.current[:sharepoint_upload_response] = response
151
159
  end
152
160
 
153
161
  # Download a file from SharePoint
@@ -185,9 +193,11 @@ module ActiveStorage
185
193
  # @see #download
186
194
  def fetch_download(key)
187
195
  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)
196
+ download_url = sharepoint_content_url_for(key)
197
+ response = http.get(download_url)
198
+ return response unless should_retry_with_path_url?(key, response)
199
+
200
+ http.get(legacy_content_url_for(key))
191
201
  end
192
202
 
193
203
  # Handle the HTTP response from a download request
@@ -249,20 +259,25 @@ module ActiveStorage
249
259
  #
250
260
  # @see #download_chunk
251
261
  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}" })
262
+ download_url = sharepoint_content_url_for(key)
263
+ response = http.get(download_url, { "Range": "bytes=#{range.begin}-#{range.end}" })
264
+ return response unless should_retry_with_path_url?(key, response)
265
+
266
+ http.get(legacy_content_url_for(key), { "Range": "bytes=#{range.begin}-#{range.end}" })
255
267
  end
256
268
 
257
269
  # Delete a file from SharePoint
258
270
  #
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).
271
+ # Removes a file from the SharePoint drive.
272
+ #
273
+ # It prefers deletion by SharePoint item ID and falls back to filename when
274
+ # the item ID is not available. When the blob record has already been deleted,
275
+ # it reads pending data from PendingDelete.
261
276
  #
262
277
  # @param [String] key The blob key to delete
263
278
  # @return [Boolean] true if deletion was successful (204 response)
264
279
  #
265
- # @raise [StandardError] if filename not found or deletion fails
280
+ # @raise [StandardError] if identifier not found or deletion fails
266
281
  #
267
282
  # @example
268
283
  # success = service.delete("key123") # => true
@@ -271,12 +286,10 @@ module ActiveStorage
271
286
  # @see M365ActiveStorage::Railtie
272
287
  def delete(key)
273
288
  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
289
 
279
- delete_url = "#{drive_url}/root:/#{CGI.escape(storage_name)}"
290
+ delete_url = sharepoint_delete_url_for(key)
291
+ raise "Identifier not found for key #{key}. Cannot delete file from SharePoint." unless delete_url
292
+
280
293
  response = http.delete(delete_url)
281
294
  response.code.to_i == 204
282
295
  end
@@ -301,8 +314,7 @@ module ActiveStorage
301
314
  # service.exist?("key123") # => true or false
302
315
  def exist?(key)
303
316
  auth.ensure_valid_token
304
- storage_name = get_storage_name(key)
305
- check_url = "#{drive_url}/root:/#{CGI.escape(storage_name)}"
317
+ check_url = sharepoint_item_url_for(key)
306
318
  response = http.get(check_url)
307
319
  response.code.to_i == 200
308
320
  end
@@ -348,19 +360,224 @@ module ActiveStorage
348
360
  "#{config.ms_graph_endpoint}/sites/#{config.site_id}/drives/#{config.drive_id}"
349
361
  end
350
362
 
351
- # Handle the response from an upload request
363
+
364
+
365
+ # Persist the SharePoint item id in blob metadata from a stored upload response.
366
+ #
367
+ # This is called by the Railtie's after_commit hook to store the SharePoint ID
368
+ # after the blob has been fully saved to the database, preventing the ID from
369
+ # being overwritten by Active Storage's metadata save.
370
+ #
371
+ # Stored keys:
372
+ # * metadata["sharepoint_id"] - convenience flat key for quick access
373
+ # * metadata["sharepoint"]["id"] - namespaced SharePoint metadata
374
+ #
375
+ # @param [String] key The Active Storage blob key
376
+ # @param [Net::HTTPResponse, String] response The upload response or JSON payload string
377
+ # @return [void]
378
+
379
+ def persist_sharepoint_id_from_response(key, response)
380
+ body = response.is_a?(String) ? response : response.body.to_s
381
+ return if body.to_s.strip.empty?
382
+
383
+ payload = JSON.parse(body)
384
+ sharepoint_id = payload["id"]
385
+ return if sharepoint_id.to_s.empty?
386
+
387
+ blob = ActiveStorage::Blob.find_by(key: key)
388
+ return unless blob
389
+
390
+ metadata = blob.metadata.is_a?(Hash) ? blob.metadata.dup : {}
391
+ sharepoint_metadata = metadata["sharepoint"].is_a?(Hash) ? metadata["sharepoint"].dup : {}
392
+
393
+ sharepoint_metadata["id"] = sharepoint_id
394
+ metadata["sharepoint"] = sharepoint_metadata
395
+ metadata["sharepoint_id"] = sharepoint_id
396
+
397
+ blob.update_columns(metadata: metadata)
398
+ rescue JSON::ParserError
399
+ nil
400
+ end
401
+
402
+ # Resolve the SharePoint content URL for a blob key.
403
+ #
404
+ # Preference order:
405
+ # 1) Blob metadata sharepoint_id (stable item reference)
406
+ # 2) Filename/key path fallback for backwards compatibility
407
+ #
408
+ # @param [String] key The Active Storage blob key
409
+ # @return [String] The content URL to download from SharePoint
410
+ def sharepoint_content_url_for(key)
411
+ sharepoint_id = sharepoint_item_id_for(key)
412
+ return "#{drive_url}/items/#{CGI.escape(sharepoint_id)}/content" if sharepoint_id.present?
413
+
414
+ legacy_content_url_for(key)
415
+ end
416
+
417
+ # Build the legacy path-based content URL for a blob key.
418
+ #
419
+ # Includes folder path when present in blob metadata.
420
+ #
421
+ # @param [String] key The Active Storage blob key
422
+ # @return [String] Path-based content URL
423
+ def legacy_content_url_for(key)
424
+ "#{drive_url}/root:/#{encode_storage_path(get_storage_path(key))}:/content"
425
+ end
426
+
427
+ # Retry with path URL only when ID-based lookup was attempted and failed.
428
+ #
429
+ # @param [String] key The Active Storage blob key
430
+ # @param [Net::HTTPResponse] response The response from the ID-based request
431
+ # @return [Boolean] true when a path fallback should be attempted
432
+ def should_retry_with_path_url?(key, response)
433
+ sharepoint_item_id_for(key).present? && [400, 404].include?(response.code.to_i)
434
+ end
435
+
436
+ # Resolve the SharePoint item URL for a blob key.
437
+ #
438
+ # Preference order:
439
+ # 1) Blob metadata sharepoint_id
440
+ # 2) Filename/key path fallback
441
+ #
442
+ # @param [String] key The Active Storage blob key
443
+ # @return [String] SharePoint item URL (without /content)
444
+ def sharepoint_item_url_for(key)
445
+ sharepoint_id = sharepoint_item_id_for(key)
446
+ return "#{drive_url}/items/#{CGI.escape(sharepoint_id)}" if sharepoint_id.present?
447
+
448
+ "#{drive_url}/root:/#{encode_storage_path(get_storage_path(key))}"
449
+ end
450
+
451
+ # Resolve the SharePoint delete URL for a blob key.
452
+ #
453
+ # Since blob records may already be deleted when Active Storage calls #delete,
454
+ # this method also checks PendingDelete data captured in before_destroy.
455
+ #
456
+ # @param [String] key The Active Storage blob key
457
+ # @return [String, nil] URL to delete, or nil when no identifier could be resolved
458
+ def sharepoint_delete_url_for(key)
459
+ sharepoint_id = sharepoint_item_id_for(key)
460
+ return "#{drive_url}/items/#{CGI.escape(sharepoint_id)}" if sharepoint_id.present?
461
+
462
+ pending_delete = M365ActiveStorage::PendingDelete.get(key)
463
+ if pending_delete.is_a?(Hash)
464
+ pending_sharepoint_id = pending_delete["sharepoint_id"] || pending_delete[:sharepoint_id]
465
+ return "#{drive_url}/items/#{CGI.escape(pending_sharepoint_id)}" if pending_sharepoint_id.present?
466
+
467
+ storage_name = pending_delete["filename"] || pending_delete[:filename]
468
+ folder_path = pending_delete["sharepoint_folder"] || pending_delete[:sharepoint_folder]
469
+ storage_path = build_storage_path(storage_name, folder_path)
470
+ else
471
+ storage_path = pending_delete
472
+ end
473
+
474
+ storage_path = key if storage_path.blank? && @config.storage_key.downcase == "key"
475
+ return nil if storage_path.blank?
476
+
477
+ "#{drive_url}/root:/#{encode_storage_path(storage_path)}"
478
+ end
479
+
480
+ # Read the SharePoint folder path from blob metadata.
481
+ #
482
+ # The app sets this before attaching a file:
483
+ #
484
+ # blob.metadata["sharepoint_folder"] = "documents/invoices"
485
+ # record.file.attach(io: file, filename: "doc.pdf",
486
+ # metadata: { "sharepoint_folder" => "documents/invoices" })
487
+ #
488
+ # Supports nested paths: "level1/level2/level3"
489
+ #
490
+ # @param [String] key The Active Storage blob key
491
+ # @return [String, nil] Folder path or nil when not set
492
+ def sharepoint_folder_for(key)
493
+ blob = ActiveStorage::Blob.find_by(key: key)
494
+ return nil unless blob&.metadata.is_a?(Hash)
495
+
496
+ blob.metadata["sharepoint_folder"].presence
497
+ end
498
+
499
+ # Build the full SharePoint storage path combining folder and filename.
352
500
  #
353
- # Validates that the upload succeeded (201 Created or 200 OK).
354
- # Raises an error for any other status code.
501
+ # @param [String] filename The file name
502
+ # @param [String, nil] folder_path Optional folder path (e.g. "docs/invoices")
503
+ # @return [String] Combined path (e.g. "docs/invoices/document.pdf")
504
+ def build_storage_path(filename, folder_path = nil)
505
+ return filename if folder_path.blank?
506
+
507
+ "#{folder_path.to_s.chomp('/')}/#{filename}"
508
+ end
509
+
510
+ # Get the full storage path (folder + filename) for a blob key.
355
511
  #
356
- # @param [Net::HTTPResponse] response The HTTP response from the upload
512
+ # @param [String] key The Active Storage blob key
513
+ # @return [String] Full storage path
514
+ def get_storage_path(key)
515
+ build_storage_path(get_storage_name(key), sharepoint_folder_for(key))
516
+ end
517
+
518
+ # Encode a storage path for use in a Microsoft Graph URL.
519
+ #
520
+ # Each path segment is CGI-encoded individually so that "/" separators
521
+ # are preserved and not encoded as "%2F".
522
+ # Spaces are encoded as "%20" (not "+") to ensure proper URL path handling.
523
+ #
524
+ # @param [String] path The path to encode (e.g. "my folder/sub folder/file.pdf")
525
+ # @return [String] URL-safe encoded path (e.g. "my%20folder/sub%20folder/file.pdf")
526
+ def encode_storage_path(path)
527
+ path.split("/").map { |s| CGI.escape(s).gsub("+", "%20") }.join("/")
528
+ end
529
+
530
+ # Ensure the full folder hierarchy exists in SharePoint.
531
+ #
532
+ # Walks through each segment of the path from root down, creating
533
+ # any folder that does not yet exist. Existing folders are silently skipped.
534
+ #
535
+ # @param [String, nil] folder_path The folder path to create (e.g. "docs/invoices/2024")
357
536
  # @return [void]
537
+ def ensure_folder_path(folder_path)
538
+ return if folder_path.blank?
539
+
540
+ segments = folder_path.split("/").reject(&:blank?)
541
+ segments.each_with_index do |segment, index|
542
+ parent_path = segments[0...index].join("/")
543
+ create_sharepoint_folder(parent_path, segment)
544
+ end
545
+ end
546
+
547
+ # Create a single folder in SharePoint.
548
+ #
549
+ # Uses conflictBehavior "fail" and treats a 409 Conflict response as a
550
+ # no-op so the call is idempotent — safe to call even when the folder
551
+ # already exists.
358
552
  #
359
- # @raise [StandardError] if upload failed
360
- def handle_upload_response(response)
361
- return if [201, 200].include?(response.code.to_i)
553
+ # @param [String] parent_path Parent folder path relative to drive root
554
+ # (empty string means drive root)
555
+ # @param [String] folder_name The name of the folder to create
556
+ # @return [void]
557
+ def create_sharepoint_folder(parent_path, folder_name)
558
+ url = if parent_path.present?
559
+ "#{drive_url}/root:/#{encode_storage_path(parent_path)}:/children"
560
+ else
561
+ "#{drive_url}/root/children"
562
+ end
563
+ body = { name: folder_name, folder: {}, "@microsoft.graph.conflictBehavior": "fail" }.to_json
564
+ http.post(url, body, { "Content-Type": "application/json" })
565
+ # 201 = created, 409 = already exists — both are acceptable outcomes
566
+ end
362
567
 
363
- raise "Failed to upload file to SharePoint"
568
+ # Extract SharePoint item id from blob metadata.
569
+ #
570
+ # Supported metadata keys:
571
+ # * metadata["sharepoint_id"]
572
+ # * metadata["sharepoint"]["id"]
573
+ #
574
+ # @param [String] key The Active Storage blob key
575
+ # @return [String, nil] SharePoint item id if present
576
+ def sharepoint_item_id_for(key)
577
+ blob = ActiveStorage::Blob.find_by(key: key)
578
+ return nil unless blob&.metadata.is_a?(Hash)
579
+
580
+ blob.metadata["sharepoint_id"].presence || blob.metadata.dig("sharepoint", "id").presence
364
581
  end
365
582
 
366
583
  # Get the storage name for a blob key
@@ -376,6 +593,8 @@ module ActiveStorage
376
593
  # # If blob exists: => "document.pdf"
377
594
  # # If blob doesn't exist: => "abc123def456"
378
595
  def get_storage_name(key)
596
+ return key if @config.storage_key.downcase == "key"
597
+
379
598
  blob = ActiveStorage::Blob.find_by(key: key)
380
599
  return key unless blob.present? && blob.filename.present?
381
600
 
@@ -43,6 +43,8 @@ module M365ActiveStorage
43
43
  # @see ActiveStorage::Blob
44
44
  # @see ActiveStorage::Service::SharepointService
45
45
  class BlobsController < ActionController::Base
46
+ protect_from_forgery with: :exception
47
+
46
48
  # Display/download a blob
47
49
  #
48
50
  # Retrieves a blob by its signed ID and sends it to the client with appropriate
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
4
+
3
5
  module M365ActiveStorage
4
6
  # == File Discovery and Loading
5
7
  #
@@ -52,8 +54,17 @@ module M365ActiveStorage
52
54
  # @see #controller_files
53
55
  def self.controller_classes
54
56
  controller_files.map do |path|
55
- path.remove("#{root}/lib/").remove("controllers/").remove(".rb").camelize.constantize
56
- end
57
+ # Extract the relative path from the gem root, then convert to class name
58
+ root_path = Pathname.new("#{root}/lib")
59
+ file_path = Pathname.new(path)
60
+
61
+ # Skip files that are not within the gem's lib directory (e.g., from other gem installations)
62
+ next nil if file_path.relative_path_from(root_path).to_s.start_with?("..")
63
+
64
+ relative_path = file_path.relative_path_from(root_path).to_s
65
+ class_name = relative_path.remove("controllers/").remove(".rb").camelize.constantize
66
+ class_name
67
+ end.compact
57
68
  end
58
69
  end
59
70
  end
@@ -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,48 @@ 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
+ # Add after_commit callback to persist SharePoint ID after blob is fully saved
45
46
  ::ActiveStorage::Blob.class_eval do
46
47
  before_destroy :store_filename_for_deletion, if: proc { |blob| blob.service.is_a?(::ActiveStorage::Service::SharepointService) }
48
+ after_commit :persist_sharepoint_id_from_thread_storage, on: [:create], if: proc { |blob| blob.service.is_a?(::ActiveStorage::Service::SharepointService) }
47
49
 
48
50
  private
49
51
 
50
- # Store the filename in the pending deletes storage before the blob is destroyed
52
+ # Store deletion identifiers in the pending deletes storage before the blob is destroyed
51
53
  #
52
54
  # 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
55
+ # It captures the blob's filename and SharePoint item ID (if present)
56
+ # and stores them in PendingDelete so that
54
57
  # asynchronous deletion processes can move the file to the recycle bin
55
58
  # in SharePoint.
56
59
  #
57
60
  # @return [void]
58
61
  def store_filename_for_deletion
59
- M365ActiveStorage::PendingDelete.store(key, filename.to_s)
62
+ blob_metadata = metadata.is_a?(Hash) ? metadata : {}
63
+ sharepoint_id = blob_metadata["sharepoint_id"].presence || blob_metadata.dig("sharepoint", "id").presence
64
+ pending_delete_data = {
65
+ "filename" => filename.to_s,
66
+ "sharepoint_id" => sharepoint_id,
67
+ "sharepoint_folder" => blob_metadata["sharepoint_folder"].presence
68
+ }
69
+ M365ActiveStorage::PendingDelete.store(key, pending_delete_data)
70
+ end
71
+
72
+ # Persist the SharePoint ID from the upload response after blob is saved
73
+ #
74
+ # This callback fires after the blob has been successfully committed to the database
75
+ # via the after_commit hook. It retrieves the upload response stored in thread-local
76
+ # storage by the service.upload method and persists the SharePoint ID to avoid
77
+ # being overwritten by Active Storage's blob.save.
78
+ #
79
+ # @return [void]
80
+ def persist_sharepoint_id_from_thread_storage
81
+ upload_response = Thread.current[:sharepoint_upload_response]
82
+ return unless upload_response.present?
83
+
84
+ Thread.current[:sharepoint_upload_response] = nil # Clean up thread storage
85
+ service.send(:persist_sharepoint_id_from_response, key, upload_response)
60
86
  end
61
87
  end
62
88
  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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Óscar León
@@ -51,7 +51,7 @@ metadata:
51
51
  allowed_push_host: https://rubygems.org
52
52
  homepage_uri: https://github.com/oei-int/m365-active-storage
53
53
  source_code_uri: https://github.com/oei-int/m365-active-storage
54
- changelog_uri: https://github.com/oei-int/m365-active-storage/CHANGELOG.md
54
+ changelog_uri: https://github.com/oei-int/m365-active-storage/blob/main/CHANGELOG.md
55
55
  documentation_uri: https://rubydoc.info/gems/m365_active_storage
56
56
  bug_tracker_uri: https://github.com/oei-int/m365-active-storage/issues
57
57
  rdoc_options: