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 +4 -4
- data/CHANGELOG.md +24 -2
- data/README.md +35 -0
- data/config/storage.yml +2 -1
- data/lib/active_storage/configuration.rb +4 -3
- data/lib/active_storage/service/sharepoint_service.rb +246 -27
- data/lib/m365_active_storage/controllers/blobs_controller.rb +2 -0
- data/lib/m365_active_storage/files.rb +13 -2
- data/lib/m365_active_storage/railtie.rb +32 -6
- data/lib/m365_active_storage/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72492311b32ca0b097c13fdec2e254d43006191dfeaa4a6d2245640ac7cd0b97
|
|
4
|
+
data.tar.gz: 281c7e93c835fb9c8bab29d29f0fc9efdb066c44457327ddd4be5eb71592cf63
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0ecc16539d70ec002cb69f6aa1dde824e52ad6e51ec2ba2ea76ff0e9cc54de1aa714098f3ac301784489db932d3390cd93e158f6c5f38898592877a3350f1c55
|
|
7
|
+
data.tar.gz: 8d73cd54cec9435e1db2b3d7ac7583430d3fe450c83856dccf67fef018d91fcb162516f60cb15071f462855918d86c28c6d891d26e8ef46f564107908d46655b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
|
-
## [
|
|
1
|
+
## [1.1.1] - 2026-04-10
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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.
|
|
260
|
-
#
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
354
|
-
#
|
|
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 [
|
|
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
|
-
# @
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
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.
|
|
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:
|