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 +4 -4
- 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 +245 -21
- data/lib/m365_active_storage/railtie.rb +14 -6
- data/lib/m365_active_storage/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d083b38ef3eacb7fa4d749c7c8a1de3c11fd3e8e86350d11c9533f9f295034e
|
|
4
|
+
data.tar.gz: b8db90e6bd4d2f16c5664ae495cdf4efed60af27fe42bb6823dc4d68bd729e73
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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.
|
|
260
|
-
#
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|